Detailed changes
@@ -5,6 +5,8 @@ go 1.25.6
require (
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
github.com/chromedp/chromedp v0.14.1
+ github.com/coder/websocket v1.8.12
+ github.com/creack/pty v1.1.24
github.com/fynelabs/selfupdate v0.2.1
github.com/google/uuid v1.6.0
github.com/oklog/ulid/v2 v2.1.1
@@ -14,6 +14,8 @@ github.com/chromedp/chromedp v0.14.1 h1:0uAbnxewy/Q+Bg7oafVePE/6EXEho9hnaC38f+TT
github.com/chromedp/chromedp v0.14.1/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
+github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
+github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
@@ -0,0 +1,220 @@
+package server
+
+import (
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "syscall"
+ "unsafe"
+
+ "github.com/coder/websocket"
+ "github.com/coder/websocket/wsjson"
+ "github.com/creack/pty"
+)
+
+// ExecMessage is the message format for terminal websocket communication
+type ExecMessage struct {
+ Type string `json:"type"`
+ Data string `json:"data,omitempty"`
+ Cols uint16 `json:"cols,omitempty"`
+ Rows uint16 `json:"rows,omitempty"`
+}
+
+// handleExecWS handles websocket connections for executing shell commands
+// Query params:
+// - cmd: the command to execute (required)
+// - cwd: working directory (optional, defaults to current dir)
+func (s *Server) handleExecWS(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ cmd := r.URL.Query().Get("cmd")
+ if cmd == "" {
+ http.Error(w, "cmd parameter required", http.StatusBadRequest)
+ return
+ }
+
+ cwd := r.URL.Query().Get("cwd")
+ if cwd == "" {
+ var err error
+ cwd, err = os.Getwd()
+ if err != nil {
+ cwd = "/"
+ }
+ }
+
+ // Upgrade to websocket
+ conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
+ CompressionMode: websocket.CompressionDisabled,
+ })
+ if err != nil {
+ s.logger.Error("Failed to upgrade websocket", "error", err)
+ return
+ }
+ defer conn.Close(websocket.StatusInternalError, "internal error")
+
+ // Wait for init message with terminal size
+ var initMsg ExecMessage
+ if err := wsjson.Read(ctx, conn, &initMsg); err != nil {
+ s.logger.Error("Failed to read init message", "error", err)
+ conn.Close(websocket.StatusPolicyViolation, "no init message")
+ return
+ }
+
+ if initMsg.Type != "init" {
+ conn.Close(websocket.StatusPolicyViolation, "expected init message")
+ return
+ }
+
+ cols := initMsg.Cols
+ rows := initMsg.Rows
+ if cols == 0 {
+ cols = 80
+ }
+ if rows == 0 {
+ rows = 24
+ }
+
+ // Create command
+ shellCmd := exec.CommandContext(ctx, "bash", "-c", cmd)
+ shellCmd.Dir = cwd
+ shellCmd.Env = append(os.Environ(), "TERM=xterm-256color")
+
+ // Start with pty
+ ptmx, err := pty.StartWithSize(shellCmd, &pty.Winsize{
+ Cols: cols,
+ Rows: rows,
+ })
+ if err != nil {
+ s.logger.Error("Failed to start command with pty", "error", err, "cmd", cmd)
+ errMsg := ExecMessage{
+ Type: "error",
+ Data: err.Error(),
+ }
+ wsjson.Write(ctx, conn, errMsg)
+ conn.Close(websocket.StatusInternalError, "failed to start command")
+ return
+ }
+ defer ptmx.Close()
+
+ // Channel to signal when process exits with all output sent
+ done := make(chan error, 1)
+
+ // Read from pty and send to websocket, then wait for process and signal done
+ go func() {
+ // First, read all output from pty
+ buf := make([]byte, 4096)
+ for {
+ n, err := ptmx.Read(buf)
+ if n > 0 {
+ msg := ExecMessage{
+ Type: "output",
+ Data: base64.StdEncoding.EncodeToString(buf[:n]),
+ }
+ if wsjson.Write(ctx, conn, msg) != nil {
+ return
+ }
+ }
+ if err != nil {
+ if !errors.Is(err, io.EOF) {
+ s.logger.Debug("PTY read error", "error", err)
+ }
+ break
+ }
+ }
+ // After pty is closed/EOF, wait for process exit status
+ err := shellCmd.Wait()
+ done <- err
+ }()
+
+ // Create a channel for incoming websocket messages
+ msgChan := make(chan ExecMessage)
+ errChan := make(chan error)
+
+ // Goroutine to read websocket messages
+ go func() {
+ for {
+ var msg ExecMessage
+ if err := wsjson.Read(ctx, conn, &msg); err != nil {
+ errChan <- err
+ return
+ }
+ msgChan <- msg
+ }
+ }()
+
+ // Process messages and handle exit
+ for {
+ select {
+ case <-ctx.Done():
+ if shellCmd.Process != nil {
+ shellCmd.Process.Kill()
+ }
+ return
+ case exitErr := <-done:
+ // Process exited
+ exitCode := 0
+ if exitErr != nil {
+ if exitError, ok := exitErr.(*exec.ExitError); ok {
+ exitCode = exitError.ExitCode()
+ }
+ }
+ exitMsg := ExecMessage{
+ Type: "exit",
+ Data: fmt.Sprintf("%d", exitCode),
+ }
+ s.logger.Info("Sending exit message", "exitCode", exitCode)
+ if err := wsjson.Write(ctx, conn, exitMsg); err != nil {
+ s.logger.Error("Failed to write exit message", "error", err)
+ }
+ s.logger.Info("Closing websocket normally")
+ conn.Close(websocket.StatusNormalClosure, "process exited")
+ return
+ case err := <-errChan:
+ if websocket.CloseStatus(err) != websocket.StatusNormalClosure {
+ s.logger.Debug("Websocket read error", "error", err)
+ }
+ if shellCmd.Process != nil {
+ shellCmd.Process.Kill()
+ }
+ return
+ case msg := <-msgChan:
+ switch msg.Type {
+ case "input":
+ if msg.Data != "" {
+ ptmx.Write([]byte(msg.Data))
+ }
+ case "resize":
+ if msg.Cols > 0 && msg.Rows > 0 {
+ setWinsize(ptmx, msg.Cols, msg.Rows)
+ }
+ }
+ }
+ }
+}
+
+// setWinsize sets the terminal window size
+func setWinsize(f *os.File, cols, rows uint16) error {
+ ws := struct {
+ Rows uint16
+ Cols uint16
+ X uint16
+ Y uint16
+ }{
+ Rows: rows,
+ Cols: cols,
+ }
+ _, _, errno := syscall.Syscall(
+ syscall.SYS_IOCTL,
+ f.Fd(),
+ uintptr(syscall.TIOCSWINSZ),
+ uintptr(unsafe.Pointer(&ws)),
+ )
+ if errno != 0 {
+ return errno
+ }
+ return nil
+}
@@ -0,0 +1,267 @@
+package server
+
+import (
+ "context"
+ "encoding/base64"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/coder/websocket"
+ "github.com/coder/websocket/wsjson"
+)
+
+func TestExecTerminal_SimpleCommand(t *testing.T) {
+ h := NewTestHarness(t)
+ defer h.cleanup()
+
+ mux := http.NewServeMux()
+ h.server.RegisterRoutes(mux)
+ server := httptest.NewServer(mux)
+ defer server.Close()
+
+ // Convert http to ws URL
+ wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/exec-ws?cmd=echo+hello"
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ conn, _, err := websocket.Dial(ctx, wsURL, nil)
+ if err != nil {
+ t.Fatalf("Failed to dial websocket: %v", err)
+ }
+ defer conn.Close(websocket.StatusNormalClosure, "test done")
+
+ // Send init message
+ initMsg := ExecMessage{Type: "init", Cols: 80, Rows: 24}
+ if err := wsjson.Write(ctx, conn, initMsg); err != nil {
+ t.Fatalf("Failed to write init message: %v", err)
+ }
+
+ // Read messages until connection closes (server closes after sending exit)
+ var output strings.Builder
+ var exitCode int = -1
+
+ for {
+ var msg ExecMessage
+ err := wsjson.Read(ctx, conn, &msg)
+ if err != nil {
+ // Connection closed - this is expected after exit message
+ break
+ }
+
+ switch msg.Type {
+ case "output":
+ data, err := base64.StdEncoding.DecodeString(msg.Data)
+ if err == nil {
+ output.Write(data)
+ }
+ case "exit":
+ if msg.Data == "0" {
+ exitCode = 0
+ } else {
+ exitCode = 1
+ }
+ // Don't break here - continue reading until connection is closed
+ // to ensure we've received all output
+ case "error":
+ t.Fatalf("Received error: %s", msg.Data)
+ }
+ }
+
+ if exitCode != 0 {
+ t.Errorf("Expected exit code 0, got %d", exitCode)
+ }
+
+ if !strings.Contains(output.String(), "hello") {
+ t.Errorf("Expected output to contain 'hello', got: %q", output.String())
+ }
+}
+
+func TestExecTerminal_FailingCommand(t *testing.T) {
+ h := NewTestHarness(t)
+ defer h.cleanup()
+
+ mux := http.NewServeMux()
+ h.server.RegisterRoutes(mux)
+ server := httptest.NewServer(mux)
+ defer server.Close()
+
+ wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/exec-ws?cmd=exit+42"
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ conn, _, err := websocket.Dial(ctx, wsURL, nil)
+ if err != nil {
+ t.Fatalf("Failed to dial websocket: %v", err)
+ }
+ defer conn.Close(websocket.StatusNormalClosure, "test done")
+
+ // Send init message
+ initMsg := ExecMessage{Type: "init", Cols: 80, Rows: 24}
+ if err := wsjson.Write(ctx, conn, initMsg); err != nil {
+ t.Fatalf("Failed to write init message: %v", err)
+ }
+
+ // Read messages until we get exit
+ var exitCode string
+
+ for {
+ var msg ExecMessage
+ err := wsjson.Read(ctx, conn, &msg)
+ if err != nil {
+ break
+ }
+
+ if msg.Type == "exit" {
+ exitCode = msg.Data
+ }
+ }
+
+ if exitCode != "42" {
+ t.Errorf("Expected exit code 42, got %q", exitCode)
+ }
+}
+
+func TestExecTerminal_MissingCmd(t *testing.T) {
+ h := NewTestHarness(t)
+ defer h.cleanup()
+
+ mux := http.NewServeMux()
+ h.server.RegisterRoutes(mux)
+ server := httptest.NewServer(mux)
+ defer server.Close()
+
+ // Try without cmd parameter
+ wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/exec-ws"
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ _, resp, err := websocket.Dial(ctx, wsURL, nil)
+ if err == nil {
+ t.Fatal("Expected error for missing cmd parameter")
+ }
+
+ if resp != nil && resp.StatusCode != 400 {
+ t.Errorf("Expected status 400, got %d", resp.StatusCode)
+ }
+}
+
+func TestExecTerminal_WorkingDirectory(t *testing.T) {
+ h := NewTestHarness(t)
+ defer h.cleanup()
+
+ mux := http.NewServeMux()
+ h.server.RegisterRoutes(mux)
+ server := httptest.NewServer(mux)
+ defer server.Close()
+
+ wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/exec-ws?cmd=pwd&cwd=/tmp"
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ conn, _, err := websocket.Dial(ctx, wsURL, nil)
+ if err != nil {
+ t.Fatalf("Failed to dial websocket: %v", err)
+ }
+ defer conn.Close(websocket.StatusNormalClosure, "test done")
+
+ // Send init message
+ initMsg := ExecMessage{Type: "init", Cols: 80, Rows: 24}
+ if err := wsjson.Write(ctx, conn, initMsg); err != nil {
+ t.Fatalf("Failed to write init message: %v", err)
+ }
+
+ // Read messages
+ var output strings.Builder
+
+ for {
+ var msg ExecMessage
+ err := wsjson.Read(ctx, conn, &msg)
+ if err != nil {
+ break
+ }
+
+ if msg.Type == "output" {
+ data, _ := base64.StdEncoding.DecodeString(msg.Data)
+ output.Write(data)
+ }
+ }
+
+ if !strings.Contains(output.String(), "/tmp") {
+ t.Errorf("Expected output to contain '/tmp', got: %q", output.String())
+ }
+}
+
+func TestExecTerminal_Input(t *testing.T) {
+ h := NewTestHarness(t)
+ defer h.cleanup()
+
+ mux := http.NewServeMux()
+ h.server.RegisterRoutes(mux)
+ server := httptest.NewServer(mux)
+ defer server.Close()
+
+ // Use cat which echoes input
+ wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/exec-ws?cmd=cat"
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ conn, _, err := websocket.Dial(ctx, wsURL, nil)
+ if err != nil {
+ t.Fatalf("Failed to dial websocket: %v", err)
+ }
+ defer conn.Close(websocket.StatusNormalClosure, "test done")
+
+ // Send init message
+ initMsg := ExecMessage{Type: "init", Cols: 80, Rows: 24}
+ if err := wsjson.Write(ctx, conn, initMsg); err != nil {
+ t.Fatalf("Failed to write init message: %v", err)
+ }
+
+ // Send some input followed by EOF (Ctrl-D)
+ inputMsg := ExecMessage{Type: "input", Data: "test input\n"}
+ if err := wsjson.Write(ctx, conn, inputMsg); err != nil {
+ t.Fatalf("Failed to write input message: %v", err)
+ }
+
+ // Send EOF
+ eofMsg := ExecMessage{Type: "input", Data: "\x04"} // Ctrl-D
+ if err := wsjson.Write(ctx, conn, eofMsg); err != nil {
+ t.Fatalf("Failed to write EOF message: %v", err)
+ }
+
+ // Read messages
+ var output strings.Builder
+ var gotExit bool
+
+ for i := 0; i < 20; i++ { // Limit iterations to avoid infinite loop
+ var msg ExecMessage
+ err := wsjson.Read(ctx, conn, &msg)
+ if err != nil {
+ break
+ }
+
+ switch msg.Type {
+ case "output":
+ data, _ := base64.StdEncoding.DecodeString(msg.Data)
+ output.Write(data)
+ case "exit":
+ gotExit = true
+ }
+
+ if gotExit {
+ break
+ }
+ }
+
+ if !strings.Contains(output.String(), "test input") {
+ t.Errorf("Expected output to contain 'test input', got: %q", output.String())
+ }
+}
@@ -264,6 +264,7 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/upload", s.handleUpload) // Binary uploads
mux.HandleFunc("/api/read", s.handleRead) // Serves images
mux.Handle("/api/write-file", http.HandlerFunc(s.handleWriteFile)) // Small response
+ mux.HandleFunc("/api/exec-ws", s.handleExecWS) // Websocket for shell commands
// Custom models API
mux.Handle("/api/custom-models", http.HandlerFunc(s.handleCustomModels))
@@ -20,6 +20,9 @@
"test:e2e:debug": "pnpm run build && playwright test --debug"
},
"dependencies": {
+ "@xterm/addon-fit": "^0.11.0",
+ "@xterm/addon-web-links": "^0.12.0",
+ "@xterm/xterm": "^6.0.0",
"jszip": "^3.10.1",
"monaco-editor": "^0.44.0",
"react": "^18.2.0",
@@ -8,6 +8,15 @@ importers:
.:
dependencies:
+ '@xterm/addon-fit':
+ specifier: ^0.11.0
+ version: 0.11.0
+ '@xterm/addon-web-links':
+ specifier: ^0.12.0
+ version: 0.12.0
+ '@xterm/xterm':
+ specifier: ^6.0.0
+ version: 6.0.0
jszip:
specifier: ^3.10.1
version: 3.10.1
@@ -501,6 +510,15 @@ packages:
resolution: {integrity: sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@xterm/addon-fit@0.11.0':
+ resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==}
+
+ '@xterm/addon-web-links@0.12.0':
+ resolution: {integrity: sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==}
+
+ '@xterm/xterm@6.0.0':
+ resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==}
+
acorn-jsx@5.3.2:
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -1710,6 +1728,12 @@ snapshots:
'@typescript-eslint/types': 8.52.0
eslint-visitor-keys: 4.2.1
+ '@xterm/addon-fit@0.11.0': {}
+
+ '@xterm/addon-web-links@0.12.0': {}
+
+ '@xterm/xterm@6.0.0': {}
+
acorn-jsx@5.3.2(acorn@8.15.0):
dependencies:
acorn: 8.15.0
@@ -105,7 +105,7 @@ async function build() {
// Generate gzip versions of large files and remove originals to reduce binary size
// The server will decompress on-the-fly for the rare clients that don't support gzip
log('\nGenerating gzip compressed files...');
- const filesToCompress = ['monaco-editor.js', 'editor.worker.js', 'main.js', 'monaco-editor.css', 'styles.css'];
+ const filesToCompress = ['monaco-editor.js', 'editor.worker.js', 'main.js', 'monaco-editor.css', 'styles.css', 'main.css'];
const checksums = {};
let totalOrigSize = 0;
let totalGzSize = 0;
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useRef } from "react";
+import React, { useState, useEffect, useRef, useCallback } from "react";
import {
Message,
Conversation,
@@ -27,6 +27,15 @@ import SubagentTool from "./SubagentTool";
import OutputIframeTool from "./OutputIframeTool";
import DirectoryPickerModal from "./DirectoryPickerModal";
import { useVersionChecker } from "./VersionChecker";
+import TerminalWidget from "./TerminalWidget";
+
+// Ephemeral terminal instance (not persisted to database)
+interface EphemeralTerminal {
+ id: string;
+ command: string;
+ cwd: string;
+ createdAt: Date;
+}
interface ContextUsageBarProps {
contextWindowSize: number;
@@ -584,6 +593,9 @@ function ChatInterface({
const [, setReconnectAttempts] = useState(0);
const [isDisconnected, setIsDisconnected] = useState(false);
const [showScrollToBottom, setShowScrollToBottom] = useState(false);
+ // Ephemeral terminals are local-only and not persisted to the database
+ const [ephemeralTerminals, setEphemeralTerminals] = useState<EphemeralTerminal[]>([]);
+ const [terminalInjectedText, setTerminalInjectedText] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const eventSourceRef = useRef<EventSource | null>(null);
@@ -594,6 +606,9 @@ function ChatInterface({
// Load messages and set up streaming
useEffect(() => {
+ // Clear ephemeral terminals when conversation changes
+ setEphemeralTerminals([]);
+
if (conversationId) {
setAgentWorking(false);
loadMessages();
@@ -840,6 +855,25 @@ function ChatInterface({
const sendMessage = async (message: string) => {
if (!message.trim() || sending) return;
+ // Check if this is a shell command (starts with "!")
+ const trimmedMessage = message.trim();
+ if (trimmedMessage.startsWith("!")) {
+ const shellCommand = trimmedMessage.slice(1).trim();
+ if (shellCommand) {
+ // Create an ephemeral terminal
+ const terminal: EphemeralTerminal = {
+ id: `term-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
+ command: shellCommand,
+ cwd: selectedCwd || window.__SHELLEY_INIT__?.default_cwd || "/",
+ createdAt: new Date(),
+ };
+ setEphemeralTerminals((prev) => [...prev, terminal]);
+ // Scroll to bottom to show the new terminal
+ setTimeout(() => scrollToBottom(), 100);
+ }
+ return;
+ }
+
try {
setSending(true);
setError(null);
@@ -878,6 +912,11 @@ function ChatInterface({
setShowScrollToBottom(false);
};
+ // Callback for terminals to insert text into the message input
+ const handleInsertFromTerminal = useCallback((text: string) => {
+ setTerminalInjectedText(text);
+ }, []);
+
const handleManualReconnect = () => {
if (!conversationId || eventSourceRef.current) return;
setIsDisconnected(false);
@@ -1124,7 +1163,18 @@ function ChatInterface({
};
const renderMessages = () => {
- if (messages.length === 0) {
+ // Build ephemeral terminal elements first - they should always render
+ const terminalElements = ephemeralTerminals.map((terminal) => (
+ <TerminalWidget
+ key={terminal.id}
+ command={terminal.command}
+ cwd={terminal.cwd}
+ onInsertIntoInput={handleInsertFromTerminal}
+ onClose={() => setEphemeralTerminals((prev) => prev.filter((t) => t.id !== terminal.id))}
+ />
+ ));
+
+ if (messages.length === 0 && ephemeralTerminals.length === 0) {
const proxyURL = `https://${hostname}/`;
return (
<div className="empty-state">
@@ -1160,6 +1210,11 @@ function ChatInterface({
);
}
+ // If we have terminals but no messages, just show terminals
+ if (messages.length === 0) {
+ return terminalElements;
+ }
+
const coalescedItems = processMessages();
const rendered = coalescedItems.map((item, index) => {
@@ -1194,7 +1249,8 @@ function ChatInterface({
return null;
});
- return rendered;
+ // Append ephemeral terminals at the end
+ return [...rendered, ...terminalElements];
};
return (
@@ -1622,8 +1678,11 @@ function ChatInterface({
onSend={sendMessage}
disabled={sending || loading}
autoFocus={true}
- injectedText={diffCommentText}
- onClearInjectedText={() => setDiffCommentText("")}
+ injectedText={terminalInjectedText || diffCommentText}
+ onClearInjectedText={() => {
+ setDiffCommentText("");
+ setTerminalInjectedText(null);
+ }}
persistKey={conversationId || "new-conversation"}
/>
@@ -385,11 +385,13 @@ function MessageInput({
const canSubmit = message.trim() && !isDisabled && !submitting;
const isDraggingOver = dragCounter > 0;
+ // Check if user is typing a shell command (starts with !)
+ const isShellMode = message.trimStart().startsWith("!");
// Note: injectedText is auto-inserted via useEffect, no manual UI needed
return (
<div
- className={`message-input-container ${isDraggingOver ? "drag-over" : ""}`}
+ className={`message-input-container ${isDraggingOver ? "drag-over" : ""} ${isShellMode ? "shell-mode" : ""}`}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
@@ -410,6 +412,21 @@ function MessageInput({
accept="image/*,video/*,audio/*,.pdf,.txt,.md,.json,.csv,.xml,.html,.css,.js,.ts,.tsx,.jsx,.py,.go,.rs,.java,.c,.cpp,.h,.hpp,.sh,.yaml,.yml,.toml,.sql,.log,*"
aria-hidden="true"
/>
+ {isShellMode && (
+ <div className="shell-mode-indicator" title="This will run as a shell command">
+ <svg
+ width="16"
+ height="16"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ >
+ <polyline points="4 17 10 11 4 5" />
+ <line x1="12" y1="19" x2="20" y2="19" />
+ </svg>
+ </div>
+ )}
<textarea
ref={textareaRef}
value={message}
@@ -0,0 +1,717 @@
+import React, { useEffect, useRef, useState, useCallback } from "react";
+import { Terminal } from "@xterm/xterm";
+import { FitAddon } from "@xterm/addon-fit";
+import { WebLinksAddon } from "@xterm/addon-web-links";
+import "@xterm/xterm/css/xterm.css";
+
+interface TerminalWidgetProps {
+ command: string;
+ cwd: string;
+ onInsertIntoInput?: (text: string) => void;
+ onClose?: () => void;
+}
+
+// Theme colors for xterm.js
+function getTerminalTheme(isDark: boolean): Record<string, string> {
+ if (isDark) {
+ return {
+ background: "#1a1b26",
+ foreground: "#c0caf5",
+ cursor: "#c0caf5",
+ cursorAccent: "#1a1b26",
+ selectionBackground: "#364a82",
+ selectionForeground: "#c0caf5",
+ black: "#32344a",
+ red: "#f7768e",
+ green: "#9ece6a",
+ yellow: "#e0af68",
+ blue: "#7aa2f7",
+ magenta: "#ad8ee6",
+ cyan: "#449dab",
+ white: "#9699a8",
+ brightBlack: "#444b6a",
+ brightRed: "#ff7a93",
+ brightGreen: "#b9f27c",
+ brightYellow: "#ff9e64",
+ brightBlue: "#7da6ff",
+ brightMagenta: "#bb9af7",
+ brightCyan: "#0db9d7",
+ brightWhite: "#acb0d0",
+ };
+ }
+ // Light theme
+ return {
+ background: "#f8f9fa",
+ foreground: "#383a42",
+ cursor: "#526eff",
+ cursorAccent: "#f8f9fa",
+ selectionBackground: "#bfceff",
+ selectionForeground: "#383a42",
+ black: "#383a42",
+ red: "#e45649",
+ green: "#50a14f",
+ yellow: "#c18401",
+ blue: "#4078f2",
+ magenta: "#a626a4",
+ cyan: "#0184bc",
+ white: "#a0a1a7",
+ brightBlack: "#4f525e",
+ brightRed: "#e06c75",
+ brightGreen: "#98c379",
+ brightYellow: "#e5c07b",
+ brightBlue: "#61afef",
+ brightMagenta: "#c678dd",
+ brightCyan: "#56b6c2",
+ brightWhite: "#ffffff",
+ };
+}
+
+// Reusable icon button component matching MessageActionBar style
+function ActionButton({
+ onClick,
+ title,
+ children,
+ feedback,
+}: {
+ onClick: () => void;
+ title: string;
+ children: React.ReactNode;
+ feedback?: boolean;
+}) {
+ return (
+ <button
+ onClick={onClick}
+ title={title}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ width: "24px",
+ height: "24px",
+ borderRadius: "4px",
+ border: "none",
+ background: feedback ? "var(--success-bg)" : "transparent",
+ cursor: "pointer",
+ color: feedback ? "var(--success-text)" : "var(--text-secondary)",
+ transition: "background-color 0.15s, color 0.15s",
+ }}
+ onMouseEnter={(e) => {
+ if (!feedback) {
+ e.currentTarget.style.backgroundColor = "var(--bg-tertiary)";
+ }
+ }}
+ onMouseLeave={(e) => {
+ if (!feedback) {
+ e.currentTarget.style.backgroundColor = "transparent";
+ }
+ }}
+ >
+ {children}
+ </button>
+ );
+}
+
+// SVG icons
+const CopyIcon = () => (
+ <svg
+ width="14"
+ height="14"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
+ </svg>
+);
+
+const CheckIcon = () => (
+ <svg
+ width="14"
+ height="14"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <polyline points="20 6 9 17 4 12" />
+ </svg>
+);
+
+const InsertIcon = () => (
+ <svg
+ width="14"
+ height="14"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <path d="M12 3v12" />
+ <path d="m8 11 4 4 4-4" />
+ <path d="M4 21h16" />
+ </svg>
+);
+
+const CloseIcon = () => (
+ <svg
+ width="14"
+ height="14"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <line x1="18" y1="6" x2="6" y2="18" />
+ <line x1="6" y1="6" x2="18" y2="18" />
+ </svg>
+);
+
+export default function TerminalWidget({
+ command,
+ cwd,
+ onInsertIntoInput,
+ onClose,
+}: TerminalWidgetProps) {
+ const terminalRef = useRef<HTMLDivElement>(null);
+ const xtermRef = useRef<Terminal | null>(null);
+ const fitAddonRef = useRef<FitAddon | null>(null);
+ const wsRef = useRef<WebSocket | null>(null);
+ const [status, setStatus] = useState<"connecting" | "running" | "exited" | "error">("connecting");
+ const [exitCode, setExitCode] = useState<number | null>(null);
+ const [height, setHeight] = useState(300);
+ const [autoSized, setAutoSized] = useState(false);
+ const isResizingRef = useRef(false);
+ const startYRef = useRef(0);
+ const startHeightRef = useRef(0);
+ const lineCountRef = useRef(0);
+ const [copyFeedback, setCopyFeedback] = useState<string | null>(null);
+
+ // Detect dark mode
+ const isDarkMode = () => {
+ return document.documentElement.getAttribute("data-theme") === "dark";
+ };
+
+ const [isDark, setIsDark] = useState(isDarkMode);
+
+ // Watch for theme changes
+ useEffect(() => {
+ const observer = new MutationObserver(() => {
+ const newIsDark = isDarkMode();
+ setIsDark(newIsDark);
+ if (xtermRef.current) {
+ xtermRef.current.options.theme = getTerminalTheme(newIsDark);
+ }
+ });
+
+ observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["data-theme"],
+ });
+
+ return () => observer.disconnect();
+ }, []);
+
+ // Show copy feedback briefly
+ const showFeedback = useCallback((type: string) => {
+ setCopyFeedback(type);
+ setTimeout(() => setCopyFeedback(null), 1500);
+ }, []);
+
+ // Copy screen content (visible area)
+ const copyScreen = useCallback(() => {
+ if (!xtermRef.current) return;
+ const term = xtermRef.current;
+ const lines: string[] = [];
+ const buffer = term.buffer.active;
+ const startRow = buffer.viewportY;
+ for (let i = 0; i < term.rows; i++) {
+ const line = buffer.getLine(startRow + i);
+ if (line) {
+ lines.push(line.translateToString(true));
+ }
+ }
+ const text = lines.join("\n").trimEnd();
+ navigator.clipboard.writeText(text);
+ showFeedback("copyScreen");
+ }, [showFeedback]);
+
+ // Copy scrollback buffer (entire history)
+ const copyScrollback = useCallback(() => {
+ if (!xtermRef.current) return;
+ const term = xtermRef.current;
+ const lines: string[] = [];
+ const buffer = term.buffer.active;
+ for (let i = 0; i < buffer.length; i++) {
+ const line = buffer.getLine(i);
+ if (line) {
+ lines.push(line.translateToString(true));
+ }
+ }
+ const text = lines.join("\n").trimEnd();
+ navigator.clipboard.writeText(text);
+ showFeedback("copyAll");
+ }, [showFeedback]);
+
+ // Insert into input
+ const handleInsertScreen = useCallback(() => {
+ if (!xtermRef.current || !onInsertIntoInput) return;
+ const term = xtermRef.current;
+ const lines: string[] = [];
+ const buffer = term.buffer.active;
+ const startRow = buffer.viewportY;
+ for (let i = 0; i < term.rows; i++) {
+ const line = buffer.getLine(startRow + i);
+ if (line) {
+ lines.push(line.translateToString(true));
+ }
+ }
+ const text = lines.join("\n").trimEnd();
+ onInsertIntoInput(text);
+ showFeedback("insertScreen");
+ }, [onInsertIntoInput, showFeedback]);
+
+ const handleInsertScrollback = useCallback(() => {
+ if (!xtermRef.current || !onInsertIntoInput) return;
+ const term = xtermRef.current;
+ const lines: string[] = [];
+ const buffer = term.buffer.active;
+ for (let i = 0; i < buffer.length; i++) {
+ const line = buffer.getLine(i);
+ if (line) {
+ lines.push(line.translateToString(true));
+ }
+ }
+ const text = lines.join("\n").trimEnd();
+ onInsertIntoInput(text);
+ showFeedback("insertAll");
+ }, [onInsertIntoInput, showFeedback]);
+
+ // Close handler - kills the websocket/process
+ const handleClose = useCallback(() => {
+ if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
+ wsRef.current.close();
+ }
+ if (onClose) {
+ onClose();
+ }
+ }, [onClose]);
+
+ // Resize handling
+ const handleMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ isResizingRef.current = true;
+ startYRef.current = e.clientY;
+ startHeightRef.current = height;
+
+ const handleMouseMove = (e: MouseEvent) => {
+ if (!isResizingRef.current) return;
+ const delta = e.clientY - startYRef.current;
+ const newHeight = Math.max(80, Math.min(800, startHeightRef.current + delta));
+ setHeight(newHeight);
+ setAutoSized(false); // User manually resized, disable auto-sizing
+ };
+
+ const handleMouseUp = () => {
+ isResizingRef.current = false;
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ // Refit terminal after resize
+ if (fitAddonRef.current) {
+ fitAddonRef.current.fit();
+ }
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+ },
+ [height],
+ );
+
+ // Auto-size terminal based on content when process exits
+ const autoSizeTerminal = useCallback(() => {
+ if (!xtermRef.current || autoSized) return;
+
+ const term = xtermRef.current;
+ const buffer = term.buffer.active;
+
+ // Count actual content lines (non-empty from the end)
+ let contentLines = 0;
+ for (let i = buffer.length - 1; i >= 0; i--) {
+ const line = buffer.getLine(i);
+ if (line && line.translateToString(true).trim()) {
+ contentLines = i + 1;
+ break;
+ }
+ }
+
+ // Get actual cell dimensions from xterm
+ // @ts-expect-error - accessing private _core for accurate measurements
+ const core = term._core;
+ const cellHeight = core?._renderService?.dimensions?.css?.cell?.height || 17;
+
+ // Minimal padding for the terminal area
+ const minHeight = 34; // ~2 lines minimum
+ const maxHeight = 400;
+
+ // Calculate exact height needed for content lines
+ const neededHeight = Math.min(
+ maxHeight,
+ Math.max(minHeight, Math.ceil(contentLines * cellHeight) + 4),
+ );
+
+ setHeight(neededHeight);
+ setAutoSized(true);
+
+ // Refit after height change
+ setTimeout(() => fitAddonRef.current?.fit(), 20);
+ }, [autoSized]);
+
+ useEffect(() => {
+ if (!terminalRef.current) return;
+
+ // Create terminal
+ const term = new Terminal({
+ cursorBlink: true,
+ fontSize: 13,
+ fontFamily: 'Consolas, "Liberation Mono", Menlo, Courier, monospace',
+ theme: getTerminalTheme(isDark),
+ scrollback: 10000,
+ });
+ xtermRef.current = term;
+
+ // Add fit addon
+ const fitAddon = new FitAddon();
+ fitAddonRef.current = fitAddon;
+ term.loadAddon(fitAddon);
+
+ // Add web links addon
+ const webLinksAddon = new WebLinksAddon();
+ term.loadAddon(webLinksAddon);
+
+ // Open terminal in DOM
+ term.open(terminalRef.current);
+ fitAddon.fit();
+
+ // Connect websocket
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ const wsUrl = `${protocol}//${window.location.host}/api/exec-ws?cmd=${encodeURIComponent(command)}&cwd=${encodeURIComponent(cwd)}`;
+ const ws = new WebSocket(wsUrl);
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ // Send init message with terminal size
+ ws.send(
+ JSON.stringify({
+ type: "init",
+ cols: term.cols,
+ rows: term.rows,
+ }),
+ );
+ setStatus("running");
+ };
+
+ ws.onmessage = (event) => {
+ try {
+ const msg = JSON.parse(event.data);
+ if (msg.type === "output" && msg.data) {
+ // Decode base64 data
+ const decoded = atob(msg.data);
+ term.write(decoded);
+ // Track line count for auto-sizing
+ lineCountRef.current = term.buffer.active.length;
+ } else if (msg.type === "exit") {
+ const code = parseInt(msg.data, 10) || 0;
+ setExitCode(code);
+ setStatus("exited");
+ } else if (msg.type === "error") {
+ term.write(`\r\n\x1b[31mError: ${msg.data}\x1b[0m\r\n`);
+ setStatus("error");
+ }
+ } catch (err) {
+ console.error("Failed to parse terminal message:", err);
+ }
+ };
+
+ ws.onerror = (event) => {
+ console.error("WebSocket error:", event);
+ };
+
+ ws.onclose = () => {
+ setStatus((currentStatus) => {
+ if (currentStatus === "exited") return currentStatus;
+ return "exited";
+ });
+ };
+
+ // Handle terminal input
+ term.onData((data) => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: "input", data }));
+ }
+ });
+
+ // Handle resize
+ const resizeObserver = new ResizeObserver(() => {
+ fitAddon.fit();
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(
+ JSON.stringify({
+ type: "resize",
+ cols: term.cols,
+ rows: term.rows,
+ }),
+ );
+ }
+ });
+ resizeObserver.observe(terminalRef.current);
+
+ return () => {
+ resizeObserver.disconnect();
+ ws.close();
+ term.dispose();
+ };
+ }, [command, cwd]); // Only recreate on command/cwd change, not on isDark change
+
+ // Auto-size when process exits
+ useEffect(() => {
+ if (status === "exited" || status === "error") {
+ // Small delay to ensure all output is written
+ setTimeout(autoSizeTerminal, 100);
+ }
+ }, [status, autoSizeTerminal]);
+
+ // Update theme when isDark changes without recreating terminal
+ useEffect(() => {
+ if (xtermRef.current) {
+ xtermRef.current.options.theme = getTerminalTheme(isDark);
+ }
+ }, [isDark]);
+
+ // Fit terminal when height changes
+ useEffect(() => {
+ if (fitAddonRef.current) {
+ setTimeout(() => fitAddonRef.current?.fit(), 10);
+ }
+ }, [height]);
+
+ return (
+ <div className="terminal-widget" style={{ marginBottom: "1rem" }}>
+ {/* Header */}
+ <div
+ className="terminal-widget-header"
+ style={{
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ padding: "6px 12px",
+ backgroundColor: "var(--bg-secondary)",
+ borderRadius: "8px 8px 0 0",
+ border: "1px solid var(--border)",
+ borderBottom: "none",
+ }}
+ >
+ <div style={{ display: "flex", alignItems: "center", gap: "8px", flex: 1, minWidth: 0 }}>
+ <svg
+ width="14"
+ height="14"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ style={{ flexShrink: 0, color: "var(--text-secondary)" }}
+ >
+ <polyline points="4 17 10 11 4 5" />
+ <line x1="12" y1="19" x2="20" y2="19" />
+ </svg>
+ <code
+ style={{
+ fontSize: "12px",
+ fontFamily: 'Consolas, "Liberation Mono", Menlo, monospace',
+ color: "var(--text-primary)",
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ }}
+ >
+ {command}
+ </code>
+ {status === "running" && (
+ <span
+ style={{
+ fontSize: "11px",
+ color: "var(--success-text)",
+ fontWeight: 500,
+ flexShrink: 0,
+ }}
+ >
+ ● running
+ </span>
+ )}
+ {status === "exited" && (
+ <span
+ style={{
+ fontSize: "11px",
+ color: exitCode === 0 ? "var(--success-text)" : "var(--error-text)",
+ fontWeight: 500,
+ flexShrink: 0,
+ }}
+ >
+ exit {exitCode}
+ </span>
+ )}
+ {status === "error" && (
+ <span
+ style={{
+ fontSize: "11px",
+ color: "var(--error-text)",
+ fontWeight: 500,
+ flexShrink: 0,
+ }}
+ >
+ ● error
+ </span>
+ )}
+ </div>
+
+ {/* Action buttons - styled like MessageActionBar */}
+ <div
+ style={{
+ display: "flex",
+ gap: "2px",
+ background: "var(--bg-base)",
+ border: "1px solid var(--border)",
+ borderRadius: "4px",
+ padding: "2px",
+ }}
+ >
+ <ActionButton
+ onClick={copyScreen}
+ title="Copy visible screen to clipboard"
+ feedback={copyFeedback === "copyScreen"}
+ >
+ {copyFeedback === "copyScreen" ? <CheckIcon /> : <CopyIcon />}
+ </ActionButton>
+ <ActionButton
+ onClick={copyScrollback}
+ title="Copy all output to clipboard"
+ feedback={copyFeedback === "copyAll"}
+ >
+ {copyFeedback === "copyAll" ? (
+ <CheckIcon />
+ ) : (
+ <svg
+ width="14"
+ height="14"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
+ <line x1="12" y1="17" x2="18" y2="17" />
+ </svg>
+ )}
+ </ActionButton>
+ {onInsertIntoInput && (
+ <>
+ <ActionButton
+ onClick={handleInsertScreen}
+ title="Insert visible screen into message input"
+ feedback={copyFeedback === "insertScreen"}
+ >
+ {copyFeedback === "insertScreen" ? <CheckIcon /> : <InsertIcon />}
+ </ActionButton>
+ <ActionButton
+ onClick={handleInsertScrollback}
+ title="Insert all output into message input"
+ feedback={copyFeedback === "insertAll"}
+ >
+ {copyFeedback === "insertAll" ? (
+ <CheckIcon />
+ ) : (
+ <svg
+ width="14"
+ height="14"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ strokeWidth="2"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ >
+ <path d="M12 3v12" />
+ <path d="m8 11 4 4 4-4" />
+ <path d="M4 21h16" />
+ <line x1="4" y1="18" x2="20" y2="18" />
+ </svg>
+ )}
+ </ActionButton>
+ </>
+ )}
+ <div
+ style={{
+ width: "1px",
+ background: "var(--border)",
+ margin: "2px 2px",
+ }}
+ />
+ <ActionButton onClick={handleClose} title="Close terminal and kill process">
+ <CloseIcon />
+ </ActionButton>
+ </div>
+ </div>
+
+ {/* Terminal container */}
+ <div
+ ref={terminalRef}
+ style={{
+ height: `${height}px`,
+ backgroundColor: isDark ? "#1a1b26" : "#f8f9fa",
+ border: "1px solid var(--border)",
+ borderTop: "none",
+ borderBottom: "none",
+ overflow: "hidden",
+ }}
+ />
+
+ {/* Resize handle */}
+ <div
+ onMouseDown={handleMouseDown}
+ style={{
+ height: "8px",
+ cursor: "ns-resize",
+ backgroundColor: "var(--bg-secondary)",
+ border: "1px solid var(--border)",
+ borderTop: "none",
+ borderRadius: "0 0 8px 8px",
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ }}
+ >
+ <div
+ style={{
+ width: "40px",
+ height: "3px",
+ backgroundColor: "var(--text-tertiary)",
+ borderRadius: "2px",
+ }}
+ />
+ </div>
+ </div>
+ );
+}
@@ -14,6 +14,7 @@
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>Shelley Agent</title>
<link rel="stylesheet" href="/styles.css" />
+ <link rel="stylesheet" href="/main.css" />
</head>
<body>
<div id="root"></div>
@@ -41,6 +41,9 @@ body {
--error-bg: #fef2f2;
--error-border: #fecaca;
--error-text: #991b1b;
+ --warning-bg: #fffbeb;
+ --warning-border: #fcd34d;
+ --warning-text: #92400e;
--blue-bg: #eff6ff;
--blue-border: #bfdbfe;
--blue-text: #1e40af;
@@ -74,6 +77,9 @@ body {
--error-bg: rgba(239, 68, 68, 0.1);
--error-border: rgba(239, 68, 68, 0.3);
--error-text: #fca5a5;
+ --warning-bg: rgba(251, 191, 36, 0.15);
+ --warning-border: #fbbf24;
+ --warning-text: #fcd34d;
--blue-bg: rgba(59, 130, 246, 0.1);
--blue-border: rgba(59, 130, 246, 0.3);
--blue-text: #93c5fd;
@@ -2018,6 +2024,34 @@ button {
border-color: var(--primary);
}
+.message-input-container.shell-mode .message-input-form {
+ border-color: var(--warning-border);
+ background: var(--warning-bg);
+}
+
+.message-input-container.shell-mode .message-textarea {
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
+ background: transparent;
+ padding-left: 36px;
+}
+
+.message-input-container.shell-mode .message-textarea::placeholder {
+ font-family: inherit;
+}
+
+.shell-mode-indicator {
+ position: absolute;
+ left: 12px;
+ top: 50%;
+ transform: translateY(-50%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: var(--warning-text);
+ pointer-events: none;
+ z-index: 1;
+}
+
.drag-overlay {
position: absolute;
inset: 0;