diff --git a/packages/opencode/src/askquestion/index.ts b/packages/opencode/src/askquestion/index.ts new file mode 100644 index 00000000000..4ec61d6ecb9 --- /dev/null +++ b/packages/opencode/src/askquestion/index.ts @@ -0,0 +1,161 @@ +import { BusEvent } from "@/bus/bus-event" +import z from "zod" + +export namespace AskQuestion { + /** + * Schema for a single question option + */ + export const OptionSchema = z.object({ + value: z.string().describe("Short identifier for the option"), + label: z.string().describe("Display label for the option"), + description: z.string().optional().describe("Additional context for the option"), + }) + export type Option = z.infer + + /** + * Schema for a single question in the wizard + */ + export const QuestionSchema = z.object({ + id: z.string().describe("Unique identifier for the question"), + label: z.string().describe("Short tab label, e.g. 'UI Framework'"), + question: z.string().describe("The full question to ask the user"), + options: z.array(OptionSchema).min(2).max(8).describe("2-8 suggested answer options"), + multiSelect: z.boolean().optional().describe("Allow selecting multiple options"), + }) + export type Question = z.infer + + /** + * Schema for a single answer from the user + */ + export const AnswerSchema = z.object({ + questionId: z.string().describe("ID of the question being answered"), + values: z.array(z.string()).describe("Selected option value(s)"), + customText: z.string().optional().describe("Custom text if user typed their own response"), + }) + export type Answer = z.infer + + /** + * Bus events for askquestion flow + */ + export const Event = { + /** + * Published by the askquestion tool when it needs user input + */ + Requested: BusEvent.define( + "askquestion.requested", + z.object({ + sessionID: z.string(), + messageID: z.string(), + callID: z.string(), + questions: z.array(QuestionSchema), + }), + ), + + /** + * Published by the TUI when user submits answers + */ + Answered: BusEvent.define( + "askquestion.answered", + z.object({ + sessionID: z.string(), + callID: z.string(), + answers: z.array(AnswerSchema), + }), + ), + + /** + * Published when user cancels the question wizard + */ + Cancelled: BusEvent.define( + "askquestion.cancelled", + z.object({ + sessionID: z.string(), + callID: z.string(), + }), + ), + } + + /** + * Pending askquestion requests waiting for user response + */ + interface PendingRequest { + sessionID: string + messageID: string + callID: string + questions: Question[] + resolve: (answers: Answer[]) => void + reject: (error: Error) => void + } + + // Global map of pending requests by callID + const pendingRequests = new Map() + + /** + * Register a pending askquestion request + */ + export function register( + callID: string, + sessionID: string, + messageID: string, + questions: Question[], + ): Promise { + return new Promise((resolve, reject) => { + pendingRequests.set(callID, { + sessionID, + messageID, + callID, + questions, + resolve, + reject, + }) + }) + } + + /** + * Get a pending request + */ + export function get(callID: string): PendingRequest | undefined { + return pendingRequests.get(callID) + } + + /** + * Get all pending requests for a session + */ + export function getForSession(sessionID: string): PendingRequest[] { + return Array.from(pendingRequests.values()).filter((r) => r.sessionID === sessionID) + } + + /** + * Respond to a pending askquestion request + */ + export function respond(callID: string, answers: Answer[]): boolean { + const pending = pendingRequests.get(callID) + if (!pending) return false + pending.resolve(answers) + pendingRequests.delete(callID) + return true + } + + /** + * Cancel a pending askquestion request + */ + export function cancel(callID: string): boolean { + const pending = pendingRequests.get(callID) + if (!pending) return false + pending.reject(new Error("User cancelled the question wizard")) + pendingRequests.delete(callID) + return true + } + + /** + * Clean up pending requests for a session (e.g., on abort) + */ + export function cleanup(sessionID: string): void { + for (const [callID, request] of pendingRequests) { + if (request.sessionID === sessionID) { + request.reject(new Error("Session aborted")) + pendingRequests.delete(callID) + } + } + } +} diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index 3ea7c90b700..156b9e81cdb 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -7,6 +7,7 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ name: "SDK", init: (props: { url: string }) => { const abort = new AbortController() + process.env.OPENCODE_CLIENT_TYPE = "tui" const sdk = createOpencodeClient({ baseUrl: props.url, signal: abort.signal, 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 826fa2acf8e..0a357c79502 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -68,6 +68,8 @@ import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" import { Filesystem } from "@/util/filesystem" import { DialogSubagent } from "./dialog-subagent.tsx" +import { DialogAskQuestion } from "../../ui/dialog-askquestion.tsx" +import type { AskQuestion } from "@/askquestion" addDefaultParsers(parsers.parsers) @@ -200,6 +202,37 @@ export function Session() { } }) + // Detect pending askquestion tools from synced message parts + // Access via session.messages -> parts for proper Solid.js reactivity + const pendingAskQuestionFromSync = createMemo(() => { + const sessionMessages = sync.data.message[route.sessionID] ?? [] + + // Search backwards for the most recent pending question + for (const message of [...sessionMessages].reverse()) { + const parts = sync.data.part[message.id] ?? [] + + for (const part of [...parts].reverse()) { + if (part.type !== "tool") continue + const toolPart = part as ToolPart + + if (toolPart.tool !== "askquestion") continue + if (toolPart.state.status !== "running") continue + + const metadata = toolPart.state.metadata as { status?: string; questions?: AskQuestion.Question[] } | undefined + + if (metadata?.status !== "waiting") continue + + return { + callID: toolPart.callID, + messageId: toolPart.messageID, + questions: (metadata.questions ?? []) as AskQuestion.Question[], + } + } + } + + return null + }) + let scroll: ScrollBoxRenderable let prompt: PromptRef const keybind = useKeybind() @@ -1082,17 +1115,59 @@ export function Session() { - { - prompt = r - promptRef.set(r) - }} - disabled={permissions().length > 0} - onSubmit={() => { - toBottom() - }} - sessionID={route.sessionID} - /> + + + {(pending) => ( + { + await fetch(`${sdk.url}/askquestion/respond`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + callID: pending().callID, + sessionID: route.sessionID, + answers, + }), + }).catch(() => { + toast.show({ + message: "Failed to submit answers", + variant: "error", + }) + }) + }} + onCancel={async () => { + await fetch(`${sdk.url}/askquestion/cancel`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + callID: pending().callID, + sessionID: route.sessionID, + }), + }).catch(() => { + toast.show({ + message: "Failed to cancel", + variant: "error", + }) + }) + }} + /> + )} + + + { + prompt = r + promptRef.set(r) + }} + disabled={permissions().length > 0} + onSubmit={() => { + toBottom() + }} + sessionID={route.sessionID} + /> + +