Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/opencode/src/cli/cmd/tui/routes/session/question.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import { useSDK } from "../../context/sdk"
import { SplitBorder } from "../../component/border"
import { useTuiConfig } from "../../context/tui-config"
import { useBindings, useOpencodeModeStack } from "../../keymap"
import { useProject } from "../../context/project"

const QUESTION_MODE = "question"

export function QuestionPrompt(props: { request: QuestionRequest }) {
const sdk = useSDK()
const project = useProject()
Comment on lines 16 to +17
const { theme } = useTheme()
const renderer = useRenderer()
const tuiConfig = useTuiConfig()
Expand Down Expand Up @@ -50,12 +52,14 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
void sdk.client.question.reply({
requestID: props.request.id,
answers,
workspace: project.workspace.current(),
})
Comment on lines 52 to 56
}

function reject() {
void sdk.client.question.reject({
requestID: props.request.id,
workspace: project.workspace.current(),
})
Comment on lines 60 to 63
}

Expand All @@ -72,6 +76,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
void sdk.client.question.reply({
requestID: props.request.id,
answers: [[answer]],
workspace: project.workspace.current(),
})
Comment on lines 76 to 80
return
}
Expand Down
275 changes: 275 additions & 0 deletions packages/opencode/test/cli/tui/question-prompt-routing.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
/** @jsxImportSource @opentui/solid */
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { testRender, useRenderer } from "@opentui/solid"
import { expect, test } from "bun:test"
import { Global } from "@opencode-ai/core/global"
import { onCleanup, onMount } from "solid-js"
import { ProjectProvider, useProject } from "../../../src/cli/cmd/tui/context/project"
import { SDKProvider } from "../../../src/cli/cmd/tui/context/sdk"
import { KVProvider } from "../../../src/cli/cmd/tui/context/kv"
import { ThemeProvider } from "../../../src/cli/cmd/tui/context/theme"
import { TuiConfigProvider } from "../../../src/cli/cmd/tui/context/tui-config"
import {
OpencodeKeymapProvider,
registerOpencodeKeymap,
} from "../../../src/cli/cmd/tui/keymap"
import { QuestionPrompt } from "../../../src/cli/cmd/tui/routes/session/question"
import { tmpdir } from "../../fixture/fixture"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createFetch, directory, json } from "../../fixture/tui-sdk"
import type { QuestionRequest } from "@opencode-ai/sdk/v2"

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)
}
}

const request: QuestionRequest = {
id: "que_test",
sessionID: "ses_test",
questions: [
{
header: "Routing",
question: "Pick one",
options: [
{
label: "A",
description: "First option",
},
],
custom: false,
},
],
}

const multipleRequest: QuestionRequest = {
id: "que_test",
sessionID: "ses_test",
questions: [
{
header: "Routing",
question: "Pick many",
options: [
{
label: "A",
description: "First option",
},
{
label: "B",
description: "Second option",
},
],
multiple: true,
custom: false,
},
],
}

test("question prompt replies through the active workspace", async () => {
const previous = Global.Path.state
await using tmp = await tmpdir()
Global.Path.state = tmp.path
await Bun.write(`${tmp.path}/kv.json`, "{}")

const replies: URL[] = []
const calls = createFetch((url) => {
if (url.pathname === "/question/que_test/reply") {
replies.push(url)
return json(true)
}
})
Comment on lines +78 to +83

let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})

function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const config = createTuiResolvedConfig()
const off = registerOpencodeKeymap(keymap, renderer, config)
onCleanup(off)

return (
<OpencodeKeymapProvider keymap={keymap}>
<TuiConfigProvider config={config}>
<KVProvider>
<ThemeProvider mode="dark">
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch}>
<ProjectProvider>
<Probe onReady={ready} />
<QuestionPrompt request={request} />
</ProjectProvider>
</SDKProvider>
</ThemeProvider>
</KVProvider>
</TuiConfigProvider>
</OpencodeKeymapProvider>
)
}

function Probe(props: { onReady: () => void }) {
const project = useProject()
onMount(async () => {
await project.sync()
project.workspace.set("ws_question")
props.onReady()
})
return <box />
}

const app = await testRender(() => <Harness />, { kittyKeyboard: true })
try {
await mounted

app.mockInput.pressEnter()
await wait(() => replies.length === 1)

expect(replies[0]?.searchParams.get("workspace")).toBe("ws_question")
} finally {
app.renderer.destroy()
Global.Path.state = previous
}
})

test("multiple question prompt replies through the active workspace", async () => {
const previous = Global.Path.state
await using tmp = await tmpdir()
Global.Path.state = tmp.path
await Bun.write(`${tmp.path}/kv.json`, "{}")

const replies: URL[] = []
const calls = createFetch((url) => {
if (url.pathname === "/question/que_test/reply") {
replies.push(url)
return json(true)
}
})

let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})

function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const config = createTuiResolvedConfig()
const off = registerOpencodeKeymap(keymap, renderer, config)
onCleanup(off)

return (
<OpencodeKeymapProvider keymap={keymap}>
<TuiConfigProvider config={config}>
<KVProvider>
<ThemeProvider mode="dark">
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch}>
<ProjectProvider>
<Probe onReady={ready} />
<QuestionPrompt request={multipleRequest} />
</ProjectProvider>
</SDKProvider>
</ThemeProvider>
</KVProvider>
</TuiConfigProvider>
</OpencodeKeymapProvider>
)
}

function Probe(props: { onReady: () => void }) {
const project = useProject()
onMount(async () => {
await project.sync()
project.workspace.set("ws_question")
props.onReady()
})
return <box />
}

const app = await testRender(() => <Harness />, { kittyKeyboard: true })
try {
await mounted

app.mockInput.pressEnter()
app.mockInput.pressKey("l")
app.mockInput.pressEnter()
await wait(() => replies.length === 1)

expect(replies[0]?.searchParams.get("workspace")).toBe("ws_question")
} finally {
app.renderer.destroy()
Global.Path.state = previous
}
})

test("question prompt rejects through the active workspace", async () => {
const previous = Global.Path.state
await using tmp = await tmpdir()
Global.Path.state = tmp.path
await Bun.write(`${tmp.path}/kv.json`, "{}")

const rejects: URL[] = []
const calls = createFetch((url) => {
if (url.pathname === "/question/que_test/reject") {
rejects.push(url)
return json(true)
}
})

let ready!: () => void
const mounted = new Promise<void>((resolve) => {
ready = resolve
})

function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const config = createTuiResolvedConfig()
const off = registerOpencodeKeymap(keymap, renderer, config)
onCleanup(off)

return (
<OpencodeKeymapProvider keymap={keymap}>
<TuiConfigProvider config={config}>
<KVProvider>
<ThemeProvider mode="dark">
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch}>
<ProjectProvider>
<Probe onReady={ready} />
<QuestionPrompt request={request} />
</ProjectProvider>
</SDKProvider>
</ThemeProvider>
</KVProvider>
</TuiConfigProvider>
</OpencodeKeymapProvider>
)
}

function Probe(props: { onReady: () => void }) {
const project = useProject()
onMount(async () => {
await project.sync()
project.workspace.set("ws_question")
props.onReady()
})
return <box />
}

const app = await testRender(() => <Harness />, { kittyKeyboard: true })
try {
await mounted

app.mockInput.pressEscape()
await wait(() => rejects.length === 1)

expect(rejects[0]?.searchParams.get("workspace")).toBe("ws_question")
} finally {
app.renderer.destroy()
Global.Path.state = previous
}
})
Loading