From c9433128f261f077c709a96f63928ac83a2e287a Mon Sep 17 00:00:00 2001 From: dbpolito Date: Sun, 14 Dec 2025 11:34:58 -0300 Subject: [PATCH 01/17] Ask Tool --- .../opencode/src/cli/cmd/tui/context/sdk.tsx | 2 +- .../opencode/src/cli/cmd/tui/context/sync.tsx | 45 +- .../src/cli/cmd/tui/routes/session/footer.tsx | 10 + .../src/cli/cmd/tui/routes/session/index.tsx | 94 +++ .../src/cli/cmd/tui/ui/dialog-question.tsx | 689 ++++++++++++++++++ .../src/cli/cmd/tui/ui/dialog-select.tsx | 85 ++- .../opencode/src/cli/cmd/tui/ui/dialog.tsx | 17 + packages/opencode/src/id/id.ts | 1 + packages/opencode/src/question/index.ts | 175 +++++ packages/opencode/src/server/server.ts | 71 ++ packages/opencode/src/session/prompt.ts | 2 + .../opencode/src/session/prompt/anthropic.txt | 10 + .../opencode/src/session/prompt/beast.txt | 3 + .../opencode/src/session/prompt/gemini.txt | 1 + packages/opencode/src/session/prompt/qwen.txt | 3 + packages/opencode/src/tool/ask.ts | 59 ++ packages/opencode/src/tool/ask.txt | 21 + packages/opencode/src/tool/registry.ts | 2 + specs/ask-tool.md | 651 +++++++++++++++++ 19 files changed, 1916 insertions(+), 25 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/ui/dialog-question.tsx create mode 100644 packages/opencode/src/question/index.ts create mode 100644 packages/opencode/src/tool/ask.ts create mode 100644 packages/opencode/src/tool/ask.txt create mode 100644 specs/ask-tool.md diff --git a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx index b283f672f0f..08bc3385b0c 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sdk.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sdk.tsx @@ -70,6 +70,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ abort.abort() }) - return { client: sdk, event: emitter } + return { client: sdk, event: emitter, baseUrl: props.url } }, }) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index f74f787db8c..6b835f85c95 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -16,6 +16,9 @@ import type { ProviderAuthMethod, VcsInfo, } from "@opencode-ai/sdk/v2" +import type { Question as QuestionNamespace } from "@/question" + +type Question = QuestionNamespace.Info import { createStore, produce, reconcile } from "solid-js/store" import { useSDK } from "@tui/context/sdk" import { Binary } from "@opencode-ai/util/binary" @@ -40,6 +43,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ permission: { [sessionID: string]: Permission[] } + question: { + [sessionID: string]: Question[] + } config: Config session: Session[] session_status: { @@ -75,6 +81,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ status: "loading", agent: [], permission: {}, + question: {}, command: [], provider: [], provider_default: {}, @@ -94,7 +101,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sdk = useSDK() sdk.event.listen((e) => { - const event = e.details + const event = e.details as any switch (event.type) { case "permission.updated": { const permissions = store.permission[event.properties.sessionID] @@ -131,6 +138,42 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ break } + case "question.updated": { + const questions = store.question[event.properties.sessionID] + if (!questions) { + setStore("question", event.properties.sessionID, [event.properties as Question]) + break + } + const match = Binary.search(questions, event.properties.id, (q) => q.id) + setStore( + "question", + event.properties.sessionID, + produce((draft) => { + if (match.found) { + draft[match.index] = event.properties as Question + return + } + draft.push(event.properties as Question) + }), + ) + break + } + + case "question.replied": { + const questions = store.question[event.properties.sessionID] + if (!questions) break + const match = Binary.search(questions, event.properties.questionID, (q) => q.id) + if (!match.found) break + setStore( + "question", + event.properties.sessionID, + produce((draft) => { + draft.splice(match.index, 1) + }), + ) + break + } + case "todo.updated": setStore("todo", event.properties.sessionID, event.properties.todos) break diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx index 69082c870ba..68e1a7a6572 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/footer.tsx @@ -17,6 +17,10 @@ export function Footer() { if (route.data.type !== "session") return [] return sync.data.permission[route.data.sessionID] ?? [] }) + const questions = createMemo(() => { + if (route.data.type !== "session") return [] + return sync.data.question[route.data.sessionID] ?? [] + }) const directory = useDirectory() const connected = useConnected() @@ -63,6 +67,12 @@ export function Footer() { {permissions().length > 1 ? "s" : ""} + 0}> + + {questions().length} Question + {questions().length > 1 ? "s" : ""} + + {lsp().length} LSP 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 1c1e4b65ec1..da31aeb88bb 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -8,6 +8,7 @@ import { on, Show, Switch, + untrack, useContext, type Component, } from "solid-js" @@ -34,6 +35,7 @@ import type { WriteTool } from "@/tool/write" import { BashTool } from "@/tool/bash" import type { GlobTool } from "@/tool/glob" import { TodoWriteTool } from "@/tool/todo" +import type { AskTool } from "@/tool/ask" import type { GrepTool } from "@/tool/grep" import type { ListTool } from "@/tool/ls" import type { EditTool } from "@/tool/edit" @@ -52,6 +54,7 @@ import type { PromptInfo } from "../../component/prompt/history" import { iife } from "@/util/iife" import { DialogConfirm } from "@tui/ui/dialog-confirm" import { DialogPrompt } from "@tui/ui/dialog-prompt" +import { DialogQuestion } from "@tui/ui/dialog-question" import { DialogTimeline } from "./dialog-timeline" import { DialogSessionRename } from "../../component/dialog-session-rename" import { Sidebar } from "./sidebar" @@ -104,6 +107,7 @@ export function Session() { const session = createMemo(() => sync.session.get(route.sessionID)!) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? []) + const questions = createMemo(() => sync.data.question[route.sessionID] ?? []) const pending = createMemo(() => { return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id @@ -185,6 +189,41 @@ export function Session() { } }) + // Auto-open question dialog when question arrives + let currentQuestionID: string | null = null + createEffect(() => { + const pendingQuestions = questions() + if (pendingQuestions.length === 0) { + currentQuestionID = null + return + } + + const question = pendingQuestions[0] + // Already showing this question + if (currentQuestionID === question.id) return + // Another dialog is open - use untrack to avoid making this reactive + if (untrack(() => dialog.stack.length > 0)) return + + currentQuestionID = question.id + DialogQuestion.show(dialog, question as any).then(({ answers, cancelled }) => { + currentQuestionID = null + const baseUrl = sdk.baseUrl + if (cancelled) { + // User cancelled - reject the question + fetch(`${baseUrl}/session/${route.sessionID}/questions/${question.id}/reject`, { + method: "POST", + }) + } else { + // User submitted answers + fetch(`${baseUrl}/session/${route.sessionID}/questions/${question.id}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ answers }), + }) + } + }) + }) + let scroll: ScrollBoxRenderable let prompt: PromptRef const keybind = useKeybind() @@ -1683,6 +1722,61 @@ ToolRegistry.register({ }, }) +ToolRegistry.register({ + name: "ask", + container: "block", + render(props) { + const { theme } = useTheme() + const questions = createMemo(() => props.input.questions ?? []) + const answers = createMemo(() => (props.metadata.answers ?? {}) as Record) + const pending = createMemo(() => !props.output) + + return ( + <> + 0}> + {pending() ? "Asking" : "Asked"} {questions().length} question{questions().length !== 1 ? "s" : ""} + + 0}> + + + {(q) => { + const answer = createMemo(() => answers()[q.id]) + return ( + + Q: {q.question} + + + A:{" "} + {typeof answer()!.value === "boolean" + ? answer()!.value + ? "Yes" + : "No" + : Array.isArray(answer()!.value) + ? answer()!.value.join(", ") + : answer()!.value} + + + + "{answer()!.comment}" + + + + + + (skipped) + + + + ) + }} + + + + + ) + }, +}) + function normalizePath(input?: string) { if (!input) return "" if (path.isAbsolute(input)) { diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-question.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-question.tsx new file mode 100644 index 00000000000..188eb5f9926 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-question.tsx @@ -0,0 +1,689 @@ +import { TextareaRenderable, TextAttributes } from "@opentui/core" +import { useTheme, selectedForeground } from "@tui/context/theme" +import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { useKeyboard } from "@opentui/solid" +import { useDialog, type DialogContext } from "@tui/ui/dialog" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import type { Question } from "@/question" + +// Types +interface Answer { + value: string | string[] | boolean | null + comment?: string +} + +type Answers = Record + +interface QuestionDialogProps { + question: Question.Info + onSubmit: (answers: Answers) => void + onCancel: () => void +} + +interface SingleQuestionProps { + item: Question.QuestionItem + currentAnswer?: Answer + onAnswer: (answer: Answer) => void + onCancel: () => void + onSubmitAll: (answer: Answer) => void +} + +// Main DialogQuestion - shows list of all questions +export function DialogQuestion(props: QuestionDialogProps) { + const dialog = useDialog() + const [answers, setAnswers] = createStore({}) + + const questions = () => props.question.questions + const answeredCount = () => + Object.keys(answers).filter((k) => answers[k]?.value !== undefined && answers[k]?.value !== null).length + + console.log("[DialogQuestion] Mounting with", questions().length, "questions") + + onMount(() => { + console.log("[DialogQuestion] onMount called") + dialog.setSize("medium") + }) + + function openQuestion(item: Question.QuestionItem) { + const Component = { + select: DialogQuestionSelect, + "multi-select": DialogQuestionMultiSelect, + confirm: DialogQuestionConfirm, + text: DialogQuestionText, + }[item.type] + + dialog.push(() => ( + { + setAnswers(item.id, answer) + dialog.pop() + }} + onCancel={() => dialog.pop()} + onSubmitAll={(answer) => { + setAnswers(item.id, answer) + submit({ ...answers, [item.id]: answer }) + }} + /> + )) + } + + function submit(finalAnswers?: Answers) { + props.onSubmit(finalAnswers ?? answers) + } + + // Convert questions to DialogSelect options + const selectOptions = createMemo[]>(() => + questions().map((item) => { + const answer = answers[item.id] + const answerPreview = formatAnswerPreview(item, answer) + const comment = answer?.comment ? ` 💬 "${truncate(answer.comment, 30)}"` : "" + + return { + title: item.question, + value: item, + description: answerPreview + comment, + onSelect: () => openQuestion(item), + } + }), + ) + + // Handle submission with ctrl+enter keybind + useKeyboard((evt) => { + if (questions().length === 1) return + if (evt.ctrl && evt.name === "return") { + submit() + evt.preventDefault() + } + }) + + return ( + option.onSelect?.(dialog)} + hideSearch={true} + /> + ) +} + +// Single Select Question Dialog +function DialogQuestionSelect(props: SingleQuestionProps) { + const dialog = useDialog() + const { theme } = useTheme() + const [comment, setComment] = createSignal(props.currentAnswer?.comment) + + // Sort options with recommended first + const sortedOptions = createMemo(() => { + const opts = props.item.options ?? [] + return [...opts].sort((a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0)) + }) + + // Find current selection value + const currentValue = createMemo(() => props.currentAnswer?.value) + + onMount(() => { + dialog.setSize("medium") + }) + + function openComment() { + dialog.push(() => ( + { + setComment(c) + dialog.pop() + }} + onCancel={() => dialog.pop()} + /> + )) + } + + function confirmSelection(option: { value: string; label: string; recommended?: boolean }) { + props.onAnswer({ value: option.value, comment: comment() }) + } + + function submitAll(option: { value: string; label: string; recommended?: boolean }) { + props.onSubmitAll({ value: option.value, comment: comment() }) + } + + // Convert options to DialogSelectOption format + const selectOptions = createMemo[]>(() => + sortedOptions().map((option) => ({ + title: option.label, + value: option, + footer: option.recommended ? "(Recommended)" : undefined, + onSelect: () => confirmSelection(option), + })), + ) + + // Show default as hint if present + const defaultHint = createMemo(() => { + if (typeof props.item.default === "string") { + const opt = props.item.options?.find((o) => o.value === props.item.default) + return opt?.label + } + return undefined + }) + + return ( + <> + + + Default: {defaultHint()} + + + o.value === currentValue())} + hideSearch={true} + keybind={[ + { + keybind: { name: "m", ctrl: false, meta: false, shift: false, super: false, leader: false }, + title: "add comment", + onTrigger: () => openComment(), + }, + { + keybind: { name: "s", ctrl: false, meta: false, shift: false, super: false, leader: false }, + title: "submit all", + onTrigger: (option) => submitAll(option.value), + }, + ]} + /> + + + 💬 "{truncate(comment()!, 40)}" + + + + ) +} + +// Multi-Select Question Dialog +function DialogQuestionMultiSelect(props: SingleQuestionProps) { + const dialog = useDialog() + const { theme } = useTheme() + const [checked, setChecked] = createStore>({}) + const [comment, setComment] = createSignal(props.currentAnswer?.comment) + + // Sort options with recommended first + const sortedOptions = createMemo(() => { + const opts = props.item.options ?? [] + return [...opts].sort((a, b) => (b.recommended ? 1 : 0) - (a.recommended ? 1 : 0)) + }) + + // Initialize checked state from current answer + createEffect(() => { + if (Array.isArray(props.currentAnswer?.value)) { + for (const v of props.currentAnswer.value) { + setChecked(v, true) + } + } + }) + + onMount(() => { + dialog.setSize("medium") + }) + + function openComment() { + dialog.push(() => ( + { + setComment(c) + dialog.pop() + }} + onCancel={() => dialog.pop()} + /> + )) + } + + function getSelectedValues(): string[] { + return sortedOptions() + .filter((o) => checked[o.value]) + .map((o) => o.value) + } + + function confirmSelection() { + props.onAnswer({ value: getSelectedValues(), comment: comment() }) + } + + function submitAll() { + props.onSubmitAll({ value: getSelectedValues(), comment: comment() }) + } + + // Convert options to DialogSelectOption format + const selectOptions = createMemo[]>(() => + sortedOptions().map((option) => ({ + title: option.label, + value: option, + footer: checked[option.value] ? "☑" : "☐", + onSelect: () => {}, // Don't close on select + })), + ) + + const selectedCount = createMemo(() => getSelectedValues().length) + + return ( + <> + + {selectedCount()} selected + + confirmSelection()} + keybind={[ + { + keybind: { name: "space", ctrl: false, meta: false, shift: false, super: false, leader: false }, + title: "toggle", + onTrigger: (option) => setChecked(option.value.value, !checked[option.value.value]), + }, + { + keybind: { name: "m", ctrl: false, meta: false, shift: false, super: false, leader: false }, + title: "add comment", + onTrigger: () => openComment(), + }, + { + keybind: { name: "s", ctrl: false, meta: false, shift: false, super: false, leader: false }, + title: "submit all", + onTrigger: () => submitAll(), + }, + ]} + /> + + + 💬 "{truncate(comment()!, 40)}" + + + + ) + + + function openComment() { + dialog.push(() => ( + { + setComment(c) + dialog.pop() + }} + onCancel={() => dialog.pop()} + /> + )) + } + + function getSelectedValues(): string[] { + return sortedOptions() + .filter((o) => checked[o.value]) + .map((o) => o.value) + } + + function confirmSelection() { + props.onAnswer({ value: getSelectedValues(), comment: comment() }) + } + + function submitAll() { + props.onSubmitAll({ value: getSelectedValues(), comment: comment() }) + } + + function CheckboxStatus(props: { value: string }) { + const isChecked = () => checked[props.value] + return {isChecked() ? "☑" : "☐"} + } + +} + +// Confirm (Yes/No) Question Dialog +function DialogQuestionConfirm(props: SingleQuestionProps) { + const dialog = useDialog() + const { theme } = useTheme() + const [selected, setSelected] = createSignal( + typeof props.currentAnswer?.value === "boolean" ? props.currentAnswer.value : null, + ) + const [comment, setComment] = createSignal(props.currentAnswer?.comment) + + onMount(() => { + dialog.setSize("medium") + }) + + function openComment() { + dialog.push(() => ( + { + setComment(c) + dialog.pop() + }} + onCancel={() => dialog.pop()} + /> + )) + } + + function confirmSelection() { + if (selected() !== null) { + props.onAnswer({ value: selected(), comment: comment() }) + } + } + + function submitAll() { + if (selected() !== null) { + props.onSubmitAll({ value: selected(), comment: comment() }) + } + } + + useKeyboard((evt) => { + if (evt.name === "up" || evt.name === "left" || (evt.ctrl && evt.name === "p")) { + setSelected(true) + evt.preventDefault() + } + if (evt.name === "down" || evt.name === "right" || (evt.ctrl && evt.name === "n")) { + setSelected(false) + evt.preventDefault() + } + if (evt.name === "return" && !evt.ctrl) { + confirmSelection() + evt.preventDefault() + } + if (evt.ctrl && evt.name === "return") { + submitAll() + evt.preventDefault() + } + if (evt.name === "c" && !evt.ctrl) { + openComment() + evt.preventDefault() + } + if (evt.name === "y") { + setSelected(true) + evt.preventDefault() + } + if (evt.name === "n") { + setSelected(false) + evt.preventDefault() + } + }) + + // Show default as hint + const defaultHint = createMemo(() => { + if (typeof props.item.default === "boolean") { + return props.item.default ? "Yes" : "No" + } + return undefined + }) + + return ( + + + + + {props.item.question} + + esc + + + Default: {defaultHint()} + + + + { + setSelected(true) + confirmSelection() + }} + > + + Yes + + + { + setSelected(false) + confirmSelection() + }} + > + + No + + + + + + + {"\uD83D\uDCAC"} "{truncate(comment()!, 40)}" + + + + + + + y/n{" "} + + select + + + + enter{" "} + + confirm + + + + c{" "} + + comment + + + + ) +} + +// Text Input Question Dialog +function DialogQuestionText(props: SingleQuestionProps) { + const dialog = useDialog() + const { theme } = useTheme() + let textarea: TextareaRenderable + + onMount(() => { + dialog.setSize("medium") + setTimeout(() => { + textarea.focus() + }, 1) + }) + + function confirmText() { + const value = textarea.plainText.trim() + props.onAnswer({ value: value || null }) + } + + function submitAll() { + const value = textarea.plainText.trim() + props.onSubmitAll({ value: value || null }) + } + + useKeyboard((evt) => { + if (evt.ctrl && evt.name === "return") { + submitAll() + evt.preventDefault() + } + }) + + // Show default as placeholder + const placeholder = createMemo(() => { + if (typeof props.item.default === "string") { + return props.item.default + } + return "Enter your response..." + }) + + return ( + + + + {props.item.question} + + esc + + +