From 878c1b8c2d2fadd3cf646e7ffea2489e334153fd Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 7 Feb 2026 16:41:42 -0500 Subject: [PATCH 01/72] feat(tui): add auto-accept mode for permission requests Add a toggleable auto-accept mode that automatically accepts all incoming permission requests with a 'once' reply. This is useful for users who want to streamline their workflow when they trust the agent's actions. Changes: - Add permission_auto_accept keybind (default: shift+tab) to config - Remove default for agent_cycle_reverse (was shift+tab) - Add auto-accept logic in sync.tsx to auto-reply when enabled - Add command bar action to toggle auto-accept mode (copy: "Toggle autoaccept permissions") - Add visual indicator showing 'auto-accept' when active - Store auto-accept state in KV for persistence across sessions --- .../cli/cmd/tui/component/prompt/index.tsx | 50 +++++++++++++------ .../opencode/src/cli/cmd/tui/context/sync.tsx | 11 ++++ packages/opencode/src/config/config.ts | 7 ++- 3 files changed, 51 insertions(+), 17 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 8576dd5763ab..8a08c3fc1378 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -74,6 +74,7 @@ export function Prompt(props: PromptProps) { const renderer = useRenderer() const { theme, syntax } = useTheme() const kv = useKV() + const [autoaccept, setAutoaccept] = kv.signal("permission_auto_accept", false) function promptModelWarning() { toast.show({ @@ -157,6 +158,16 @@ export function Prompt(props: PromptProps) { command.register(() => { return [ + { + title: "Toggle autoaccept permissions", + value: "permission.auto_accept.toggle", + keybind: "permission_auto_accept_toggle", + category: "Permission", + onSelect: (dialog) => { + setAutoaccept(!autoaccept() as any) + dialog.clear() + }, + }, { title: "Clear prompt", value: "prompt.clear", @@ -973,23 +984,30 @@ export function Prompt(props: PromptProps) { cursorColor={theme.text} syntaxStyle={syntax()} /> - - - {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} - - - - - {local.model.parsed().model} - - {local.model.parsed().provider} - - · - - {local.model.variant.current()} + + + + {store.mode === "shell" ? "Shell" : Locale.titlecase(local.agent.current().name)}{" "} + + + + + {local.model.parsed().model} - - + {local.model.parsed().provider} + + · + + {local.model.variant.current()} + + + + + + + + auto-accept + diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index eb8ed2d9bbad..2ad41d348254 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -25,6 +25,7 @@ import { createSimpleContext } from "./helper" import type { Snapshot } from "@/snapshot" import { useExit } from "./exit" import { useArgs } from "./args" +import { useKV } from "./kv" import { batch, onMount } from "solid-js" import { Log } from "@/util/log" import type { Path } from "@opencode-ai/sdk" @@ -103,6 +104,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }) const sdk = useSDK() + const kv = useKV() + const [autoaccept] = kv.signal("permission_auto_accept", false) sdk.event.listen((e) => { const event = e.details @@ -127,6 +130,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ case "permission.asked": { const request = event.properties + if (autoaccept()) { + sdk.client.permission.reply({ + reply: "once", + requestID: request.id, + }) + break + } const requests = store.permission[request.sessionID] if (!requests) { setStore("permission", request.sessionID, [request]) @@ -423,6 +433,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ get ready() { return store.status !== "loading" }, + session: { get(sessionID: string) { const match = Binary.search(store.session, sessionID, (s) => s.id) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a231a5300724..1f6ca484ce38 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -818,7 +818,12 @@ export namespace Config { command_list: z.string().optional().default("ctrl+p").describe("List available commands"), agent_list: z.string().optional().default("a").describe("List agents"), agent_cycle: z.string().optional().default("tab").describe("Next agent"), - agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"), + agent_cycle_reverse: z.string().optional().default("none").describe("Previous agent"), + permission_auto_accept_toggle: z + .string() + .optional() + .default("shift+tab") + .describe("Toggle auto-accept mode for permissions"), variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"), input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"), input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"), From 405cc3f610a16c28c521e049e6d0fdbd67e2cc35 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 7 Feb 2026 16:51:55 -0500 Subject: [PATCH 02/72] tui: streamline permission toggle command naming and add keyboard shortcut support Rename 'Toggle autoaccept permissions' to 'Toggle permissions' for clarity and move the command to the Agent category for better discoverability. Add permission_auto_accept_toggle keybind to enable keyboard shortcut toggling of auto-accept mode for permission requests. --- packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx | 4 ++-- packages/sdk/js/src/v2/gen/types.gen.ts | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 8a08c3fc1378..b2cd177f146e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -159,10 +159,10 @@ export function Prompt(props: PromptProps) { command.register(() => { return [ { - title: "Toggle autoaccept permissions", + title: "Toggle permissions", value: "permission.auto_accept.toggle", keybind: "permission_auto_accept_toggle", - category: "Permission", + category: "Agent", onSelect: (dialog) => { setAutoaccept(!autoaccept() as any) dialog.clear() diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index d72c37a28b5a..8740059607f0 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1126,6 +1126,10 @@ export type KeybindsConfig = { * Previous agent */ agent_cycle_reverse?: string + /** + * Toggle auto-accept mode for permissions + */ + permission_auto_accept_toggle?: string /** * Cycle model variants */ From f202536b65b5a42a9533a527b697fc83ed7cd0c6 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 7 Feb 2026 16:57:48 -0500 Subject: [PATCH 03/72] tui: show enable/disable state in permission toggle and make it searchable by 'toggle permissions' --- packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx | 3 ++- packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index b2cd177f146e..362a6c0b5f96 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -159,8 +159,9 @@ export function Prompt(props: PromptProps) { command.register(() => { return [ { - title: "Toggle permissions", + title: autoaccept() ? "Disable permissions" : "Enable permissions", value: "permission.auto_accept.toggle", + search: "toggle permissions", keybind: "permission_auto_accept_toggle", category: "Agent", onSelect: (dialog) => { diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 7792900bcfef..6ba0648086c3 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -33,6 +33,7 @@ export interface DialogSelectOption { title: string value: T description?: string + search?: string footer?: JSX.Element | string category?: string disabled?: boolean @@ -85,8 +86,8 @@ export function DialogSelect(props: DialogSelectProps) { // users typically search by the item name, and not its category. const result = fuzzysort .go(needle, options, { - keys: ["title", "category"], - scoreFn: (r) => r[0].score * 2 + r[1].score, + keys: ["title", "category", "search"], + scoreFn: (r) => r[0].score * 2 + r[1].score + r[2].score, }) .map((x) => x.obj) From ac244b1458f6092aa2303738e58e339e83e839e6 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 7 Feb 2026 17:03:34 -0500 Subject: [PATCH 04/72] tui: add searchable 'toggle' keywords to command palette and show current state in toggle titles --- packages/opencode/src/cli/cmd/tui/app.tsx | 9 ++++++++- packages/opencode/src/cli/cmd/tui/routes/home.tsx | 1 + .../opencode/src/cli/cmd/tui/routes/session/index.tsx | 8 +++++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 0d5aefe7bc3b..5b1a85b6f5fa 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -415,6 +415,7 @@ function App() { { title: "Toggle MCPs", value: "mcp.list", + search: "toggle mcps", category: "Agent", slash: { name: "mcps", @@ -490,8 +491,9 @@ function App() { category: "System", }, { - title: "Toggle appearance", + title: mode() === "dark" ? "Light mode" : "Dark mode", value: "theme.switch_mode", + search: "toggle appearance", onSelect: (dialog) => { setMode(mode() === "dark" ? "light" : "dark") dialog.clear() @@ -530,6 +532,7 @@ function App() { }, { title: "Toggle debug panel", + search: "toggle debug", category: "System", value: "app.debug", onSelect: (dialog) => { @@ -539,6 +542,7 @@ function App() { }, { title: "Toggle console", + search: "toggle console", category: "System", value: "app.console", onSelect: (dialog) => { @@ -579,6 +583,7 @@ function App() { { title: terminalTitleEnabled() ? "Disable terminal title" : "Enable terminal title", value: "terminal.title.toggle", + search: "toggle terminal title", keybind: "terminal_title_toggle", category: "System", onSelect: (dialog) => { @@ -594,6 +599,7 @@ function App() { { title: kv.get("animations_enabled", true) ? "Disable animations" : "Enable animations", value: "app.toggle.animations", + search: "toggle animations", category: "System", onSelect: (dialog) => { kv.set("animations_enabled", !kv.get("animations_enabled", true)) @@ -603,6 +609,7 @@ function App() { { title: kv.get("diff_wrap_mode", "word") === "word" ? "Disable diff wrapping" : "Enable diff wrapping", value: "app.toggle.diffwrap", + search: "toggle diff wrapping", category: "System", onSelect: (dialog) => { const current = kv.get("diff_wrap_mode", "word") diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index 59923c69d94c..48ec24d0d555 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -46,6 +46,7 @@ export function Home() { { title: tipsHidden() ? "Show tips" : "Hide tips", value: "tips.toggle", + search: "toggle tips", keybind: "tips_toggle", category: "System", onSelect: (dialog) => { 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 77872eedaddd..70a038ffe368 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -509,6 +509,7 @@ export function Session() { { title: sidebarVisible() ? "Hide sidebar" : "Show sidebar", value: "session.sidebar.toggle", + search: "toggle sidebar", keybind: "sidebar_toggle", category: "Session", onSelect: (dialog) => { @@ -523,6 +524,7 @@ export function Session() { { title: conceal() ? "Disable code concealment" : "Enable code concealment", value: "session.toggle.conceal", + search: "toggle code concealment", keybind: "messages_toggle_conceal" as any, category: "Session", onSelect: (dialog) => { @@ -533,6 +535,7 @@ export function Session() { { title: showTimestamps() ? "Hide timestamps" : "Show timestamps", value: "session.toggle.timestamps", + search: "toggle timestamps", category: "Session", slash: { name: "timestamps", @@ -546,6 +549,7 @@ export function Session() { { title: showThinking() ? "Hide thinking" : "Show thinking", value: "session.toggle.thinking", + search: "toggle thinking", keybind: "display_thinking", category: "Session", slash: { @@ -560,6 +564,7 @@ export function Session() { { title: showDetails() ? "Hide tool details" : "Show tool details", value: "session.toggle.actions", + search: "toggle tool details", keybind: "tool_details", category: "Session", onSelect: (dialog) => { @@ -568,8 +573,9 @@ export function Session() { }, }, { - title: "Toggle session scrollbar", + title: showScrollbar() ? "Hide session scrollbar" : "Show session scrollbar", value: "session.toggle.scrollbar", + search: "toggle session scrollbar", keybind: "scrollbar_toggle", category: "Session", onSelect: (dialog) => { From ad545d0cc9152675549f878f258aeff37f9e17e8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 7 Feb 2026 19:52:53 -0500 Subject: [PATCH 05/72] tui: allow auto-accepting only edit permissions instead of all permissions --- packages/opencode/src/agent/agent.ts | 1 + .../src/cli/cmd/tui/component/prompt/index.tsx | 10 +++++----- packages/opencode/src/cli/cmd/tui/context/sync.tsx | 4 ++-- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index e338559be7e4..a091484f15ec 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -63,6 +63,7 @@ export namespace Agent { question: "deny", plan_enter: "deny", plan_exit: "deny", + edit: "ask", // mirrors github.com/github/gitignore Node.gitignore pattern for .env files read: { "*": "allow", diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 362a6c0b5f96..a78ef1102287 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -74,7 +74,7 @@ export function Prompt(props: PromptProps) { const renderer = useRenderer() const { theme, syntax } = useTheme() const kv = useKV() - const [autoaccept, setAutoaccept] = kv.signal("permission_auto_accept", false) + const [autoaccept, setAutoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit") function promptModelWarning() { toast.show({ @@ -159,13 +159,13 @@ export function Prompt(props: PromptProps) { command.register(() => { return [ { - title: autoaccept() ? "Disable permissions" : "Enable permissions", + title: autoaccept() === "none" ? "Enable autoedit" : "Disable autoedit", value: "permission.auto_accept.toggle", search: "toggle permissions", keybind: "permission_auto_accept_toggle", category: "Agent", onSelect: (dialog) => { - setAutoaccept(!autoaccept() as any) + setAutoaccept(() => (autoaccept() === "none" ? "edit" : "none")) dialog.clear() }, }, @@ -1005,9 +1005,9 @@ export function Prompt(props: PromptProps) { - + - auto-accept + auto-edit diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 2ad41d348254..a51461125874 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -105,7 +105,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sdk = useSDK() const kv = useKV() - const [autoaccept] = kv.signal("permission_auto_accept", false) + const [autoaccept] = kv.signal<"none" | "edit">("permission_auto_accept", "edit") sdk.event.listen((e) => { const event = e.details @@ -130,7 +130,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ case "permission.asked": { const request = event.properties - if (autoaccept()) { + if (autoaccept() === "edit" && request.permission === "edit") { sdk.client.permission.reply({ reply: "once", requestID: request.id, From bb3382311d720a21c38186a540e7c12d1fd68a50 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 7 Feb 2026 19:57:45 -0500 Subject: [PATCH 06/72] tui: standardize autoedit indicator text styling to match other status labels --- packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index a78ef1102287..97e3d1052340 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1007,7 +1007,7 @@ export function Prompt(props: PromptProps) { - auto-edit + autoedit From a531f3f36d441cc39082fc5c9aaab64ac35f60ad Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sat, 7 Feb 2026 20:00:09 -0500 Subject: [PATCH 07/72] core: run command build agent now auto-accepts file edits to reduce workflow interruptions while still requiring confirmation for bash commands --- packages/opencode/src/cli/cmd/run.ts | 5 +++++ packages/opencode/test/agent/agent.test.ts | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 163a5820d99d..a7d6fa7f3926 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -350,6 +350,11 @@ export const RunCommand = cmd({ action: "deny", pattern: "*", }, + { + permission: "edit", + action: "allow", + pattern: "*", + }, ] function title() { diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts index 5e91059ffb36..cde30f681ff0 100644 --- a/packages/opencode/test/agent/agent.test.ts +++ b/packages/opencode/test/agent/agent.test.ts @@ -38,7 +38,7 @@ test("build agent has correct default properties", async () => { expect(build).toBeDefined() expect(build?.mode).toBe("primary") expect(build?.native).toBe(true) - expect(evalPerm(build, "edit")).toBe("allow") + expect(evalPerm(build, "edit")).toBe("ask") expect(evalPerm(build, "bash")).toBe("allow") }, }) @@ -203,8 +203,8 @@ test("agent permission config merges with defaults", async () => { expect(build).toBeDefined() // Specific pattern is denied expect(PermissionNext.evaluate("bash", "rm -rf *", build!.permission).action).toBe("deny") - // Edit still allowed - expect(evalPerm(build, "edit")).toBe("allow") + // Edit still asks (default behavior) + expect(evalPerm(build, "edit")).toBe("ask") }, }) }) From 9d78b69cd31ab208ed34f36007fa1e21755d6b5d Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 20 Feb 2026 10:59:59 -0600 Subject: [PATCH 08/72] wip(app): beta badge --- packages/app/src/components/titlebar.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 760f40fc0ef3..aeac42bb4445 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -265,6 +265,9 @@ export function Titlebar() {
+
+ BETA +
From abd9e195ace5eea8c52b543743b8ce1605f43fad Mon Sep 17 00:00:00 2001 From: Makonnen Date: Thu, 19 Feb 2026 05:02:23 +0000 Subject: [PATCH 09/72] fix: use parentID matching instead of ID ordering for prompt loop exit and message rendering When the client clock is ahead of the server, user message IDs (generated client-side) sort after assistant message IDs (generated server-side). This broke the prompt loop exit check and the UI message pairing logic. - Extract shouldExitLoop() into a pure function that uses parentID matching instead of relying on ID ordering - Extract findAssistantMessages() with forward+backward scan to handle messages sorted out of expected order due to clock skew - Remove debug console.log statements added during investigation - Add tests for both extracted functions Co-Authored-By: Claude Opus 4.6 --- .../session/find-assistant-messages.test.ts | 81 ++++++++++++++++++ packages/opencode/src/session/processor.ts | 6 +- packages/opencode/src/session/prompt.ts | 17 ++-- .../test/session/prompt-loop-exit.test.ts | 85 +++++++++++++++++++ .../components/find-assistant-messages.tsx | 40 +++++++++ packages/ui/src/components/session-turn.tsx | 10 +-- 6 files changed, 222 insertions(+), 17 deletions(-) create mode 100644 packages/app/src/components/session/find-assistant-messages.test.ts create mode 100644 packages/opencode/test/session/prompt-loop-exit.test.ts create mode 100644 packages/ui/src/components/find-assistant-messages.tsx diff --git a/packages/app/src/components/session/find-assistant-messages.test.ts b/packages/app/src/components/session/find-assistant-messages.test.ts new file mode 100644 index 000000000000..ab944e2cc9f1 --- /dev/null +++ b/packages/app/src/components/session/find-assistant-messages.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test" +import type { Message } from "@opencode-ai/sdk/v2/client" +import { findAssistantMessages } from "@opencode-ai/ui/find-assistant-messages" + +function user(id: string): Message { + return { + id, + role: "user", + sessionID: "session-1", + time: { created: 1 }, + } as unknown as Message +} + +function assistant(id: string, parentID: string): Message { + return { + id, + role: "assistant", + sessionID: "session-1", + parentID, + time: { created: 1 }, + } as unknown as Message +} + +describe("findAssistantMessages", () => { + test("normal ordering: assistant after user in array → found via forward scan", () => { + const messages = [user("u1"), assistant("a1", "u1")] + const result = findAssistantMessages(messages, 0, "u1") + expect(result).toHaveLength(1) + expect(result[0].id).toBe("a1") + }) + + test("clock skew: assistant before user in array → found via backward scan", () => { + // When client clock is ahead, user ID sorts after assistant ID, + // so assistant appears earlier in the ID-sorted message array + const messages = [assistant("a1", "u1"), user("u1")] + const result = findAssistantMessages(messages, 1, "u1") + expect(result).toHaveLength(1) + expect(result[0].id).toBe("a1") + }) + + test("no assistant messages → returns empty array", () => { + const messages = [user("u1"), user("u2")] + const result = findAssistantMessages(messages, 0, "u1") + expect(result).toHaveLength(0) + }) + + test("multiple assistant messages with matching parentID → all found", () => { + const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "u1")] + const result = findAssistantMessages(messages, 0, "u1") + expect(result).toHaveLength(2) + expect(result[0].id).toBe("a1") + expect(result[1].id).toBe("a2") + }) + + test("does not return assistant messages with different parentID", () => { + const messages = [user("u1"), assistant("a1", "u1"), assistant("a2", "other")] + const result = findAssistantMessages(messages, 0, "u1") + expect(result).toHaveLength(1) + expect(result[0].id).toBe("a1") + }) + + test("stops forward scan at next user message", () => { + const messages = [user("u1"), assistant("a1", "u1"), user("u2"), assistant("a2", "u1")] + const result = findAssistantMessages(messages, 0, "u1") + expect(result).toHaveLength(1) + expect(result[0].id).toBe("a1") + }) + + test("stops backward scan at previous user message", () => { + const messages = [assistant("a0", "u1"), user("u0"), assistant("a1", "u1"), user("u1")] + const result = findAssistantMessages(messages, 3, "u1") + expect(result).toHaveLength(1) + expect(result[0].id).toBe("a1") + }) + + test("invalid index returns empty array", () => { + const messages = [user("u1")] + expect(findAssistantMessages(messages, -1, "u1")).toHaveLength(0) + expect(findAssistantMessages(messages, 5, "u1")).toHaveLength(0) + }) +}) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index e7532d20073b..03b97c73b201 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -409,10 +409,8 @@ export namespace SessionProcessor { } input.assistantMessage.time.completed = Date.now() await Session.updateMessage(input.assistantMessage) - if (needsCompaction) return "compact" - if (blocked) return "stop" - if (input.assistantMessage.error) return "stop" - return "continue" + const exitReason = needsCompaction ? "compact" : blocked ? "stop" : input.assistantMessage.error ? "stop" : "continue" + return exitReason as any } }, } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 75bd3c9dfaca..f6b35a0f5398 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -315,11 +315,7 @@ export namespace SessionPrompt { } if (!lastUser) throw new Error("No user message found in stream. This should never happen.") - if ( - lastAssistant?.finish && - !["tool-calls", "unknown"].includes(lastAssistant.finish) && - lastUser.id < lastAssistant.id - ) { + if (shouldExitLoop(lastUser, lastAssistant)) { log.info("exiting loop", { sessionID }) break } @@ -1956,4 +1952,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the return Session.setTitle({ sessionID: input.session.id, title }) } } + + /** @internal Exported for testing — determines whether the prompt loop should exit */ + export function shouldExitLoop( + lastUser: MessageV2.User | undefined, + lastAssistant: MessageV2.Assistant | undefined, + ): boolean { + if (!lastUser) return false + if (!lastAssistant?.finish) return false + if (["tool-calls", "unknown"].includes(lastAssistant.finish)) return false + return lastAssistant.parentID === lastUser.id + } } diff --git a/packages/opencode/test/session/prompt-loop-exit.test.ts b/packages/opencode/test/session/prompt-loop-exit.test.ts new file mode 100644 index 000000000000..8de582d0d7e5 --- /dev/null +++ b/packages/opencode/test/session/prompt-loop-exit.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test" +import type { MessageV2 } from "../../src/session/message-v2" +import { SessionPrompt } from "../../src/session/prompt" + +function makeUser(id: string): MessageV2.User { + return { + id, + role: "user", + sessionID: "session-1", + time: { created: Date.now() }, + agent: "default", + model: { providerID: "openai", modelID: "gpt-4" }, + } as MessageV2.User +} + +function makeAssistant( + id: string, + parentID: string, + finish?: string, +): MessageV2.Assistant { + return { + id, + role: "assistant", + sessionID: "session-1", + parentID, + mode: "default", + agent: "default", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: "gpt-4", + providerID: "openai", + time: { created: Date.now() }, + finish, + } as MessageV2.Assistant +} + +describe("shouldExitLoop", () => { + test("normal case: user ID < assistant ID, parentID matches, finish=end_turn → exits", () => { + const user = makeUser("01AAA") + const assistant = makeAssistant("01BBB", "01AAA", "end_turn") + expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(true) + }) + + test("clock skew: user ID > assistant ID, parentID matches, finish=stop → exits", () => { + // Simulates client clock ahead: user message ID sorts AFTER the assistant ID + const user = makeUser("01ZZZ") + const assistant = makeAssistant("01AAA", "01ZZZ", "stop") + expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(true) + }) + + test("unfinished assistant: finish=tool-calls → does NOT exit", () => { + const user = makeUser("01AAA") + const assistant = makeAssistant("01BBB", "01AAA", "tool-calls") + expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false) + }) + + test("unfinished assistant: finish=unknown → does NOT exit", () => { + const user = makeUser("01AAA") + const assistant = makeAssistant("01BBB", "01AAA", "unknown") + expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false) + }) + + test("no assistant yet → does NOT exit", () => { + const user = makeUser("01AAA") + expect(SessionPrompt.shouldExitLoop(user, undefined)).toBe(false) + }) + + test("assistant has no finish → does NOT exit", () => { + const user = makeUser("01AAA") + const assistant = makeAssistant("01BBB", "01AAA", undefined) + expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false) + }) + + test("parentID mismatch → does NOT exit", () => { + const user = makeUser("01AAA") + const assistant = makeAssistant("01BBB", "01OTHER", "end_turn") + expect(SessionPrompt.shouldExitLoop(user, assistant)).toBe(false) + }) + + test("no user message → does NOT exit", () => { + const assistant = makeAssistant("01BBB", "01AAA", "end_turn") + expect(SessionPrompt.shouldExitLoop(undefined, assistant)).toBe(false) + }) +}) diff --git a/packages/ui/src/components/find-assistant-messages.tsx b/packages/ui/src/components/find-assistant-messages.tsx new file mode 100644 index 000000000000..1448e5e34712 --- /dev/null +++ b/packages/ui/src/components/find-assistant-messages.tsx @@ -0,0 +1,40 @@ +import type { AssistantMessage, Message as MessageType } from "@opencode-ai/sdk/v2/client" + +/** + * Find assistant messages that are replies to a given user message. + * + * Scans forward from the user message index first, then falls back to scanning + * backward. The backward scan handles clock skew where assistant messages + * (generated server-side) sort before the user message (generated client-side + * with an ahead clock) in the ID-sorted array. + */ +export function findAssistantMessages( + messages: MessageType[], + userIndex: number, + userID: string, +): AssistantMessage[] { + if (userIndex < 0 || userIndex >= messages.length) return [] + + const result: AssistantMessage[] = [] + + // Scan forward from user message + for (let i = userIndex + 1; i < messages.length; i++) { + const item = messages[i] + if (!item) continue + if (item.role === "user") break + if (item.role === "assistant" && item.parentID === userID) result.push(item as AssistantMessage) + } + + // Scan backward to find assistant messages that sort before the user + // message due to clock skew between client and server + if (result.length === 0) { + for (let i = userIndex - 1; i >= 0; i--) { + const item = messages[i] + if (!item) continue + if (item.role === "user") break + if (item.role === "assistant" && item.parentID === userID) result.push(item as AssistantMessage) + } + } + + return result +} diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 33e72fb1e788..4cca4a136f56 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -7,6 +7,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path" import { createEffect, createMemo, createSignal, For, on, ParentProps, Show } from "solid-js" import { Dynamic } from "solid-js/web" import { AssistantParts, Message, PART_MAPPING } from "./message-part" +import { findAssistantMessages } from "./find-assistant-messages" import { Card } from "./card" import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" @@ -240,14 +241,7 @@ export function SessionTurn( const index = messageIndex() if (index < 0) return emptyAssistant - const result: AssistantMessage[] = [] - for (let i = index + 1; i < messages.length; i++) { - const item = messages[i] - if (!item) continue - if (item.role === "user") break - if (item.role === "assistant" && item.parentID === msg.id) result.push(item as AssistantMessage) - } - return result + return findAssistantMessages(messages, index, msg.id) }, emptyAssistant, { equals: same }, From fc258ea74fd7015634bfa326f600a4c1f3e9bac3 Mon Sep 17 00:00:00 2001 From: MakonnenMak Date: Thu, 19 Feb 2026 12:15:35 -0500 Subject: [PATCH 10/72] fix: remove as any type cast in processor exit logic --- packages/opencode/src/session/processor.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 03b97c73b201..e7532d20073b 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -409,8 +409,10 @@ export namespace SessionProcessor { } input.assistantMessage.time.completed = Date.now() await Session.updateMessage(input.assistantMessage) - const exitReason = needsCompaction ? "compact" : blocked ? "stop" : input.assistantMessage.error ? "stop" : "continue" - return exitReason as any + if (needsCompaction) return "compact" + if (blocked) return "stop" + if (input.assistantMessage.error) return "stop" + return "continue" } }, } From 724dd665ecf3826f9c9b8580dd92475325cda2b9 Mon Sep 17 00:00:00 2001 From: David Hill Date: Fri, 27 Feb 2026 18:47:53 +0000 Subject: [PATCH 11/72] tweak(ui): collapse questions --- .../composer/session-question-dock.tsx | 293 ++++++++++-------- packages/ui/src/components/message-part.css | 43 --- 2 files changed, 164 insertions(+), 172 deletions(-) diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index fd2ced3dc814..65d570a748dc 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -3,6 +3,7 @@ import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { DockPrompt } from "@opencode-ai/ui/dock-prompt" import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" import { showToast } from "@opencode-ai/ui/toast" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import { useLanguage } from "@/context/language" @@ -22,6 +23,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit customOn: [] as boolean[], editing: false, sending: false, + collapsed: false, }) let root: HTMLDivElement | undefined @@ -31,6 +33,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const input = createMemo(() => store.custom[store.tab] ?? "") const on = createMemo(() => store.customOn[store.tab] === true) const multi = createMemo(() => question()?.multiple === true) + const picked = createMemo(() => store.answers[store.tab]?.length ?? 0) const summary = createMemo(() => { const n = Math.min(store.tab + 1, total()) @@ -39,6 +42,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const last = createMemo(() => store.tab >= total() - 1) + const fold = () => setStore("collapsed", (value) => !value) + const customUpdate = (value: string, selected: boolean = on()) => { const prev = input().trim() const next = value.trim() @@ -228,38 +233,44 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit setStore("editing", false) } - const jump = (tab: number) => { - if (store.sending) return - setStore("tab", tab) - setStore("editing", false) - } - return ( (root = el)} header={ - <> +
{ + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + fold() + }} + >
{summary()}
-
- - {(_, i) => ( -
} footer={ <> @@ -279,56 +290,121 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } > -
{question()?.question}
- {language.t("ui.question.singleHint")}
}> -
{language.t("ui.question.multiHint")}
+
{ + if (!store.collapsed) return + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + fold() + }} + > + {question()?.question} +
+ 0}> +
+ {picked()} answer{picked() === 1 ? "" : "s"} selected +
-
- - {(opt, i) => { - const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false - return ( + }> +
{language.t("ui.question.multiHint")}
+ +
+ + {(opt, i) => { + const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false + return ( + + ) + }} + + + selectOption(i())} + onClick={customOpen} > -