From 45c0fc60aa83ab2d1d03542f0bc3b0ca3612f50c Mon Sep 17 00:00:00 2001 From: Kjell Knapen Date: Thu, 12 Mar 2026 09:02:48 +0100 Subject: [PATCH 1/6] Add initial /btw command, todo IMPLEMENT --- .../src/cli/cmd/tui/routes/session/index.tsx | 31 ++++++++++++++++--- 1 file changed, 27 insertions(+), 4 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 7456742cdf3..2e875b22ccc 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -52,7 +52,7 @@ import { useCommandDialog } from "@tui/component/dialog-command" import type { DialogContext } from "@tui/ui/dialog" import { useKeybind } from "@tui/context/keybind" import { Header } from "./header" -import { parsePatch } from "diff" +import { diffLines, parsePatch } from "diff" import { useDialog } from "../../ui/dialog" import { TodoItem } from "../../component/todo-item" import { DialogMessage } from "./dialog-message" @@ -81,17 +81,18 @@ import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" +import { DialogPrompt } from "../../ui/dialog-prompt.tsx" addDefaultParsers(parsers.parsers) class CustomSpeedScroll implements ScrollAcceleration { - constructor(private speed: number) {} + constructor(private speed: number) { } tick(_now?: number): number { return this.speed } - reset(): void {} + reset(): void { } } const context = createContext<{ @@ -510,7 +511,7 @@ export function Session() { }, onSelect: async (dialog) => { const status = sync.data.session_status?.[route.sessionID] - if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {}) + if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => { }) const revert = session()?.revert?.messageID const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user") if (!message) return @@ -616,6 +617,28 @@ export function Session() { dialog.clear() }, }, + { + title: "Ask a quick question", + description: "Ask a quick question about your current work without adding to the conversation history.", + value: "session.btw", + keybind: "btw", + category: "Session", + slash: { + name: "btw", + }, + onSelect: (dialog) => { + dialog.replace(() => ( + { + console.log(value); + dialog.clear() + }} + /> + )); + }, + }, { title: showDetails() ? "Hide tool details" : "Show tool details", value: "session.toggle.actions", From 721e57c036fb4ebd142887e536e55c2b1d8b80f6 Mon Sep 17 00:00:00 2001 From: Kjell Knapen Date: Thu, 12 Mar 2026 09:08:27 +0100 Subject: [PATCH 2/6] Add comments to describe how we want to implement --- packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 2e875b22ccc..b05b44f3c38 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -632,7 +632,9 @@ export function Session() { title="Btw Prompt" placeholder="Ask a quick question" onConfirm={(value) => { - console.log(value); + // read value + // pipe value to ask question to session history and give an answer + // display answer in a dialog dialog.clear() }} /> From 7946468e55e118d17dd126dc5ef14944ca3d0a0f Mon Sep 17 00:00:00 2001 From: Kjell Knapen Date: Thu, 12 Mar 2026 14:24:36 +0100 Subject: [PATCH 3/6] Add interception for btw command --- .../cli/cmd/tui/component/prompt/index.tsx | 12 ++ .../src/cli/cmd/tui/routes/session/index.tsx | 161 ++++++++++++++++-- 2 files changed, 156 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 c85426cc247..3ec6b7c3e4c 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -41,6 +41,7 @@ export type PromptProps = { visible?: boolean disabled?: boolean onSubmit?: () => void + onBeforeSubmit?: (text: string) => boolean ref?: (ref: PromptRef) => void hint?: JSX.Element showPlaceholder?: boolean @@ -587,6 +588,17 @@ export function Prompt(props: PromptProps) { const currentMode = store.mode const variant = local.model.variant.current() + // Allow external interception (e.g., /btw mode) + if (props.onBeforeSubmit?.(inputText)) { + history.append({ ...store.prompt, mode: currentMode }) + input.extmarks.clear() + setStore("prompt", { input: "", parts: [] }) + setStore("extmarkToPartIndex", new Map()) + input.clear() + props.onSubmit?.() + return + } + if (store.mode === "shell") { sdk.client.session.shell({ sessionID, 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 b05b44f3c38..c6e357d4e76 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -81,18 +81,23 @@ import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" -import { DialogPrompt } from "../../ui/dialog-prompt.tsx" +import { Identifier } from "@/id/id" addDefaultParsers(parsers.parsers) +type BtwState = + | { type: "waiting" } + | { type: "loading"; question: string; userMessageID: string } + | { type: "response"; question: string; responseMessageID: string } + class CustomSpeedScroll implements ScrollAcceleration { - constructor(private speed: number) { } + constructor(private speed: number) {} tick(_now?: number): number { return this.speed } - reset(): void { } + reset(): void {} } const context = createContext<{ @@ -129,7 +134,8 @@ export function Session() { .filter((x) => x.parentID === parentID || x.id === parentID) .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) }) - const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) + const messages = createMemo(() => (sync.data.message[route.sessionID] ?? []).filter((m) => !m.btw)) + const [btwState, setBtwState] = createSignal(null) const permissions = createMemo(() => { if (session()?.parentID) return [] return children().flatMap((x) => sync.data.permission[x.id] ?? []) @@ -260,6 +266,39 @@ export function Session() { ) }) + // Watch for btw response arriving via sync events + createEffect(() => { + const state = btwState() + if (!state || state.type !== "loading") return + const msgs = sync.data.message[route.sessionID] ?? [] + const response = msgs.find( + (m): m is AssistantMessage => + m.role === "assistant" && + (m as AssistantMessage).parentID === state.userMessageID && + m.btw === true && + !!(m as AssistantMessage).finish && + !["tool-calls", "unknown"].includes((m as AssistantMessage).finish!), + ) + if (response) { + setBtwState({ type: "response", question: state.question, responseMessageID: response.id }) + } + }) + + // Dismiss btw with esc (any state) or enter (response state) + useKeyboard((evt) => { + const state = btwState() + if (!state) return + if (evt.name === "escape") { + setBtwState(null) + evt.preventDefault() + evt.stopPropagation() + } else if (state.type === "response" && evt.name === "return") { + setBtwState(null) + evt.preventDefault() + evt.stopPropagation() + } + }) + useKeyboard((evt) => { if (!session()?.parentID) return if (keybind.match("app_exit", evt)) { @@ -320,6 +359,33 @@ export function Session() { }, 50) } + const btwBeforeSubmit = (text: string): boolean => { + const state = btwState() + if (!state || state.type !== "waiting") return false + const sessionID = session()?.id + if (!sessionID) { + setBtwState(null) + return false + } + const userMessageID = Identifier.ascending("message") + setBtwState({ type: "loading", question: text, userMessageID }) + sdk.client.session + .promptAsync({ + sessionID, + btw: true, + messageID: userMessageID, + parts: [{ type: "text", text }], + }) + .catch((error) => { + toast.show({ + message: error instanceof Error ? error.message : "Failed", + variant: "error", + }) + setBtwState(null) + }) + return true + } + const local = useLocal() function moveFirstChild() { @@ -511,7 +577,7 @@ export function Session() { }, onSelect: async (dialog) => { const status = sync.data.session_status?.[route.sessionID] - if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => { }) + if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {}) const revert = session()?.revert?.messageID const message = messages().findLast((x) => (!revert || x.id < revert) && x.role === "user") if (!message) return @@ -627,18 +693,8 @@ export function Session() { name: "btw", }, onSelect: (dialog) => { - dialog.replace(() => ( - { - // read value - // pipe value to ask question to session history and give an answer - // display answer in a dialog - dialog.clear() - }} - /> - )); + setBtwState({ type: "waiting" }) + dialog.clear() }, }, { @@ -1189,6 +1245,7 @@ export function Session() { )} + 0}> @@ -1208,6 +1265,7 @@ export function Session() { } }} disabled={permissions().length > 0 || questions().length > 0} + onBeforeSubmit={btwBeforeSubmit} onSubmit={() => { toBottom() }} @@ -2304,3 +2362,72 @@ function filetype(input?: string) { if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript" return language } + +function BtwSection(props: { state: BtwState | null; sync: ReturnType; sessionID: string }) { + const { theme, syntax } = useTheme() + + const responseText = createMemo(() => { + const state = props.state + if (!state || state.type !== "response") return null + const msgs = props.sync.data.message[props.sessionID] ?? [] + const msg = msgs.find((m) => m.id === state.responseMessageID) + if (!msg) return null + const parts = props.sync.data.part[msg.id] ?? [] + return parts + .filter((p): p is TextPart => p.type === "text" && !(p as TextPart).synthetic) + .map((p) => p.text) + .join("") + }) + + return ( + + + + + /btw + + + {(props.state as { type: "loading" | "response"; question: string }).question} + + + Type your question and press enter... + + + + + + + Thinking... + + + + + + + + enter/esc to close + + + + + + ) +} From bb80a5fcaa67e2c39cae7d7ae1c04a005ebfef69 Mon Sep 17 00:00:00 2001 From: Kjell Knapen Date: Thu, 12 Mar 2026 14:25:35 +0100 Subject: [PATCH 4/6] Add btw flag to types and add custom loop so it can run concurrently --- packages/opencode/src/session/message-v2.ts | 14 +++ packages/opencode/src/session/prompt.ts | 113 +++++++++++++++----- packages/sdk/js/src/v2/gen/sdk.gen.ts | 4 + packages/sdk/js/src/v2/gen/types.gen.ts | 4 + 4 files changed, 111 insertions(+), 24 deletions(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 03ccb44c1ad..591100886e6 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -369,6 +369,7 @@ export namespace MessageV2 { system: z.string().optional(), tools: z.record(z.string(), z.boolean()).optional(), variant: z.string().optional(), + btw: z.boolean().optional(), }).meta({ ref: "UserMessage", }) @@ -438,6 +439,7 @@ export namespace MessageV2 { structured: z.any().optional(), variant: z.string().optional(), finish: z.string().optional(), + btw: z.boolean().optional(), }).meta({ ref: "AssistantMessage", }) @@ -555,8 +557,20 @@ export namespace MessageV2 { return { type: "json", value: output as never } } + // Pre-compute which btw user messages already have a response, so we can + // skip completed btw exchanges while still including any pending btw question + const answeredBtwUserIDs = new Set() + for (const msg of input) { + if (msg.info.role === "assistant" && msg.info.btw) { + answeredBtwUserIDs.add((msg.info as Assistant).parentID) + } + } + for (const msg of input) { if (msg.parts.length === 0) continue + // Skip completed btw pairs; pending btw question passes through so the model can answer it + if (msg.info.btw && msg.info.role === "assistant") continue + if (msg.info.btw && msg.info.role === "user" && answeredBtwUserIDs.has(msg.info.id)) continue if (msg.info.role === "user") { const userMessage: UIMessage = { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 939c50a3d92..140930d0728 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -85,7 +85,14 @@ export namespace SessionPrompt { }, ) - export function assertNotBusy(sessionID: SessionID) { + const btwState = Instance.state( + () => ({} as Record), + async (current) => { + for (const ctrl of Object.values(current)) ctrl.abort() + }, + ) + + export function assertNotBusy(sessionID: string) { const match = state()[sessionID] if (match) throw new Session.BusyError(sessionID) } @@ -101,6 +108,7 @@ export namespace SessionPrompt { .optional(), agent: z.string().optional(), noReply: z.boolean().optional(), + btw: z.boolean().optional(), tools: z .record(z.string(), z.boolean()) .optional() @@ -183,6 +191,10 @@ export namespace SessionPrompt { return message } + if (input.btw) { + return btwLoop({ sessionID: input.sessionID, userMessage: message }) + } + return loop({ sessionID: input.sessionID }) }) @@ -255,7 +267,58 @@ export namespace SessionPrompt { return s[sessionID].abort.signal } - export function cancel(sessionID: SessionID) { + async function btwLoop(input: { + sessionID: string + userMessage: MessageV2.WithParts + }): Promise { + const { sessionID } = input + const controller = new AbortController() + btwState()[sessionID] = controller + try { + const allMsgs = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) + // Filter out in-progress assistant messages to avoid incomplete context + const msgs = allMsgs.filter((m) => m.info.role === "user" || !!(m.info as MessageV2.Assistant).finish) + const lastUser = [...msgs].reverse().find((m) => m.info.role === "user" && !m.info.btw) + if (!lastUser) throw new Error("No user message found") + const lastUserInfo = lastUser.info as MessageV2.User + const model = await Provider.getModel(lastUserInfo.model.providerID, lastUserInfo.model.modelID) + const agent = await Agent.get(lastUserInfo.agent) + // toModelMessages includes the pending btw question as the final user turn + const messages = MessageV2.toModelMessages(msgs, model) + const assistantMessage = (await Session.updateMessage({ + id: Identifier.ascending("message"), + parentID: input.userMessage.info.id, + role: "assistant", + sessionID, + btw: true, + mode: agent.name, + agent: agent.name, + path: { cwd: Instance.directory, root: Instance.worktree }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + modelID: model.id, + providerID: model.providerID, + time: { created: Date.now() }, + })) as MessageV2.Assistant + const processor = SessionProcessor.create({ assistantMessage, sessionID, model, abort: controller.signal }) + using _ = defer(() => InstructionPrompt.clear(processor.message.id)) + await processor.process({ + user: lastUserInfo, + agent, + abort: controller.signal, + sessionID, + system: await SystemPrompt.environment(model), + messages, + tools: {}, + model, + }) + return { info: processor.message, parts: [] } as MessageV2.WithParts + } finally { + delete btwState()[sessionID] + } + } + + export function cancel(sessionID: string) { log.info("cancel", { sessionID }) const s = state() const match = s[sessionID] @@ -305,7 +368,7 @@ export namespace SessionPrompt { let tasks: (MessageV2.CompactionPart | MessageV2.SubtaskPart)[] = [] for (let i = msgs.length - 1; i >= 0; i--) { const msg = msgs[i] - if (!lastUser && msg.info.role === "user") lastUser = msg.info as MessageV2.User + if (!lastUser && msg.info.role === "user" && !msg.info.btw) lastUser = msg.info as MessageV2.User if (!lastAssistant && msg.info.role === "assistant") lastAssistant = msg.info as MessageV2.Assistant if (!lastFinished && msg.info.role === "assistant" && msg.info.finish) lastFinished = msg.info as MessageV2.Assistant @@ -574,6 +637,7 @@ export namespace SessionPrompt { mode: agent.name, agent: agent.name, variant: lastUser.variant, + btw: lastUser.btw, path: { cwd: Instance.directory, root: Instance.worktree, @@ -672,11 +736,11 @@ export namespace SessionPrompt { ...MessageV2.toModelMessages(msgs, model), ...(isLastStep ? [ - { - role: "assistant" as const, - content: MAX_STEPS, - }, - ] + { + role: "assistant" as const, + content: MAX_STEPS, + }, + ] : []), ], tools, @@ -983,6 +1047,7 @@ export namespace SessionPrompt { system: input.system, format: input.format, variant, + btw: input.btw, } using _ = defer(() => InstructionPrompt.clear(info.id)) @@ -1157,8 +1222,8 @@ export namespace SessionPrompt { messageID: info.id, extra: { bypassCwdCheck: true, model }, messages: [], - metadata: async () => {}, - ask: async () => {}, + metadata: async () => { }, + ask: async () => { }, } const result = await t.execute(args, readCtx) pieces.push({ @@ -1216,8 +1281,8 @@ export namespace SessionPrompt { messageID: info.id, extra: { bypassCwdCheck: true }, messages: [], - metadata: async () => {}, - ask: async () => {}, + metadata: async () => { }, + ask: async () => { }, } const result = await ReadTool.init().then((t) => t.execute(args, listCtx)) return [ @@ -1844,19 +1909,19 @@ NOTE: At any point in time through this workflow you should feel free to ask the const isSubtask = (agent.mode === "subagent" && command.subtask !== false) || command.subtask === true const parts = isSubtask ? [ - { - type: "subtask" as const, - agent: agent.name, - description: command.description ?? "", - command: input.command, - model: { - providerID: taskModel.providerID, - modelID: taskModel.modelID, - }, - // TODO: how can we make task tool accept a more complex input? - prompt: templateParts.find((y) => y.type === "text")?.text ?? "", + { + type: "subtask" as const, + agent: agent.name, + description: command.description ?? "", + command: input.command, + model: { + providerID: taskModel.providerID, + modelID: taskModel.modelID, }, - ] + // TODO: how can we make task tool accept a more complex input? + prompt: templateParts.find((y) => y.type === "text")?.text ?? "", + }, + ] : [...templateParts, ...(input.parts ?? [])] const userAgent = isSubtask ? (input.agent ?? (await Agent.defaultAgent())) : agentName diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 2bb2edcd175..ecce7cfdcbd 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -1839,6 +1839,7 @@ export class Session2 extends HeyApiClient { format?: OutputFormat system?: string variant?: string + btw?: boolean parts?: Array }, options?: Options, @@ -1859,6 +1860,7 @@ export class Session2 extends HeyApiClient { { in: "body", key: "format" }, { in: "body", key: "system" }, { in: "body", key: "variant" }, + { in: "body", key: "btw" }, { in: "body", key: "parts" }, ], }, @@ -1971,6 +1973,7 @@ export class Session2 extends HeyApiClient { format?: OutputFormat system?: string variant?: string + btw?: boolean parts?: Array }, options?: Options, @@ -1991,6 +1994,7 @@ export class Session2 extends HeyApiClient { { in: "body", key: "format" }, { in: "body", key: "system" }, { in: "body", key: "variant" }, + { in: "body", key: "btw" }, { in: "body", key: "parts" }, ], }, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 30568c96df1..6f0416f96e9 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -138,6 +138,7 @@ export type UserMessage = { [key: string]: boolean } variant?: string + btw?: boolean } export type ProviderAuthError = { @@ -241,6 +242,7 @@ export type AssistantMessage = { structured?: unknown variant?: string finish?: string + btw?: boolean } export type Message = UserMessage | AssistantMessage @@ -3280,6 +3282,7 @@ export type SessionPromptData = { format?: OutputFormat system?: string variant?: string + btw?: boolean parts: Array } path: { @@ -3480,6 +3483,7 @@ export type SessionPromptAsyncData = { format?: OutputFormat system?: string variant?: string + btw?: boolean parts: Array } path: { From a329d9ac9e8b393c9721fcb719ee8b931c1ad127 Mon Sep 17 00:00:00 2001 From: Kjell Knapen Date: Thu, 12 Mar 2026 14:54:33 +0100 Subject: [PATCH 5/6] Fix code after new changes --- packages/opencode/src/session/prompt.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 140930d0728..81f76749486 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -47,6 +47,7 @@ import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" import { Truncate } from "@/tool/truncation" +import type { Brand } from "effect" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -268,7 +269,7 @@ export namespace SessionPrompt { } async function btwLoop(input: { - sessionID: string + sessionID: SessionID userMessage: MessageV2.WithParts }): Promise { const { sessionID } = input @@ -286,7 +287,7 @@ export namespace SessionPrompt { // toModelMessages includes the pending btw question as the final user turn const messages = MessageV2.toModelMessages(msgs, model) const assistantMessage = (await Session.updateMessage({ - id: Identifier.ascending("message"), + id: MessageID.ascending(), parentID: input.userMessage.info.id, role: "assistant", sessionID, @@ -318,7 +319,7 @@ export namespace SessionPrompt { } } - export function cancel(sessionID: string) { + export function cancel(sessionID: SessionID) { log.info("cancel", { sessionID }) const s = state() const match = s[sessionID] From cbe826bf7d21ed937b24326a31cf1040a1425bd2 Mon Sep 17 00:00:00 2001 From: Kjell Date: Thu, 12 Mar 2026 15:12:26 +0100 Subject: [PATCH 6/6] Remove Brand import --- packages/opencode/src/session/prompt.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 81f76749486..ab6a545cb64 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -47,7 +47,6 @@ import { LLM } from "./llm" import { iife } from "@/util/iife" import { Shell } from "@/shell/shell" import { Truncate } from "@/tool/truncation" -import type { Brand } from "effect" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false