shelley: add inline shell command execution with !prefix

Philip Zeyliger and Shelley created

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 <shelley@exe.dev>

Co-authored-by: Shelley <shelley@exe.dev>

Change summary

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, 1,354 insertions(+), 7 deletions(-)

Detailed changes

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

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=

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
+}

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())
+	}
+}

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))

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",

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

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;

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<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"}
       />
 

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 (
     <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}

ui/src/components/TerminalWidget.tsx 🔗

@@ -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>
+  );
+}

ui/src/index.html 🔗

@@ -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>

ui/src/styles.css 🔗

@@ -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;