Compare commits

...

2 Commits

Author SHA1 Message Date
opencode-agent[bot] e812ca1626 test(tui): cover question prompt keybindings 2026-05-31 15:31:52 +00:00
opencode-agent[bot] 648ad90a41 fix(tui): reset question prompt edit state 2026-05-31 15:04:03 +00:00
2 changed files with 232 additions and 3 deletions
@@ -1,5 +1,5 @@
import { createStore } from "solid-js/store"
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { createEffect, 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"
@@ -45,6 +45,21 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
return store.answers[store.tab]?.includes(value) ?? false
})
let requestID = props.request.id
createEffect(() => {
const next = props.request.id
if (next === requestID) return
requestID = next
textarea = undefined
setStore({
tab: 0,
answers: [],
custom: [],
selected: 0,
editing: false,
})
})
function submit() {
const answers = questions().map((_, i) => store.answers[i] ?? [])
void sdk.client.question.reply({
@@ -95,8 +110,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
}
function selectTab(index: number) {
setStore("tab", index)
setStore("selected", 0)
textarea = undefined
setStore({ tab: index, selected: 0, editing: false })
}
function selectOption() {
@@ -332,6 +347,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
}}
</For>
<box
id="tui-question-tab-confirm"
paddingLeft={1}
paddingRight={1}
backgroundColor={
@@ -0,0 +1,213 @@
/** @jsxImportSource @opentui/solid */
import { TextareaRenderable } from "@opentui/core"
import { Global } from "@opencode-ai/core/global"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { testRender, useRenderer } from "@opentui/solid"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { expect, test } from "bun:test"
import { mkdir } from "node:fs/promises"
import path from "node:path"
import { createEffect, createSignal, onCleanup, type ParentProps, type Setter } from "solid-js"
import { KVProvider, useKV } from "@/cli/cmd/tui/context/kv"
import { SDKProvider } from "@/cli/cmd/tui/context/sdk"
import { ThemeProvider } from "@/cli/cmd/tui/context/theme"
import { TuiConfigProvider } from "@/cli/cmd/tui/context/tui-config"
import { QuestionPrompt } from "@/cli/cmd/tui/routes/session/question"
import { OpencodeKeymapProvider, registerOpencodeKeymap } from "@/cli/cmd/tui/keymap"
import { tmpdir } from "../../fixture/fixture"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { directory, eventSource, json } from "../../fixture/tui-sdk"
type QuestionReply = {
requestID: string
answers: QuestionAnswer[]
}
async function mountQuestion(input: { root: string; request: QuestionRequest }) {
const previous = {
config: Global.Path.config,
state: Global.Path.state,
}
Global.Path.config = path.join(input.root, "config")
Global.Path.state = path.join(input.root, "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: QuestionReply[] = []
let setRequest!: Setter<QuestionRequest>
let resolveReady!: () => void
const ready = new Promise<void>((resolve) => {
resolveReady = resolve
})
const fetch = (async (requestInput: RequestInfo | URL, init?: RequestInit) => {
const request = requestInput instanceof Request ? requestInput : new Request(requestInput, init)
const url = new URL(request.url)
const match = url.pathname.match(/^\/question\/([^/]+)\/reply$/)
if (match) {
const body = (await request.json()) as { answers: QuestionAnswer[] }
replies.push({ requestID: match[1]!, answers: body.answers })
return json({})
}
if (/^\/question\/[^/]+\/reject$/.test(url.pathname)) {
return json({})
}
throw new Error(`unexpected request: ${url.pathname}`)
}) as typeof globalThis.fetch
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const config = createTuiResolvedConfig()
const offKeymap = registerOpencodeKeymap(keymap, renderer, config)
const [request, set] = createSignal(input.request)
setRequest = set
onCleanup(offKeymap)
return (
<OpencodeKeymapProvider keymap={keymap}>
<TuiConfigProvider config={config}>
<KVProvider>
<Ready onReady={resolveReady}>
<ThemeProvider mode="dark">
<SDKProvider url="http://test" directory={directory} events={eventSource()} fetch={fetch}>
<QuestionPrompt request={request()} />
</SDKProvider>
</ThemeProvider>
</Ready>
</KVProvider>
</TuiConfigProvider>
</OpencodeKeymapProvider>
)
}
const app = await testRender(
() => (
<box width={100} height={20}>
<Harness />
</box>
),
{ width: 100, height: 20, kittyKeyboard: true },
)
await ready
return {
app,
replies,
setRequest(request: QuestionRequest) {
setRequest(request)
},
cleanup() {
app.renderer.destroy()
Global.Path.config = previous.config
Global.Path.state = previous.state
},
}
}
function Ready(props: ParentProps<{ onReady: () => void }>) {
const kv = useKV()
createEffect(() => {
if (kv.ready) props.onReady()
})
return <>{props.children}</>
}
test("question prompt answers a new request after a stale custom edit", async () => {
await using tmp = await tmpdir()
const prompt = await mountQuestion({
root: tmp.path,
request: {
id: "question-1",
sessionID: "session-1",
questions: [
{
header: "First",
question: "First question?",
options: [{ label: "Preset", description: "Use the preset answer." }],
custom: true,
},
],
},
})
try {
await prompt.app.renderOnce()
prompt.app.mockInput.pressKey("2")
await prompt.app.renderOnce()
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
prompt.setRequest({
id: "question-2",
sessionID: "session-1",
questions: [
{
header: "Second",
question: "Second question?",
options: [{ label: "Next", description: "Use the next answer." }],
custom: false,
},
],
})
await prompt.app.renderOnce()
prompt.app.mockInput.pressKey("1")
await prompt.app.renderOnce()
await prompt.app.waitFor(() => prompt.replies.length === 1)
expect(prompt.replies).toEqual([{ requestID: "question-2", answers: [["Next"]] }])
} finally {
prompt.cleanup()
}
})
test("question prompt confirm keybinding works after leaving a custom edit by mouse", async () => {
await using tmp = await tmpdir()
const prompt = await mountQuestion({
root: tmp.path,
request: {
id: "question-1",
sessionID: "session-1",
questions: [
{
header: "First",
question: "First question?",
options: [{ label: "Preset", description: "Use the preset answer." }],
custom: true,
},
{
header: "Second",
question: "Second question?",
options: [{ label: "Next", description: "Use the next answer." }],
custom: false,
},
],
},
})
try {
await prompt.app.renderOnce()
prompt.app.mockInput.pressKey("2")
await prompt.app.renderOnce()
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor instanceof TextareaRenderable)
const confirm = prompt.app.renderer.root.findDescendantById("tui-question-tab-confirm")
if (!confirm) throw new Error("expected confirm tab")
await prompt.app.mockMouse.click(confirm.screenX + 1, confirm.screenY)
await prompt.app.renderOnce()
prompt.app.mockInput.pressEnter()
await prompt.app.renderOnce()
await prompt.app.waitFor(() => prompt.replies.length === 1)
expect(prompt.replies).toEqual([{ requestID: "question-1", answers: [[], []] }])
} finally {
prompt.cleanup()
}
})