From dd6a77c330cfb7442ba8c86ff556ff9078a099e8 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 22 May 2026 18:02:43 +0530 Subject: [PATCH 1/2] fix(tui): restore question prompt key handling --- .../cli/cmd/tui/routes/session/question.tsx | 16 +- .../test/cli/tui/question-prompt.test.tsx | 168 ++++++++++++++++++ 2 files changed, 180 insertions(+), 4 deletions(-) create mode 100644 packages/opencode/test/cli/tui/question-prompt.test.tsx diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index c484c62f7548..4d7b520430a3 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -1,5 +1,5 @@ import { createStore } from "solid-js/store" -import { createMemo, createSignal, For, Show } from "solid-js" +import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" import { useRenderer } from "@opentui/solid" import type { TextareaRenderable } from "@opentui/core" import { selectedForeground, tint, useTheme } from "../../context/theme" @@ -7,13 +7,16 @@ import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../component/border" import { useTuiConfig } from "../../context/tui-config" -import { OPENCODE_BASE_MODE, useBindings } from "../../keymap" +import { useBindings, useOpencodeModeStack } from "../../keymap" + +const QUESTION_MODE = "question" export function QuestionPrompt(props: { request: QuestionRequest }) { const sdk = useSDK() const { theme } = useTheme() const renderer = useRenderer() const tuiConfig = useTuiConfig() + const modeStack = useOpencodeModeStack() const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) @@ -119,8 +122,13 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { pick(opt.label) } + onMount(() => { + const popMode = modeStack.push(QUESTION_MODE) + onCleanup(popMode) + }) + useBindings(() => ({ - mode: OPENCODE_BASE_MODE, + mode: QUESTION_MODE, enabled: store.editing && !confirm(), commands: [ { @@ -201,7 +209,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { const max = Math.min(total, 9) return { - mode: OPENCODE_BASE_MODE, + mode: QUESTION_MODE, enabled: !store.editing, commands: [ { diff --git a/packages/opencode/test/cli/tui/question-prompt.test.tsx b/packages/opencode/test/cli/tui/question-prompt.test.tsx new file mode 100644 index 000000000000..4d35da977c89 --- /dev/null +++ b/packages/opencode/test/cli/tui/question-prompt.test.tsx @@ -0,0 +1,168 @@ +/** @jsxImportSource @opentui/solid */ +import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" +import { testRender, useRenderer } from "@opentui/solid" +import { expect, test } from "bun:test" +import { mkdir } from "node:fs/promises" +import path from "node:path" +import { onCleanup } from "solid-js" +import { tmpdir } from "../../fixture/fixture" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" +import { KVProvider } from "../../../src/cli/cmd/tui/context/kv" +import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk" +import { ThemeProvider } from "../../../src/cli/cmd/tui/context/theme" +import { TuiConfigProvider } from "../../../src/cli/cmd/tui/context/tui-config" +import { getOpencodeModeStack, OpencodeKeymapProvider, registerOpencodeKeymap } from "../../../src/cli/cmd/tui/keymap" +import { QuestionPrompt } from "../../../src/cli/cmd/tui/routes/session/question" + +async function wait(fn: () => boolean, timeout = 2000) { + const start = Date.now() + while (!fn()) { + if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") + await Bun.sleep(10) + } +} + +function json(data: unknown) { + return new Response(JSON.stringify(data), { + headers: { "content-type": "application/json" }, + }) +} + +async function mountQuestionPrompt() { + const tmp = await tmpdir() + const { Global } = await import("@opencode-ai/core/global") + const previous = { + config: Global.Path.config, + state: Global.Path.state, + } + Global.Path.config = path.join(tmp.path, "config") + Global.Path.state = path.join(tmp.path, "state") + await mkdir(Global.Path.config, { recursive: true }) + await mkdir(Global.Path.state, { recursive: true }) + await Bun.write(path.join(Global.Path.state, "kv.json"), "{}") + + const replies: unknown[] = [] + const rejects: string[] = [] + let currentMode!: () => string + let dispatchCommand!: (command: string) => void + + function Harness() { + const renderer = useRenderer() + const keymap = createDefaultOpenTuiKeymap(renderer) + const config = createTuiResolvedConfig() + const offKeymap = registerOpencodeKeymap(keymap, renderer, config) + const modeStack = getOpencodeModeStack(keymap) + const popAutocomplete = modeStack.push("autocomplete") + currentMode = modeStack.current + dispatchCommand = (command) => keymap.dispatchCommand(command) + + onCleanup(() => { + popAutocomplete() + offKeymap() + }) + + return ( + + + + + { + const request = input instanceof Request ? input : undefined + const url = new URL(request?.url ?? String(input)) + if (url.pathname === "/question/que_test/reply") { + replies.push(await request?.json()) + return json(true) + } + if (url.pathname === "/question/que_test/reject") { + rejects.push(url.pathname) + return json(true) + } + throw new Error(`unexpected request: ${url.pathname}`) + }) as typeof globalThis.fetch + } + events={{ subscribe: async () => () => {} }} + > + + + + + + + ) + } + + const app = await testRender(() => , { kittyKeyboard: true }) + return { + app, + replies, + rejects, + currentMode, + dispatchCommand, + async cleanup() { + app.renderer.destroy() + Global.Path.config = previous.config + Global.Path.state = previous.state + await tmp[Symbol.asyncDispose]() + }, + } +} + +test("question prompt enter works when another mode was already active", async () => { + const prompt = await mountQuestionPrompt() + try { + await wait(() => prompt.currentMode() === "question") + prompt.app.mockInput.pressKey("1") + prompt.app.mockInput.pressArrow("right") + prompt.app.mockInput.pressEnter() + + await wait(() => prompt.replies.length === 1) + expect(prompt.replies).toEqual([{ answers: [["Alpha"]] }]) + } finally { + await prompt.cleanup() + } +}) + +test("question prompt escape works when another mode was already active", async () => { + const prompt = await mountQuestionPrompt() + try { + await wait(() => prompt.currentMode() === "question") + prompt.app.mockInput.pressEscape() + + await wait(() => prompt.rejects.length === 1) + expect(prompt.rejects).toEqual(["/question/que_test/reject"]) + } finally { + await prompt.cleanup() + } +}) + +test("question prompt app.exit command works when another mode was already active", async () => { + const prompt = await mountQuestionPrompt() + try { + await wait(() => prompt.currentMode() === "question") + prompt.dispatchCommand("app.exit") + + await wait(() => prompt.rejects.length === 1) + expect(prompt.rejects).toEqual(["/question/que_test/reject"]) + } finally { + await prompt.cleanup() + } +}) From 2b2b9b53f1c13f8b483e095259ad57eca284a0cc Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 22 May 2026 18:05:15 +0530 Subject: [PATCH 2/2] test(tui): remove question prompt regression tests --- .../test/cli/tui/question-prompt.test.tsx | 168 ------------------ 1 file changed, 168 deletions(-) delete mode 100644 packages/opencode/test/cli/tui/question-prompt.test.tsx diff --git a/packages/opencode/test/cli/tui/question-prompt.test.tsx b/packages/opencode/test/cli/tui/question-prompt.test.tsx deleted file mode 100644 index 4d35da977c89..000000000000 --- a/packages/opencode/test/cli/tui/question-prompt.test.tsx +++ /dev/null @@ -1,168 +0,0 @@ -/** @jsxImportSource @opentui/solid */ -import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui" -import { testRender, useRenderer } from "@opentui/solid" -import { expect, test } from "bun:test" -import { mkdir } from "node:fs/promises" -import path from "node:path" -import { onCleanup } from "solid-js" -import { tmpdir } from "../../fixture/fixture" -import { createTuiResolvedConfig } from "../../fixture/tui-runtime" -import { KVProvider } from "../../../src/cli/cmd/tui/context/kv" -import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk" -import { ThemeProvider } from "../../../src/cli/cmd/tui/context/theme" -import { TuiConfigProvider } from "../../../src/cli/cmd/tui/context/tui-config" -import { getOpencodeModeStack, OpencodeKeymapProvider, registerOpencodeKeymap } from "../../../src/cli/cmd/tui/keymap" -import { QuestionPrompt } from "../../../src/cli/cmd/tui/routes/session/question" - -async function wait(fn: () => boolean, timeout = 2000) { - const start = Date.now() - while (!fn()) { - if (Date.now() - start > timeout) throw new Error("timed out waiting for condition") - await Bun.sleep(10) - } -} - -function json(data: unknown) { - return new Response(JSON.stringify(data), { - headers: { "content-type": "application/json" }, - }) -} - -async function mountQuestionPrompt() { - const tmp = await tmpdir() - const { Global } = await import("@opencode-ai/core/global") - const previous = { - config: Global.Path.config, - state: Global.Path.state, - } - Global.Path.config = path.join(tmp.path, "config") - Global.Path.state = path.join(tmp.path, "state") - await mkdir(Global.Path.config, { recursive: true }) - await mkdir(Global.Path.state, { recursive: true }) - await Bun.write(path.join(Global.Path.state, "kv.json"), "{}") - - const replies: unknown[] = [] - const rejects: string[] = [] - let currentMode!: () => string - let dispatchCommand!: (command: string) => void - - function Harness() { - const renderer = useRenderer() - const keymap = createDefaultOpenTuiKeymap(renderer) - const config = createTuiResolvedConfig() - const offKeymap = registerOpencodeKeymap(keymap, renderer, config) - const modeStack = getOpencodeModeStack(keymap) - const popAutocomplete = modeStack.push("autocomplete") - currentMode = modeStack.current - dispatchCommand = (command) => keymap.dispatchCommand(command) - - onCleanup(() => { - popAutocomplete() - offKeymap() - }) - - return ( - - - - - { - const request = input instanceof Request ? input : undefined - const url = new URL(request?.url ?? String(input)) - if (url.pathname === "/question/que_test/reply") { - replies.push(await request?.json()) - return json(true) - } - if (url.pathname === "/question/que_test/reject") { - rejects.push(url.pathname) - return json(true) - } - throw new Error(`unexpected request: ${url.pathname}`) - }) as typeof globalThis.fetch - } - events={{ subscribe: async () => () => {} }} - > - - - - - - - ) - } - - const app = await testRender(() => , { kittyKeyboard: true }) - return { - app, - replies, - rejects, - currentMode, - dispatchCommand, - async cleanup() { - app.renderer.destroy() - Global.Path.config = previous.config - Global.Path.state = previous.state - await tmp[Symbol.asyncDispose]() - }, - } -} - -test("question prompt enter works when another mode was already active", async () => { - const prompt = await mountQuestionPrompt() - try { - await wait(() => prompt.currentMode() === "question") - prompt.app.mockInput.pressKey("1") - prompt.app.mockInput.pressArrow("right") - prompt.app.mockInput.pressEnter() - - await wait(() => prompt.replies.length === 1) - expect(prompt.replies).toEqual([{ answers: [["Alpha"]] }]) - } finally { - await prompt.cleanup() - } -}) - -test("question prompt escape works when another mode was already active", async () => { - const prompt = await mountQuestionPrompt() - try { - await wait(() => prompt.currentMode() === "question") - prompt.app.mockInput.pressEscape() - - await wait(() => prompt.rejects.length === 1) - expect(prompt.rejects).toEqual(["/question/que_test/reject"]) - } finally { - await prompt.cleanup() - } -}) - -test("question prompt app.exit command works when another mode was already active", async () => { - const prompt = await mountQuestionPrompt() - try { - await wait(() => prompt.currentMode() === "question") - prompt.dispatchCommand("app.exit") - - await wait(() => prompt.rejects.length === 1) - expect(prompt.rejects).toEqual(["/question/que_test/reject"]) - } finally { - await prompt.cleanup() - } -})