From 9f3c47d6da4a5377c14c2b89029b7130227956a7 Mon Sep 17 00:00:00 2001 From: Marco De Nichilo <36410465+marcodenic@users.noreply.github.com> Date: Mon, 5 Jan 2026 17:38:55 +0000 Subject: [PATCH] Add Agent terminal panel for viewing bash command output - Add new "Agent" tab to terminal panel that displays real-time output from agent bash commands - Auto-focus Agent tab when agent runs commands (unless user has interacted with PTY terminals) - Show spinner indicator on Agent tab when commands are running - Support ANSI color codes in output using ansi-to-html - Hide Agent tab in empty session state (before first message) - Fix duplicate terminal creation on session load by adding ready() check and creating guard - Track user interaction with PTY terminals to control auto-focus behavior --- bun.lock | 13 +- packages/app/package.json | 1 + packages/app/src/app.tsx | 13 +- .../app/src/components/agent-terminal.tsx | 149 ++++++++++++++++++ packages/app/src/context/agent-terminal.tsx | 134 ++++++++++++++++ packages/app/src/context/terminal.tsx | 25 ++- packages/app/src/pages/session.tsx | 32 +++- 7 files changed, 356 insertions(+), 11 deletions(-) create mode 100644 packages/app/src/components/agent-terminal.tsx create mode 100644 packages/app/src/context/agent-terminal.tsx diff --git a/bun.lock b/bun.lock index 8c57d8630fc..3c97e62a5d3 100644 --- a/bun.lock +++ b/bun.lock @@ -40,6 +40,7 @@ "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", + "ansi-to-html": "0.7.2", "diff": "catalog:", "fuzzysort": "catalog:", "ghostty-web": "0.3.0", @@ -1911,6 +1912,8 @@ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ansi-to-html": ["ansi-to-html@0.7.2", "", { "dependencies": { "entities": "^2.2.0" }, "bin": { "ansi-to-html": "bin/ansi-to-html" } }, "sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g=="], + "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], @@ -2305,7 +2308,7 @@ "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], - "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "entities": ["entities@2.2.0", "", {}, "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="], "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], @@ -4239,6 +4242,8 @@ "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "dot-prop/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="], "drizzle-kit/esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="], @@ -4281,6 +4286,10 @@ "html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], + "html-minifier-terser/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "jsonwebtoken/jws": ["jws@3.2.2", "", { "dependencies": { "jwa": "^1.4.1", "safe-buffer": "^5.0.1" } }, "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA=="], @@ -4599,6 +4608,8 @@ "@jsx-email/cli/vite/rollup": ["rollup@3.29.5", "", { "optionalDependencies": { "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w=="], + "@jsx-email/doiuse-email/htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.0", "http-errors": "^2.0.0", "iconv-lite": "^0.6.3", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.0", "type-is": "^2.0.0" } }, "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg=="], diff --git a/packages/app/package.json b/packages/app/package.json index 97805892e56..8c4c0c041b8 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -45,6 +45,7 @@ "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", + "ansi-to-html": "0.7.2", "diff": "catalog:", "fuzzysort": "catalog:", "ghostty-web": "0.3.0", diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index e41575e7ad4..7bb7f94b8ed 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -15,6 +15,7 @@ import { LayoutProvider } from "@/context/layout" import { GlobalSDKProvider } from "@/context/global-sdk" import { ServerProvider, useServer } from "@/context/server" import { TerminalProvider } from "@/context/terminal" +import { AgentTerminalProvider } from "@/context/agent-terminal" import { PromptProvider } from "@/context/prompt" import { FileProvider } from "@/context/file" import { NotificationProvider } from "@/context/notification" @@ -89,11 +90,13 @@ export function App() { component={(p) => ( - - - - - + + + + + + + )} diff --git a/packages/app/src/components/agent-terminal.tsx b/packages/app/src/components/agent-terminal.tsx new file mode 100644 index 00000000000..fa9f2c7a055 --- /dev/null +++ b/packages/app/src/components/agent-terminal.tsx @@ -0,0 +1,149 @@ +import { createEffect, createMemo, For, on, Show, type JSX } from "solid-js" +import { useAgentTerminal, type BashCommand } from "@/context/agent-terminal" +import { resolveThemeVariant, useTheme } from "@opencode-ai/ui/theme" +import AnsiToHtml from "ansi-to-html" + +function useAnsiConverter() { + const theme = useTheme() + + return createMemo(() => { + const isDark = theme.mode() === "dark" + const currentTheme = theme.themes()[theme.themeId()] + + // Default ANSI colors + const defaultColors = isDark + ? { + fg: "#d4d4d4", + black: "#1a1a1a", + red: "#ff5f56", + green: "#5af78e", + yellow: "#f3f99d", + blue: "#57c7ff", + magenta: "#ff6ac1", + cyan: "#9aedfe", + white: "#f1f1f0", + brightBlack: "#686868", + brightRed: "#ff6e6e", + brightGreen: "#69ff94", + brightYellow: "#ffffa5", + brightBlue: "#69a0ff", + brightMagenta: "#ff77ff", + brightCyan: "#a4ffff", + brightWhite: "#ffffff", + } + : { + fg: "#211e1e", + black: "#000000", + red: "#c91b00", + green: "#00c200", + yellow: "#c7c400", + blue: "#0068ff", + magenta: "#c930c7", + cyan: "#00c5c7", + white: "#c7c7c7", + brightBlack: "#686868", + brightRed: "#ff6e67", + brightGreen: "#5ffa68", + brightYellow: "#fffc67", + brightBlue: "#6871ff", + brightMagenta: "#ff77ff", + brightCyan: "#60fdff", + brightWhite: "#ffffff", + } + + // Pull semantic colors from theme if available + if (currentTheme) { + const variant = isDark ? currentTheme.dark : currentTheme.light + if (variant?.seeds) { + const resolved = resolveThemeVariant(variant, isDark) + // Map theme semantic colors to ANSI where appropriate + if (resolved["syntax-critical"]) defaultColors.red = resolved["syntax-critical"] as string + if (resolved["syntax-success"]) defaultColors.green = resolved["syntax-success"] as string + if (resolved["syntax-warning"]) defaultColors.yellow = resolved["syntax-warning"] as string + if (resolved["syntax-info"]) defaultColors.cyan = resolved["syntax-info"] as string + if (resolved["text-base"]) defaultColors.fg = resolved["text-base"] as string + } + } + + return new AnsiToHtml({ + fg: defaultColors.fg, + bg: "transparent", + colors: [ + defaultColors.black, + defaultColors.red, + defaultColors.green, + defaultColors.yellow, + defaultColors.blue, + defaultColors.magenta, + defaultColors.cyan, + defaultColors.white, + defaultColors.brightBlack, + defaultColors.brightRed, + defaultColors.brightGreen, + defaultColors.brightYellow, + defaultColors.brightBlue, + defaultColors.brightMagenta, + defaultColors.brightCyan, + defaultColors.brightWhite, + ], + escapeXML: true, + }) + }) +} + +function CommandBlock(props: { command: BashCommand }) { + const convert = useAnsiConverter() + + return ( + <> +
+ $ + {props.command.command} +
+ +
+ + + ) +} + +export function AgentTerminal(): JSX.Element { + const agentTerminal = useAgentTerminal() + const theme = useTheme() + let container: HTMLDivElement | undefined + + const backgroundColor = createMemo(() => { + const mode = theme.mode() + const currentTheme = theme.themes()[theme.themeId()] + if (!currentTheme) return mode === "dark" ? "#191515" : "#fcfcfc" + const variant = mode === "dark" ? currentTheme.dark : currentTheme.light + if (!variant?.seeds) return mode === "dark" ? "#191515" : "#fcfcfc" + const resolved = resolveThemeVariant(variant, mode === "dark") + return resolved["background-stronger"] ?? (mode === "dark" ? "#191515" : "#fcfcfc") + }) + + // Auto-scroll to bottom when new output arrives + createEffect( + on( + () => agentTerminal.commands(), + () => { + if (container) { + container.scrollTo({ top: container.scrollHeight, behavior: "smooth" }) + } + }, + ), + ) + + return ( +
+ +
Agent commands will appear here
+
+ {(cmd) => } +
+ ) +} diff --git a/packages/app/src/context/agent-terminal.tsx b/packages/app/src/context/agent-terminal.tsx new file mode 100644 index 00000000000..8c9cd7dbdbd --- /dev/null +++ b/packages/app/src/context/agent-terminal.tsx @@ -0,0 +1,134 @@ +import { createStore } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { createEffect, createMemo } from "solid-js" +import { useParams } from "@solidjs/router" +import { useSync } from "./sync" +import { useTerminal } from "./terminal" +import type { Part, ToolPart } from "@opencode-ai/sdk/v2/client" + +export type BashCommand = { + id: string + messageID: string + command: string + description: string + output: string + status: "pending" | "running" | "completed" | "error" + time: { start?: number; end?: number } + exitCode?: number +} + +function isBashToolPart(part: Part): part is ToolPart { + return part.type === "tool" && part.tool === "bash" +} + +function extractBashCommand(part: ToolPart): BashCommand { + const state = part.state + const input = state.input as { command?: string; description?: string } + + const base = { + id: part.id, + messageID: part.messageID, + command: input.command ?? "", + description: input.description ?? "", + } + + switch (state.status) { + case "pending": + return { ...base, output: "", status: "pending", time: {} } + case "running": + return { + ...base, + output: (state.metadata?.output as string) ?? "", + status: "running", + time: { start: state.time.start }, + } + case "completed": + return { + ...base, + output: (state.metadata?.output as string) ?? state.output ?? "", + status: "completed", + time: { start: state.time.start, end: state.time.end }, + exitCode: state.metadata?.exit as number | undefined, + } + case "error": + return { + ...base, + output: state.error ?? "", + status: "error", + time: { start: state.time.start, end: state.time.end }, + } + } +} + +export const { use: useAgentTerminal, provider: AgentTerminalProvider } = createSimpleContext({ + name: "AgentTerminal", + init: () => { + const sync = useSync() + const terminal = useTerminal() + const params = useParams() + + const [store, setStore] = createStore<{ + cleared: boolean + previousActiveCount: number + }>({ + cleared: false, + previousActiveCount: 0, + }) + + const sessionID = createMemo(() => params.id) + + const messages = createMemo(() => { + const id = sessionID() + if (!id) return [] + return sync.data.message[id] ?? [] + }) + + const bashCommands = createMemo(() => { + if (store.cleared) return [] + const msgs = messages() + const allParts = sync.data.part + const commands: BashCommand[] = [] + + for (const msg of msgs) { + const parts = allParts[msg.id] ?? [] + for (const part of parts) { + if (isBashToolPart(part)) { + commands.push(extractBashCommand(part)) + } + } + } + + return commands + }) + + const hasActiveCommand = createMemo(() => + bashCommands().some((c) => c.status === "running" || c.status === "pending"), + ) + + // Auto-focus to agent tab when a new command starts (if user hasn't interacted with PTY terminals) + createEffect(() => { + const activeCount = bashCommands().filter((c) => c.status === "running" || c.status === "pending").length + const previousCount = store.previousActiveCount + + // A new command started + if (activeCount > previousCount && activeCount > 0) { + // Only auto-focus if user hasn't interacted with PTY terminals + if (!terminal.hasUserInteracted()) { + terminal.open("agent") + } + } + + setStore("previousActiveCount", activeCount) + }) + + return { + commands: bashCommands, + hasActiveCommand, + clear() { + setStore("cleared", true) + // Reset after a tick so new commands can come in + setTimeout(() => setStore("cleared", false), 0) + }, + } + }, +}) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index e9a07077cef..bdb97334f3c 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -26,16 +26,25 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont createStore<{ active?: string all: LocalPTY[] + hasUserInteracted: boolean }>({ all: [], + hasUserInteracted: false, }), ) + let creating = false + return { ready, all: createMemo(() => Object.values(store.all)), active: createMemo(() => store.active), - new() { + new(options?: { auto?: boolean }) { + if (creating) return + creating = true + if (!options?.auto) { + setStore("hasUserInteracted", true) + } sdk.client.pty .create({ title: `Terminal ${store.all.length + 1}` }) .then((pty) => { @@ -48,11 +57,18 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont title: pty.data?.title ?? "Terminal", }, ]) - setStore("active", id) + // Don't auto-activate if this is an auto-created terminal + // Let the agent tab stay focused + if (!options?.auto) { + setStore("active", id) + } }) .catch((e) => { console.error("Failed to create terminal", e) }) + .finally(() => { + creating = false + }) }, update(pty: Partial & { id: string }) { setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) @@ -88,8 +104,13 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont } }, open(id: string) { + // Mark as user interacted if opening a PTY terminal (not the agent tab) + if (id !== "agent") { + setStore("hasUserInteracted", true) + } setStore("active", id) }, + hasUserInteracted: () => store.hasUserInteracted, async close(id: string) { batch(() => { setStore( diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 1b044975209..c75ed3866f9 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -22,6 +22,9 @@ import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, close import type { DragEvent } from "@thisbeyond/solid-dnd" import { useSync } from "@/context/sync" import { useTerminal, type LocalPTY } from "@/context/terminal" +import { useAgentTerminal } from "@/context/agent-terminal" +import { AgentTerminal } from "@/components/agent-terminal" +import { Spinner } from "@opencode-ai/ui/spinner" import { useLayout } from "@/context/layout" import { Terminal } from "@/components/terminal" import { checksum, base64Decode } from "@opencode-ai/util/encode" @@ -57,6 +60,25 @@ function same(a: readonly T[], b: readonly T[]) { type DiffStyle = "unified" | "split" +function AgentTerminalTab() { + const params = useParams() + const agentTerminal = useAgentTerminal() + + // Don't show Agent tab in empty state (no session yet) + if (!params.id) return null + + return ( + +
+ }> + + + Agent +
+
+ ) +} + interface SessionReviewTabProps { diffs: () => FileDiff[] view: () => ReturnType["view"]> @@ -294,9 +316,9 @@ export default function Page() { }) createEffect(() => { - if (layout.terminal.opened()) { + if (layout.terminal.opened() && terminal.ready()) { if (terminal.all().length === 0) { - terminal.new() + terminal.new({ auto: true }) // auto-create, don't count as user interaction } } }) @@ -1271,9 +1293,10 @@ export default function Page() { keybind={command.keybind("terminal.new")} class="flex items-center" > - + terminal.new()} />
+ {(pty) => ( @@ -1282,6 +1305,9 @@ export default function Page() { )} + + +