From 35d47faefe671e41ff0707ed6658e4dc2a26c8ba Mon Sep 17 00:00:00 2001 From: Philip Zeyliger Date: Sun, 25 Jan 2026 15:30:21 -0800 Subject: [PATCH] shelley: add inline shell command execution with !prefix When a chat message starts with '!', instead of sending to the LLM, execute the command on the server and display output in an embedded xterm.js terminal widget. Features: - Commands executed via bash -c in a PTY with real-time streaming - Terminal embedded in chat timeline (resizable, not full height) - Exit code displayed (green for 0, red for non-zero) - Copy buttons for screen/scrollback buffer - Insert buttons to paste terminal content into message input - Light/dark theme support - Shell mode visual indicator (terminal icon + yellow background) - Terminals are ephemeral (not saved to database) Backend: - New websocket endpoint /api/exec-ws - Uses creack/pty for PTY allocation - Handles resize and input events - Tests for all major functionality Frontend: - New TerminalWidget component using @xterm/xterm - ChatInterface intercepts !prefix messages - Auto-sizing based on output content - Descriptive button tooltips Prompt: add inline shell command execution with ! prefix, with visual indicator, auto-sizing terminal, and proper tests Co-authored-by: Shelley Co-authored-by: Shelley --- go.mod | 2 + go.sum | 2 + server/exec_terminal.go | 220 ++++++++ server/exec_terminal_test.go | 267 ++++++++++ server/server.go | 1 + ui/package.json | 3 + ui/pnpm-lock.yaml | 24 + ui/scripts/build.js | 2 +- ui/src/components/ChatInterface.tsx | 69 ++- ui/src/components/MessageInput.tsx | 19 +- ui/src/components/TerminalWidget.tsx | 717 +++++++++++++++++++++++++++ ui/src/index.html | 1 + ui/src/styles.css | 34 ++ 13 files changed, 1354 insertions(+), 7 deletions(-) create mode 100644 server/exec_terminal.go create mode 100644 server/exec_terminal_test.go create mode 100644 ui/src/components/TerminalWidget.tsx diff --git a/go.mod b/go.mod index 20ed3b5b1fdc014c1a2e6d5d6a908c6815af4687..71d0fe6bbbf73413114c40369725dcf0c10c7e38 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 5cc0515b289194e269b77ba8d3babd1926e9bb50..fd3625b9532eabc71d41806c2d11efaa17de0907 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/server/exec_terminal.go b/server/exec_terminal.go new file mode 100644 index 0000000000000000000000000000000000000000..f7d7963a854f86c0db5f9c1ca51223ae995a265a --- /dev/null +++ b/server/exec_terminal.go @@ -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 +} diff --git a/server/exec_terminal_test.go b/server/exec_terminal_test.go new file mode 100644 index 0000000000000000000000000000000000000000..db1a19b3b46489dfab1e46a20481b1088518257c --- /dev/null +++ b/server/exec_terminal_test.go @@ -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()) + } +} diff --git a/server/server.go b/server/server.go index 00ac56b9a63120b77ed05a015fddc38f405d304b..df26fa0805e7cc9771614c1b85743c91ffd7a7c1 100644 --- a/server/server.go +++ b/server/server.go @@ -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)) diff --git a/ui/package.json b/ui/package.json index af03beafa8d358ee5e7506b1309217e741e8d9e7..0caaed05961e45e9347aa5221def503894212fa0 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index a4807029a2255616a9560e798b4ba606dea43989..a59f8bb3550b98339b18a53f7e2e0f4c37f4aef1 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -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 diff --git a/ui/scripts/build.js b/ui/scripts/build.js index af46092ca0f381b444d4027df5238eed7583cd1c..e96c47d32dee3c81fd38ab3553900842786f47d5 100644 --- a/ui/scripts/build.js +++ b/ui/scripts/build.js @@ -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; diff --git a/ui/src/components/ChatInterface.tsx b/ui/src/components/ChatInterface.tsx index 2d3a0611149c17370751679a41becd343130a34b..3a44b1a9a2425be1f9d2ea89982876c39ae5e334 100644 --- a/ui/src/components/ChatInterface.tsx +++ b/ui/src/components/ChatInterface.tsx @@ -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([]); + const [terminalInjectedText, setTerminalInjectedText] = useState(null); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); const eventSourceRef = useRef(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) => ( + setEphemeralTerminals((prev) => prev.filter((t) => t.id !== terminal.id))} + /> + )); + + if (messages.length === 0 && ephemeralTerminals.length === 0) { const proxyURL = `https://${hostname}/`; return (
@@ -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"} /> diff --git a/ui/src/components/MessageInput.tsx b/ui/src/components/MessageInput.tsx index 0853b7cc3102d48982d9012c45649fe90ac927f3..ae7c7cea7566e70ecac9a02ecf1588315e555e9b 100644 --- a/ui/src/components/MessageInput.tsx +++ b/ui/src/components/MessageInput.tsx @@ -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 (