From 037f6bb25a57986813b9dae7edc7a8e8cfe83021 Mon Sep 17 00:00:00 2001 From: Kyle Crommett Date: Tue, 9 Dec 2025 22:24:48 -0800 Subject: [PATCH 01/11] revert: remove PR #4773 subagent restrictions feature This feature is not going to be merged upstream in its current form. Removes: - subagents field from Agent schema and built-in agents - subagents config option - filterSubagents function and runtime validation - Subagent filtering from prompt tool resolution - Subagent filtering from TUI autocomplete - subagents-filter.test.ts test file - Subagents documentation section from agents.mdx The SDK types will be regenerated automatically on the next build. --- packages/opencode/src/agent/agent.ts | 13 +-- .../cmd/tui/component/prompt/autocomplete.tsx | 4 - packages/opencode/src/config/config.ts | 1 - packages/opencode/src/session/prompt.ts | 18 +--- packages/opencode/src/tool/task.ts | 8 -- .../opencode/test/subagents-filter.test.ts | 93 ------------------- packages/web/src/content/docs/agents.mdx | 70 -------------- 7 files changed, 2 insertions(+), 205 deletions(-) delete mode 100644 packages/opencode/test/subagents-filter.test.ts diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index df40d06a60a2..e461eef30ab3 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -32,7 +32,6 @@ export namespace Agent { .optional(), prompt: z.string().optional(), tools: z.record(z.string(), z.boolean()), - subagents: z.record(z.string(), z.boolean()), options: z.record(z.string(), z.any()), maxSteps: z.number().int().positive().optional(), }) @@ -110,7 +109,6 @@ export namespace Agent { todowrite: false, ...defaultTools, }, - subagents: {}, options: {}, permission: agentPermission, mode: "subagent", @@ -125,7 +123,6 @@ export namespace Agent { write: false, ...defaultTools, }, - subagents: {}, description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions. (Tools: All tools)`, prompt: [ `You are a file search specialist. You excel at thoroughly navigating and exploring codebases.`, @@ -155,7 +152,6 @@ export namespace Agent { build: { name: "build", tools: { ...defaultTools }, - subagents: {}, options: {}, permission: agentPermission, mode: "primary", @@ -168,7 +164,6 @@ export namespace Agent { tools: { ...defaultTools, }, - subagents: {}, mode: "primary", builtIn: true, }, @@ -186,7 +181,6 @@ export namespace Agent { permission: agentPermission, options: {}, tools: {}, - subagents: {}, builtIn: false, } const { @@ -194,7 +188,7 @@ export namespace Agent { model, prompt, tools, - subagents, + subagents: _subagents, description, temperature, top_p, @@ -219,11 +213,6 @@ export namespace Agent { ...defaultTools, ...item.tools, } - if (subagents) - item.subagents = { - ...item.subagents, - ...subagents, - } if (description) item.description = description if (temperature != undefined) item.temperature = temperature if (top_p != undefined) item.topP = top_p diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 88565577d799..a328d4d0b3bf 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -11,7 +11,6 @@ import { SplitBorder } from "@tui/component/border" import { useCommandDialog } from "@tui/component/dialog-command" import { useTerminalDimensions } from "@opentui/solid" import { Locale } from "@/util/locale" -import { Wildcard } from "@/util/wildcard" import type { PromptInfo } from "./history" export type AutocompleteRef = { @@ -185,11 +184,8 @@ export function Autocomplete(props: { ) const agents = createMemo(() => { - const current = local.agent.current() as { subagents?: Record } - const subagents = current.subagents ?? {} return sync.data.agent .filter((agent) => !agent.builtIn && agent.mode !== "primary") - .filter((agent) => Wildcard.all(agent.name, subagents) !== false) .map( (agent): AutocompleteOption => ({ display: "@" + agent.name, diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7a15516adc18..fd197d5991a4 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -388,7 +388,6 @@ export namespace Config { top_p: z.number().optional(), prompt: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), - subagents: z.record(z.string(), z.boolean()).optional(), disable: z.boolean().optional(), description: z.string().optional().describe("Description of when to use the agent"), mode: z.enum(["subagent", "primary", "all"]).optional(), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ea8ee009cc93..94debbd4d0be 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -47,7 +47,7 @@ import { Config } from "../config/config" import { NamedError } from "@opencode-ai/util/error" import { fn } from "@/util/fn" import { SessionProcessor } from "./processor" -import { TaskTool, filterSubagents, TASK_DESCRIPTION } from "@/tool/task" +import { TaskTool, TASK_DESCRIPTION } from "@/tool/task" import { SessionStatus } from "./status" import { Token } from "@/util/token" @@ -857,22 +857,6 @@ export namespace SessionPrompt { tools[key] = item } - // Regenerate task tool description with filtered subagents - if (tools.task) { - const all = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) - const filtered = filterSubagents(all, input.agent.subagents) - const description = TASK_DESCRIPTION.replace( - "{agents}", - filtered - .map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`) - .join("\n"), - ) - tools.task = { - ...tools.task, - description, - } - } - return tools } diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts index 59dff3aee6d0..c8a7be22e45f 100644 --- a/packages/opencode/src/tool/task.ts +++ b/packages/opencode/src/tool/task.ts @@ -9,15 +9,10 @@ import { Agent } from "../agent/agent" import { SessionPrompt } from "../session/prompt" import { iife } from "@/util/iife" import { defer } from "@/util/defer" -import { Wildcard } from "@/util/wildcard" import { Config } from "../config/config" export { DESCRIPTION as TASK_DESCRIPTION } -export function filterSubagents(agents: Agent.Info[], subagents: Record) { - return agents.filter((a) => Wildcard.all(a.name, subagents) !== false) -} - export const TaskTool = Tool.define("task", async () => { const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary")) const description = DESCRIPTION.replace( @@ -37,9 +32,6 @@ export const TaskTool = Tool.define("task", async () => { async execute(params, ctx) { const agent = await Agent.get(params.subagent_type) if (!agent) throw new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`) - const calling = await Agent.get(ctx.agent) - if (calling && Wildcard.all(params.subagent_type, calling.subagents) === false) - throw new Error(`Agent '${params.subagent_type}' is not available to ${ctx.agent}`) const session = await iife(async () => { if (params.session_id) { const found = await Session.get(params.session_id).catch(() => {}) diff --git a/packages/opencode/test/subagents-filter.test.ts b/packages/opencode/test/subagents-filter.test.ts deleted file mode 100644 index 5cb0b7e0a089..000000000000 --- a/packages/opencode/test/subagents-filter.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, test, expect } from "bun:test" -import type { Agent } from "../src/agent/agent" -import { filterSubagents } from "../src/tool/task" -import { Wildcard } from "../src/util/wildcard" - -describe("filterSubagents", () => { - const mockAgents = [ - { name: "general", mode: "subagent" }, - { name: "code-reviewer", mode: "subagent" }, - { name: "orchestrator-fast", mode: "subagent" }, - { name: "orchestrator-slow", mode: "subagent" }, - ] as Agent.Info[] - - test("returns all agents when subagents config is empty", () => { - const result = filterSubagents(mockAgents, {}) - expect(result).toHaveLength(4) - expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast", "orchestrator-slow"]) - }) - - test("excludes agents with explicit false", () => { - const result = filterSubagents(mockAgents, { "code-reviewer": false }) - expect(result).toHaveLength(3) - expect(result.map((a) => a.name)).toEqual(["general", "orchestrator-fast", "orchestrator-slow"]) - }) - - test("includes agents with explicit true", () => { - const result = filterSubagents(mockAgents, { - "code-reviewer": true, - general: false, - }) - expect(result).toHaveLength(3) - expect(result.map((a) => a.name)).toEqual(["code-reviewer", "orchestrator-fast", "orchestrator-slow"]) - }) - - test("supports wildcard patterns to exclude", () => { - const result = filterSubagents(mockAgents, { "orchestrator-*": false }) - expect(result).toHaveLength(2) - expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer"]) - }) - - test("supports wildcard patterns to include with specific exclusion", () => { - const result = filterSubagents(mockAgents, { - "*": true, - "orchestrator-fast": false, - }) - expect(result).toHaveLength(3) - expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-slow"]) - }) - - test("longer pattern takes precedence", () => { - const result = filterSubagents(mockAgents, { - "orchestrator-*": false, - "orchestrator-fast": true, - }) - expect(result).toHaveLength(3) - expect(result.map((a) => a.name)).toEqual(["general", "code-reviewer", "orchestrator-fast"]) - }) -}) - -describe("Wildcard.all for subagents", () => { - test("returns undefined when no match", () => { - expect(Wildcard.all("code-reviewer", {})).toBeUndefined() - }) - - test("returns false for explicit false", () => { - expect(Wildcard.all("code-reviewer", { "code-reviewer": false })).toBe(false) - }) - - test("returns true for explicit true", () => { - expect(Wildcard.all("code-reviewer", { "code-reviewer": true })).toBe(true) - }) - - test("matches wildcard patterns", () => { - expect(Wildcard.all("orchestrator-fast", { "orchestrator-*": false })).toBe(false) - expect(Wildcard.all("orchestrator-slow", { "orchestrator-*": false })).toBe(false) - expect(Wildcard.all("general", { "orchestrator-*": false })).toBeUndefined() - }) - - test("longer pattern takes precedence over shorter", () => { - expect( - Wildcard.all("orchestrator-fast", { - "orchestrator-*": false, - "orchestrator-fast": true, - }), - ).toBe(true) - expect( - Wildcard.all("orchestrator-slow", { - "orchestrator-*": false, - "orchestrator-fast": true, - }), - ).toBe(false) - }) -}) diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index 441683ddd3f3..11df4702fdaf 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -381,76 +381,6 @@ You can also use wildcards to control multiple tools at once. For example, to di --- -### Subagents - -Control which subagents this agent can invoke via the Task tool with the `subagents` config. - -By default, all subagents are available. Set a subagent to `false` to hide it from this agent. - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "agent": { - "build": { - "subagents": { - "code-reviewer": false - } - } - } -} -``` - -You can also use wildcards to control multiple subagents at once: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "agent": { - "build": { - "subagents": { - "orchestrator-*": false - } - } - } -} -``` - -Longer patterns take precedence over shorter ones. This allows you to exclude a group while keeping specific exceptions: - -```json title="opencode.json" -{ - "$schema": "https://opencode.ai/config.json", - "agent": { - "build": { - "subagents": { - "orchestrator-*": false, - "orchestrator-fast": true - } - } - } -} -``` - -You can also configure subagents in Markdown agents: - -```markdown title="~/.config/opencode/agent/focused.md" ---- -description: Agent with limited subagent access -mode: primary -subagents: - general: true - orchestrator-*: false ---- - -You are a focused agent with limited subagent access. -``` - -:::note -Filtered subagents will not appear in the Task tool description and cannot be invoked. -::: - ---- - ### Permissions You can configure permissions to manage what actions an agent can take. Currently, the permissions for the `edit`, `bash`, and `webfetch` tools can be configured to: From d273a4418469d82b180b1b165aa74e685bd4e931 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 10 Dec 2025 06:25:36 +0000 Subject: [PATCH 02/11] chore: format code --- packages/sdk/js/src/v2/gen/types.gen.ts | 9 --------- packages/sdk/openapi.json | 20 +------------------- 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 6ddf7b2277ea..909490b5b2bc 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -983,9 +983,6 @@ export type AgentConfig = { tools?: { [key: string]: boolean } - subagents?: { - [key: string]: boolean - } disable?: boolean /** * Description of when to use the agent @@ -1017,9 +1014,6 @@ export type AgentConfig = { | unknown | string | number - | { - [key: string]: boolean - } | { [key: string]: boolean } @@ -1631,9 +1625,6 @@ export type Agent = { tools: { [key: string]: boolean } - subagents: { - [key: string]: boolean - } options: { [key: string]: unknown } diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index a674760be951..7df8088fd4de 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7229,15 +7229,6 @@ "type": "boolean" } }, - "subagents": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, "disable": { "type": "boolean" }, @@ -8791,15 +8782,6 @@ "type": "boolean" } }, - "subagents": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "boolean" - } - }, "options": { "type": "object", "propertyNames": { @@ -8813,7 +8795,7 @@ "maximum": 9007199254740991 } }, - "required": ["name", "mode", "builtIn", "permission", "tools", "subagents", "options"] + "required": ["name", "mode", "builtIn", "permission", "tools", "options"] }, "MCPStatusConnected": { "type": "object", From 59ff398c8a90ce1df698c5133f5034a304cca013 Mon Sep 17 00:00:00 2001 From: Kyle Crommett Date: Wed, 10 Dec 2025 00:18:43 -0800 Subject: [PATCH 03/11] docs: update fork README to remove PR #4773 and refresh date --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 5c2a1a950044..12587d922024 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,10 @@ The following PRs have been merged into this fork and are awaiting merge into up | [#4791](https://github.com/sst/opencode/pull/4791) | Bash output with ANSI | [@remorses](https://github.com/remorses) | Open | Full terminal emulation for bash output with color support | | [#4900](https://github.com/sst/opencode/pull/4900) | Double Ctrl+C to exit | [@AmineGuitouni](https://github.com/AmineGuitouni) | Open | Require double Ctrl+C within 2 seconds to prevent accidental exits | | [#4709](https://github.com/sst/opencode/pull/4709) | Live token usage during streaming | [@arsham](https://github.com/arsham) | Open | Real-time token tracking and display during model responses | -| [#4773](https://github.com/sst/opencode/pull/4773) | Configurable subagent visibility | [@Sewer56](https://github.com/Sewer56) | Open | Allow agents to restrict which subagents they can invoke | | [#4865](https://github.com/sst/opencode/pull/4865) | Subagents sidebar with clickable navigation | [@franlol](https://github.com/franlol) | Open | Show subagents in sidebar with click-to-navigate and parent keybind | | [#4515](https://github.com/sst/opencode/pull/4515) | Show plugins in /status | [@spoons-and-mirrors](https://github.com/spoons-and-mirrors) | Open | Display configured plugins in /status dialog alongside MCP/LSP servers | -_Last updated: 2025-12-07_ +_Last updated: 2025-12-10_ --- From 31ab98807895fc5a2d3bd45d2bb006ed72cb20fd Mon Sep 17 00:00:00 2001 From: Kyle Crommett Date: Wed, 10 Dec 2025 00:18:52 -0800 Subject: [PATCH 04/11] feat: add ghostty-opentui dependency for terminal ANSI rendering --- bun.lock | 3 +++ packages/opencode/package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/bun.lock b/bun.lock index d2b570f0f627..dfe70a2d44d5 100644 --- a/bun.lock +++ b/bun.lock @@ -256,6 +256,7 @@ "decimal.js": "10.5.0", "diff": "catalog:", "fuzzysort": "3.1.0", + "ghostty-opentui": "1.3.6", "gray-matter": "4.0.3", "hono": "catalog:", "hono-openapi": "catalog:", @@ -2439,6 +2440,8 @@ "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], + "ghostty-opentui": ["ghostty-opentui@1.3.6", "", { "dependencies": { "strip-ansi": "^7.1.2" }, "peerDependencies": { "@opentui/core": "*" }, "optionalPeers": ["@opentui/core"] }, "sha512-DETUuSiIcTwTIqICmDEezYxt0gXk/4bGC+28Hd4fqFdejB8GTCJvRzGGcwfPoYgIKxsqcVTm1Hku3m6K+NiPAA=="], + "ghostty-web": ["ghostty-web@0.3.0", "", {}, "sha512-SAdSHWYF20GMZUB0n8kh1N6Z4ljMnuUqT8iTB2n5FAPswEV10MejEpLlhW/769GL5+BQa1NYwEg9y/XCckV5+A=="], "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ca3af5810b4c..34be78eb5ee5 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -85,6 +85,7 @@ "decimal.js": "10.5.0", "diff": "catalog:", "fuzzysort": "3.1.0", + "ghostty-opentui": "1.3.6", "gray-matter": "4.0.3", "hono": "catalog:", "hono-openapi": "catalog:", From b9b1ff7ab37342f3603ac192ef71c20d1329a1ba Mon Sep 17 00:00:00 2001 From: Kyle Crommett Date: Wed, 10 Dec 2025 00:18:58 -0800 Subject: [PATCH 05/11] feat: force color output in bash tool for ANSI rendering --- packages/opencode/src/tool/bash.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 6b0b9d41046b..25d38b64d5d0 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -227,6 +227,14 @@ export const BashTool = Tool.define("bash", async () => { cwd, env: { ...process.env, + FORCE_COLOR: "3", + CLICOLOR: "1", + CLICOLOR_FORCE: "1", + TERM: "xterm-256color", + TERM_PROGRAM: "bash-tool", + PY_COLORS: "1", + ANSICON: "1", + NO_COLOR: undefined, }, stdio: ["ignore", "pipe", "pipe"], detached: process.platform !== "win32", From 45152690579f0d35758a7f8f41fc7c558133eafc Mon Sep 17 00:00:00 2001 From: Kyle Crommett Date: Wed, 10 Dec 2025 00:19:04 -0800 Subject: [PATCH 06/11] feat: add live token tracking during streaming responses --- packages/opencode/src/session/processor.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index f1f7dd0964f4..2af91a02bae2 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -12,6 +12,7 @@ import { SessionRetry } from "./retry" import { SessionStatus } from "./status" import { Plugin } from "@/plugin" import type { Provider } from "@/provider/provider" +import { Token } from "@/util/token" export namespace SessionProcessor { const DOOM_LOOP_THRESHOLD = 3 @@ -53,6 +54,8 @@ export namespace SessionProcessor { try { let currentText: MessageV2.TextPart | undefined let reasoningMap: Record = {} + let reasoningTotal = 0 + let textTotal = 0 const stream = streamText(streamInput) for await (const value of stream.fullStream) { @@ -85,6 +88,9 @@ export namespace SessionProcessor { part.text += value.text if (value.providerMetadata) part.metadata = value.providerMetadata if (part.text) await Session.updatePart({ part, delta: value.text }) + // Track reasoning tokens for live display + reasoningTotal += value.text.length + input.assistantMessage.reasoningEstimate = Token.toTokenEstimate(reasoningTotal) } break @@ -311,6 +317,9 @@ export namespace SessionProcessor { part: currentText, delta: value.text, }) + // Track output tokens for live display + textTotal += value.text.length + input.assistantMessage.outputEstimate = Token.toTokenEstimate(textTotal) } break From 9bf147fd6010b52f3f5870e1b99c22c17f4836a3 Mon Sep 17 00:00:00 2001 From: Kyle Crommett Date: Wed, 10 Dec 2025 00:19:13 -0800 Subject: [PATCH 07/11] fix: use correct subagent session ID for click navigation --- .../src/cli/cmd/tui/routes/session/sidebar.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 1facc54eb576..b7e37549021d 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -241,14 +241,23 @@ export function Sidebar(props: { sessionID: string }) { const isError = () => part.state.status === "error" const input = part.state.input as Record const description = (input?.description as string) ?? "" - const sessionId = part.sessionID + + // Get subagent session ID from metadata, not part.sessionID (which is the parent) + const metadata = + part.state.status === "completed" + ? part.state.metadata + : ((part.state as { metadata?: Record }).metadata ?? {}) + const subagentSessionId = (metadata?.sessionId as string) ?? undefined + return ( { - route.navigate({ type: "session", sessionID: sessionId }) + if (subagentSessionId) { + route.navigate({ type: "session", sessionID: subagentSessionId }) + } }} > From 90b7bf728e536367d05ad45d6e426b38ec8e4bfa Mon Sep 17 00:00:00 2001 From: Kyle Crommett Date: Wed, 10 Dec 2025 00:19:20 -0800 Subject: [PATCH 08/11] feat: add search, token display, and bash ANSI viewer to TUI - Add Ctrl+F search with match highlighting and navigation - Add toggle tokens command with IN/OUT display - Add full-screen bash output viewer with ANSI color support - Integrate ghostty-terminal component for terminal rendering --- .../src/cli/cmd/tui/routes/session/index.tsx | 451 +++++++++++++----- 1 file changed, 326 insertions(+), 125 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 66dede77eced..d99da0a9f418 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -25,6 +25,7 @@ import { type ScrollAcceleration, } from "@opentui/core" import { Prompt, type PromptRef } from "@tui/component/prompt" +import { SearchInput, type SearchInputRef } from "@tui/component/prompt/search" import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" import { useLocal } from "@tui/context/local" import { Locale } from "@/util/locale" @@ -40,7 +41,7 @@ import type { EditTool } from "@/tool/edit" import type { PatchTool } from "@/tool/patch" import type { WebFetchTool } from "@/tool/webfetch" import type { TaskTool } from "@/tool/task" -import { useKeyboard, useRenderer, useTerminalDimensions, type BoxProps, type JSX } from "@opentui/solid" +import { useKeyboard, useRenderer, useTerminalDimensions, extend, type BoxProps, type JSX } from "@opentui/solid" import { useSDK } from "@tui/context/sdk" import { useCommandDialog } from "@tui/component/dialog-command" import { useKeybind } from "@tui/context/keybind" @@ -64,6 +65,9 @@ import { Editor } from "../../util/editor" import stripAnsi from "strip-ansi" import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" +import { GhosttyTerminalRenderable } from "ghostty-opentui/terminal-buffer" + +extend({ "ghostty-terminal": GhosttyTerminalRenderable }) addDefaultParsers(parsers.parsers) @@ -84,8 +88,13 @@ const context = createContext<{ showTimestamps: () => boolean usernameVisible: () => boolean showDetails: () => boolean + showTokens: () => boolean diffWrapMode: () => "word" | "none" sync: ReturnType + searchQuery: () => string + currentMatchIndex: () => number + matches: () => SearchMatch[] + showBashOutput: (command: string, output: () => string) => void }>() function use() { @@ -94,6 +103,18 @@ function use() { return ctx } +type SearchMatch = { + messageID: string + partID: string + matchIndex: number + charOffset: number +} + +type BashOutputView = { + command: string + output: () => string +} + export function Session() { const route = useRouteData("session") const { navigate } = useRoute() @@ -121,7 +142,65 @@ export function Session() { const [usernameVisible, setUsernameVisible] = createSignal(kv.get("username_visible", true)) const [showDetails, setShowDetails] = createSignal(kv.get("tool_details_visibility", true)) const [showScrollbar, setShowScrollbar] = createSignal(kv.get("scrollbar_visible", false)) + const [showTokens, setShowTokens] = createSignal(kv.get("show_tokens", false)) const [diffWrapMode, setDiffWrapMode] = createSignal<"word" | "none">("word") + const [searchMode, setSearchMode] = createSignal(false) + const [searchQuery, setSearchQuery] = createSignal("") + const [currentMatchIndex, setCurrentMatchIndex] = createSignal(0) + const [bashOutput, setBashOutput] = createSignal(undefined) + + function showBashOutput(command: string, output: () => string) { + setBashOutput({ command, output }) + } + + const matches = createMemo(() => { + const query = searchQuery() + if (!query) return [] + + const results: SearchMatch[] = [] + for (const message of messages()) { + const parts = sync.data.part[message.id] ?? [] + for (const part of parts) { + if (part.type !== "text") continue + const text = part.text.toLowerCase() + const queryLower = query.toLowerCase() + let index = 0 + let pos = text.indexOf(queryLower, index) + while (pos !== -1) { + results.push({ + messageID: message.id, + partID: part.id, + matchIndex: results.length, + charOffset: pos, + }) + index = pos + 1 + pos = text.indexOf(queryLower, index) + } + } + } + return results + }) + + function handleNextMatch() { + const m = matches() + if (m.length === 0) return + setCurrentMatchIndex((prev) => (prev + 1) % m.length) + scrollToMatch(currentMatchIndex()) + } + + function handlePrevMatch() { + const m = matches() + if (m.length === 0) return + setCurrentMatchIndex((prev) => (prev - 1 + m.length) % m.length) + scrollToMatch(currentMatchIndex()) + } + + function scrollToMatch(index: number) { + const m = matches()[index] + if (!m) return + const child = scroll.getChildren().find((c) => c.id === m.messageID || c.id === "text-" + m.partID) + if (child) scroll.scrollBy(child.y - scroll.y - 1) + } const wide = createMemo(() => dimensions().width > 120) const sidebarVisible = createMemo(() => { @@ -192,6 +271,19 @@ export function Session() { useKeyboard((evt) => { if (dialog.stack.length > 0) return + if (bashOutput()) { + if (evt.name === "escape") { + setBashOutput(undefined) + return + } + return + } + + if (evt.ctrl && evt.name === "f") { + setSearchMode(true) + return + } + const first = permissions()[0] if (first) { const response = iife(() => { @@ -239,6 +331,16 @@ export function Session() { const command = useCommandDialog() command.register(() => [ + { + title: "Search messages", + value: "session.search", + keybind: "session_search" as const, + category: "Session", + onSelect: (dialog) => { + setSearchMode(true) + dialog.clear() + }, + }, ...(sync.data.config.share !== "disabled" ? [ { @@ -475,6 +577,19 @@ export function Session() { dialog.clear() }, }, + { + title: showTokens() ? "Hide tokens" : "Show tokens", + value: "session.toggle.tokens", + category: "Session", + onSelect: (dialog) => { + setShowTokens((prev) => { + const next = !prev + kv.set("show_tokens", next) + return next + }) + dialog.clear() + }, + }, { title: "Toggle session scrollbar", value: "session.toggle.scrollbar", @@ -840,8 +955,13 @@ export function Session() { showTimestamps, usernameVisible, showDetails, + showTokens, diffWrapMode, sync, + searchQuery, + currentMatchIndex, + matches, + showBashOutput, }} > @@ -850,129 +970,163 @@ export function Session() {
- (scroll = r)} - verticalScrollbarOptions={{ - paddingLeft: 1, - visible: showScrollbar(), - trackOptions: { - backgroundColor: theme.backgroundElement, - foregroundColor: theme.border, - }, - }} - stickyScroll={true} - stickyStart="bottom" - flexGrow={1} - scrollAcceleration={scrollAcceleration()} - > - - {(message, index) => ( - - - {(function () { - const command = useCommandDialog() - const [hover, setHover] = createSignal(false) - const dialog = useDialog() - - const handleUnrevert = async () => { - const confirmed = await DialogConfirm.show( - dialog, - "Confirm Redo", - "Are you sure you want to restore the reverted messages?", - ) - if (confirmed) { - command.trigger("session.redo") - } - } - - return ( - setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={handleUnrevert} - marginTop={1} - flexShrink={0} - border={["left"]} - customBorderChars={SplitBorder.customBorderChars} - borderColor={theme.backgroundPanel} - > - - {revert()!.reverted.length} message reverted - - {keybind.print("messages_redo")} or /redo to - restore - - - - - {(file) => ( - - {file.filename} - 0}> - +{file.additions} - - 0}> - -{file.deletions} - - - )} - + (scroll = r)} + verticalScrollbarOptions={{ + paddingLeft: 1, + visible: showScrollbar(), + trackOptions: { + backgroundColor: theme.backgroundElement, + foregroundColor: theme.border, + }, + }} + stickyScroll={true} + stickyStart="bottom" + flexGrow={1} + scrollAcceleration={scrollAcceleration()} + > + + {(message, index) => ( + + + {(function () { + const command = useCommandDialog() + const [hover, setHover] = createSignal(false) + const dialog = useDialog() + + const handleUnrevert = async () => { + const confirmed = await DialogConfirm.show( + dialog, + "Confirm Redo", + "Are you sure you want to restore the reverted messages?", + ) + if (confirmed) { + command.trigger("session.redo") + } + } + + return ( + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={handleUnrevert} + marginTop={1} + flexShrink={0} + border={["left"]} + customBorderChars={SplitBorder.customBorderChars} + borderColor={theme.backgroundPanel} + > + + {revert()!.reverted.length} message reverted + + {keybind.print("messages_redo")} or /redo to + restore + + + + + {(file) => ( + + {file.filename} + 0}> + +{file.additions} + + 0}> + -{file.deletions} + + + )} + + + - - - - ) - })()} - - = revert()!.messageID}> - <> - - - { - if (renderer.getSelection()?.getSelectedText()) return - dialog.replace(() => ( - prompt.set(promptInfo)} - /> - )) - }} - message={message as UserMessage} - parts={sync.data.part[message.id] ?? []} - pending={pending()} - /> - - - - - - )} - - + + ) + })()} + + = revert()!.messageID}> + <> + + + { + if (renderer.getSelection()?.getSelectedText()) return + dialog.replace(() => ( + prompt.set(promptInfo)} + /> + )) + }} + message={message as UserMessage} + parts={sync.data.part[message.id] ?? []} + pending={pending()} + /> + + + + + + )} + + + } + > + + + $ {bashOutput()!.command} + + + ESC to close · PageUp/PageDown to scroll + + - { - prompt = r - promptRef.set(r) - }} - disabled={permissions().length > 0} - onSubmit={() => { - toBottom() - }} - sessionID={route.sessionID} - /> + { + setSearchQuery(query) + setCurrentMatchIndex(0) + }} + onExit={() => { + setSearchMode(false) + setSearchQuery("") + prompt.focus() + }} + onNext={handleNextMatch} + onPrevious={handlePrevMatch} + matchInfo={{ current: currentMatchIndex(), total: matches().length }} + /> + } + > + { + prompt = r + promptRef.set(r) + }} + disabled={permissions().length > 0} + onSubmit={() => { + toBottom() + }} + sessionID={route.sessionID} + /> +