diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-question/dialog-question-comment.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-question/dialog-question-comment.tsx new file mode 100644 index 00000000000..2506f02e5b7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-question/dialog-question-comment.tsx @@ -0,0 +1,24 @@ +import { useTheme } from "@tui/context/theme" +import { DialogPrompt } from "@tui/ui/dialog-prompt" + +interface DialogQuestionCommentProps { + question: string + value?: string + onSave: (comment: string) => void + onCancel: () => void +} + +export function DialogQuestionComment(props: DialogQuestionCommentProps) { + const { theme } = useTheme() + + return ( + Add comment} + placeholder="Optional comment..." + value={props.value} + onConfirm={(value) => props.onSave(value)} + onCancel={() => props.onCancel()} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-question/dialog-question-confirm.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-question/dialog-question-confirm.tsx new file mode 100644 index 00000000000..35e8d1d1763 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-question/dialog-question-confirm.tsx @@ -0,0 +1,82 @@ +import { createMemo, createSignal, onMount } from "solid-js" +import { useTheme } from "@tui/context/theme" +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { DialogQuestionComment } from "./dialog-question-comment" +import { truncate } from "./helpers" +import type { SingleQuestionProps } from "./types" + +export function DialogQuestionConfirm(props: SingleQuestionProps) { + const dialog = useDialog() + const { theme } = useTheme() + const [comment, setComment] = createSignal(props.currentAnswer?.comment) + + // Find current selection value + const currentValue = createMemo(() => props.currentAnswer?.value) + + onMount(() => { + dialog.setSize("medium") + }) + + function openComment() { + dialog.push(() => ( + { + const trimmedComment = c.trim() || undefined + setComment(trimmedComment) + // Update just the comment, merging with existing answer + props.onAnswer({ comment: trimmedComment }) + dialog.pop() + }} + onCancel={() => dialog.pop()} + /> + )) + } + + function confirmSelection(value: boolean) { + props.onAnswer({ value }) + dialog.pop() + } + + // Yes/No options + const options = createMemo[]>(() => [ + { + title: "Yes", + value: true, + }, + { + title: "No", + value: false, + }, + ]) + + return ( + { + if (typeof option.value === "boolean") { + confirmSelection(option.value) + } + }} + beforeFooter={ + comment() ? ( + + 💬 "{truncate(comment()!, 40)}" + + ) : undefined + } + keybind={[ + { + keybind: { name: "c", ctrl: false, meta: false, shift: false, super: false, leader: false }, + title: "add comment", + onTrigger: () => openComment(), + }, + ]} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-question/dialog-question-multi-select.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-question/dialog-question-multi-select.tsx new file mode 100644 index 00000000000..2d99cdb6560 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-question/dialog-question-multi-select.tsx @@ -0,0 +1,86 @@ +import { createMemo, createSignal, onMount } from "solid-js" +import { useTheme } from "@tui/context/theme" +import { useDialog } from "@tui/ui/dialog" +import { DialogMultiSelect, type DialogMultiSelectOption } from "@tui/ui/dialog-multiselect" +import { DialogQuestionComment } from "./dialog-question-comment" +import { truncate } from "./helpers" +import type { SingleQuestionProps } from "./types" + +export function DialogQuestionMultiSelect(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)) + }) + + onMount(() => { + dialog.setSize("medium") + }) + + function openComment() { + dialog.push(() => ( + { + const trimmedComment = c.trim() || undefined + setComment(trimmedComment) + // Update just the comment, merging with existing answer + props.onAnswer({ comment: trimmedComment }) + dialog.pop() + }} + onCancel={() => dialog.pop()} + /> + )) + } + + // Convert options to DialogMultiSelectOption format + const selectOptions = createMemo[]>(() => + sortedOptions().map((option) => ({ + title: option.label, + value: option.value, + footer: option.recommended ? "(Recommended)" : undefined, + })), + ) + + // Get current values as array + const currentValues = createMemo(() => { + if (Array.isArray(props.currentAnswer?.value)) { + return props.currentAnswer.value + } + return [] + }) + + function confirmSelection(selected: string[]) { + props.onAnswer({ value: selected }) + dialog.pop() + } + + return ( + + 💬 "{truncate(comment()!, 40)}" + + ) : undefined + } + keybind={[ + { + keybind: { name: "c", ctrl: false, meta: false, shift: false, super: false, leader: false }, + title: "add comment", + onTrigger: () => openComment(), + }, + ]} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-question/dialog-question-select.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-question/dialog-question-select.tsx new file mode 100644 index 00000000000..677669c53e7 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-question/dialog-question-select.tsx @@ -0,0 +1,81 @@ +import { createMemo, createSignal, onMount } from "solid-js" +import { useTheme } from "@tui/context/theme" +import { useDialog } from "@tui/ui/dialog" +import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select" +import { DialogQuestionComment } from "./dialog-question-comment" +import { truncate } from "./helpers" +import type { SingleQuestionProps } from "./types" + +export 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(() => ( + { + const trimmedComment = c.trim() || undefined + setComment(trimmedComment) + // Update just the comment, merging with existing answer + props.onAnswer({ comment: trimmedComment }) + dialog.pop() + }} + onCancel={() => dialog.pop()} + /> + )) + } + + function confirmSelection(option: { value: string; label: string; recommended?: boolean }) { + props.onAnswer({ value: option.value }) + dialog.pop() + } + + // Convert options to DialogSelectOption format + const selectOptions = createMemo[]>(() => + sortedOptions().map((option) => ({ + title: option.label, + value: option, + footer: option.recommended ? "(Recommended)" : undefined, + })), + ) + + return ( + o.value === currentValue())} + hideSearch={true} + onSelect={(option) => confirmSelection(option.value)} + beforeFooter={ + comment() ? ( + + 💬 "{truncate(comment()!, 40)}" + + ) : undefined + } + keybind={[ + { + keybind: { name: "c", ctrl: false, meta: false, shift: false, super: false, leader: false }, + title: "add comment", + onTrigger: () => openComment(), + }, + ]} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-question/dialog-question-text.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-question/dialog-question-text.tsx new file mode 100644 index 00000000000..2dec5abe578 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-question/dialog-question-text.tsx @@ -0,0 +1,25 @@ +import { useDialog } from "@tui/ui/dialog" +import { DialogPrompt } from "@tui/ui/dialog-prompt" +import type { SingleQuestionProps } from "./types" + +export function DialogQuestionText(props: SingleQuestionProps) { + const dialog = useDialog() + const placeholder = typeof props.item.default === "string" ? props.item.default : "Enter your response..." + const initialValue = typeof props.currentAnswer?.value === "string" ? props.currentAnswer.value : "" + + function confirmText(value: string) { + const trimmedValue = value.trim() + props.onAnswer({ value: trimmedValue || null }) + dialog.pop() + } + + return ( + props.onCancel()} + /> + ) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-question/dialog-question.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-question/dialog-question.tsx new file mode 100644 index 00000000000..8bfb6facc26 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-question/dialog-question.tsx @@ -0,0 +1,145 @@ +import { createMemo, onMount } 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" +import { DialogQuestionSelect } from "./dialog-question-select" +import { DialogQuestionMultiSelect } from "./dialog-question-multi-select" +import { DialogQuestionConfirm } from "./dialog-question-confirm" +import { DialogQuestionText } from "./dialog-question-text" +import { formatAnswerPreview, truncate } from "./helpers" +import type { Answers, QuestionDialogProps } from "./types" + +// Main DialogQuestion - shows list of all questions +export function DialogQuestion(props: QuestionDialogProps) { + const dialog = useDialog() + const [answers, setAnswers] = createStore(props.initialAnswers ?? {}) + + const questions = () => props.question.questions + const answeredCount = () => + Object.keys(answers).filter((k) => answers[k]?.value !== undefined && answers[k]?.value !== null).length + + onMount(() => { + dialog.setSize("medium") + }) + + function openQuestion(item: Question.QuestionItem) { + const Component = { + select: DialogQuestionSelect, + "multi-select": DialogQuestionMultiSelect, + confirm: DialogQuestionConfirm, + text: DialogQuestionText, + }[item.type] + + dialog.push(() => ( + { + // Merge partial answer with existing answer + const mergedAnswer = { + ...answers[item.id], + ...partialAnswer, + } + setAnswers(item.id, mergedAnswer) + // Sync to initialAnswers if provided + if (props.initialAnswers) { + props.initialAnswers[item.id] = mergedAnswer + } + }} + onCancel={() => dialog.pop()} + /> + )) + } + + function submit(finalAnswers?: Answers) { + props.onSubmit(finalAnswers ?? answers) + } + + // Convert questions to DialogSelect options + const selectOptions = createMemo[]>(() => { + // Access all keys to subscribe to any changes + Object.keys(answers) + + return 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 (evt.ctrl && evt.name === "return") { + evt.preventDefault() + submit() + return + } + }) + + return ( + { + submit() + return false + }, + }, + ]} + /> + ) +} + +// Static method to show the dialog +DialogQuestion.show = ( + dialog: DialogContext, + question: Question.Info, +): Promise<{ answers: Answers; cancelled: boolean }> => { + // Create a persistent answers object that will be shared across re-renders + const persistentAnswers: Answers = {} + + return new Promise((resolve) => { + let resolved = false + + dialog.replace( + () => ( + { + if (resolved) return + resolved = true + resolve({ answers, cancelled: false }) + // Clear dialog after resolving to prevent onClose from firing + setTimeout(() => dialog.clear(), 0) + }} + onCancel={() => { + if (resolved) return + resolved = true + dialog.clear() + resolve({ answers: {}, cancelled: true }) + }} + /> + ), + () => { + if (resolved) return + resolved = true + resolve({ answers: {}, cancelled: true }) + }, + ) + }) +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-question/helpers.ts b/packages/opencode/src/cli/cmd/tui/component/dialog-question/helpers.ts new file mode 100644 index 00000000000..b7bcd383614 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-question/helpers.ts @@ -0,0 +1,40 @@ +import type { Question } from "@/question" +import type { Answer } from "./types" + +export function formatAnswerPreview(item: Question.QuestionItem, answer?: Answer): string { + if (!answer || answer.value === undefined || answer.value === null) { + return "(not answered)" + } + + if (item.type === "confirm") { + return answer.value ? "Yes" : "No" + } + + if (item.type === "multi-select" && Array.isArray(answer.value)) { + if (answer.value.length === 0) return "(none selected)" + const labels = answer.value + .map((v) => item.options?.find((o) => o.value === v)?.label ?? v) + .slice(0, 2) + .join(", ") + if (answer.value.length > 2) { + return `${labels} +${answer.value.length - 2} more` + } + return labels + } + + if (item.type === "select" && typeof answer.value === "string") { + const option = item.options?.find((o) => o.value === answer.value) + return option?.label ?? answer.value + } + + if (typeof answer.value === "string") { + return truncate(answer.value, 40) + } + + return String(answer.value) +} + +export function truncate(str: string, maxLength: number): string { + if (str.length <= maxLength) return str + return str.slice(0, maxLength - 3) + "..." +} diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-question/index.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-question/index.tsx new file mode 100644 index 00000000000..2110d278d58 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-question/index.tsx @@ -0,0 +1,8 @@ +export { DialogQuestion } from "./dialog-question" +export { DialogQuestionSelect } from "./dialog-question-select" +export { DialogQuestionMultiSelect } from "./dialog-question-multi-select" +export { DialogQuestionConfirm } from "./dialog-question-confirm" +export { DialogQuestionText } from "./dialog-question-text" +export { DialogQuestionComment } from "./dialog-question-comment" +export { formatAnswerPreview, truncate } from "./helpers" +export type { Answer, Answers, QuestionDialogProps, SingleQuestionProps } from "./types" diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-question/types.ts b/packages/opencode/src/cli/cmd/tui/component/dialog-question/types.ts new file mode 100644 index 00000000000..5094a57979a --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-question/types.ts @@ -0,0 +1,23 @@ +import type { Question } from "@/question" + +// Types +export interface Answer { + value: string | string[] | boolean | null + comment?: string +} + +export type Answers = Record + +export interface QuestionDialogProps { + question: Question.Info + onSubmit: (answers: Answers) => void + onCancel: () => void + initialAnswers?: Answers +} + +export interface SingleQuestionProps { + item: Question.QuestionItem + currentAnswer?: Answer + onAnswer: (answer: Partial) => void // Merges with current answer + onCancel: () => void +} 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 48f7db05426..0c3704691de 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/component/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() @@ -1686,6 +1725,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-multiselect.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-multiselect.tsx new file mode 100644 index 00000000000..92e416f824c --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-multiselect.tsx @@ -0,0 +1,353 @@ +import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" +import { useTheme, selectedForeground } from "@tui/context/theme" +import { entries, filter, flatMap, groupBy, pipe } from "remeda" +import { batch, createEffect, createMemo, For, Show, type JSX } from "solid-js" +import { createStore } from "solid-js/store" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import * as fuzzysort from "fuzzysort" +import { isDeepEqual } from "remeda" +import { useDialog } from "@tui/ui/dialog" +import { useKeybind } from "@tui/context/keybind" +import { Keybind } from "@/util/keybind" +import { Locale } from "@/util/locale" + +export interface DialogMultiSelectProps { + title: string + placeholder?: string + options: DialogMultiSelectOption[] + ref?: (ref: DialogMultiSelectRef) => void + onMove?: (option: DialogMultiSelectOption) => void + onFilter?: (query: string) => void + onSelect?: (selected: T[]) => void + keybind?: { + keybind: Keybind.Info + title: string + disabled?: boolean + onTrigger: (option?: DialogMultiSelectOption) => void + }[] + current?: T[] + hideSearch?: boolean + beforeFooter?: JSX.Element +} + +export interface DialogMultiSelectOption { + title: string + value: T + description?: string + footer?: JSX.Element | string + category?: string + disabled?: boolean + bg?: RGBA + gutter?: JSX.Element +} + +export type DialogMultiSelectRef = { + filter: string + filtered: DialogMultiSelectOption[] +} + +export function DialogMultiSelect(props: DialogMultiSelectProps) { + const dialog = useDialog() + const { theme } = useTheme() + const [store, setStore] = createStore({ + selected: 0, + filter: "", + }) + const [selectedValues, setSelectedValues] = createStore>({}) + + // Initialize selected values from current array + createEffect(() => { + if (props.current && props.current.length > 0) { + const newSelected: Record = {} + for (const val of props.current) { + newSelected[JSON.stringify(val)] = true + } + setSelectedValues(newSelected) + } + }) + + const isSelected = (value: T) => selectedValues[JSON.stringify(value)] ?? false + + let input: InputRenderable + + const filtered = createMemo(() => { + const needle = store.filter.toLowerCase() + const result = pipe( + props.options, + filter((x) => x.disabled !== true), + (x) => (!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj)), + ) + return result + }) + + const grouped = createMemo(() => { + const result = pipe( + filtered(), + groupBy((x) => x.category ?? ""), + entries(), + ) + return result + }) + + const flat = createMemo(() => { + return pipe( + grouped(), + flatMap(([_, options]) => options), + ) + }) + + const dimensions = useTerminalDimensions() + const height = createMemo(() => + Math.min(flat().length + grouped().length * 2 - 1, Math.floor(dimensions().height / 2) - 6), + ) + + const selected = createMemo(() => flat()[store.selected]) + + createEffect(() => { + store.filter + if (store.filter.length > 0) { + setStore("selected", 0) + } + scroll.scrollTo(0) + }) + + function move(direction: number) { + let next = store.selected + direction + if (next < 0) next = flat().length - 1 + if (next >= flat().length) next = 0 + moveTo(next) + } + + function moveTo(next: number) { + setStore("selected", next) + props.onMove?.(selected()!) + const target = scroll.getChildren().find((child) => { + return child.id === JSON.stringify(selected()?.value) + }) + if (!target) return + const y = target.y - scroll.y + if (y >= scroll.height) { + scroll.scrollBy(y - scroll.height + 1) + } + if (y < 0) { + scroll.scrollBy(y) + if (isDeepEqual(flat()[0].value, selected()?.value)) { + scroll.scrollTo(0) + } + } + } + + function toggleSelection() { + const option = selected() + if (option) { + const key = JSON.stringify(option.value) + setSelectedValues(key, !selectedValues[key]) + } + } + + function getSelectedValues(): T[] { + return flat() + .filter((opt) => isSelected(opt.value)) + .map((opt) => opt.value) + } + + const keybind = useKeybind() + const allKeybinds = createMemo(() => [ + { + keybind: { name: "space", ctrl: false, meta: false, shift: false, super: false, leader: false }, + title: "toggle", + disabled: false, + onTrigger: () => toggleSelection(), + }, + { + keybind: { name: "return", ctrl: false, meta: false, shift: false, super: false, leader: false }, + title: "select", + disabled: false, + onTrigger: () => props.onSelect?.(getSelectedValues()), + }, + ...(props.keybind ?? []), + ]) + + useKeyboard((evt) => { + if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1) + if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1) + if (evt.name === "pageup") move(-10) + if (evt.name === "pagedown") move(10) + + for (const item of allKeybinds()) { + if (item.disabled) continue + if (Keybind.match(item.keybind, keybind.parse(evt))) { + evt.preventDefault() + item.onTrigger(selected()) + } + } + }) + + let scroll: ScrollBoxRenderable + const ref: DialogMultiSelectRef = { + get filter() { + return store.filter + }, + get filtered() { + return filtered() + }, + } + props.ref?.(ref) + + const keybinds = createMemo(() => allKeybinds().filter((x) => !x.disabled)) + + return ( + + + + + {props.title} + + esc + + {!props.hideSearch ? ( + + { + batch(() => { + setStore("filter", e) + props.onFilter?.(e) + }) + }} + focusedBackgroundColor={theme.backgroundPanel} + cursorColor={theme.primary} + focusedTextColor={theme.textMuted} + ref={(r) => { + input = r + setTimeout(() => input.focus(), 1) + }} + placeholder={props.placeholder ?? "Search"} + /> + + ) : ( + + {}} + ref={(r) => { + input = r + setTimeout(() => input.focus(), 1) + }} + /> + + )} + + (scroll = r)} + maxHeight={height()} + > + + {([category, options], index) => ( + <> + + 0 ? 1 : 0} paddingLeft={3}> + + {category} + + + + + {(option) => { + const active = createMemo(() => isDeepEqual(option.value, selected()?.value)) + const checked = createMemo(() => isSelected(option.value)) + return ( + { + const key = JSON.stringify(option.value) + setSelectedValues(key, !selectedValues[key]) + }} + onMouseOver={() => { + const index = filtered().findIndex((x) => isDeepEqual(x.value, option.value)) + if (index === -1) return + moveTo(index) + }} + backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)} + paddingLeft={1} + paddingRight={3} + gap={1} + > + + + ) + }} + + + )} + + + }> + + {props.beforeFooter} + }> + + + {(item) => ( + + + {item.title}{" "} + + {Keybind.toString(item.keybind)} + + )} + + + + + + + ) +} + +function MultiSelectOption(props: { + title: string + description?: string + active?: boolean + checked?: boolean + footer?: JSX.Element | string + gutter?: JSX.Element +}) { + const { theme } = useTheme() + const fg = selectedForeground(theme) + + return ( + <> + + ● + + + {Locale.truncate(props.title, 62)} + + {props.description} + + + + + {props.footer} + + + + ) +} 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..003dfff0187 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-question.tsx @@ -0,0 +1,493 @@ +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 { DialogPrompt } from "@tui/ui/dialog-prompt" +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 + initialAnswers?: Answers +} + +interface SingleQuestionProps { + item: Question.QuestionItem + currentAnswer?: Answer + onAnswer: (answer: Partial) => void // Merges with current answer + onCancel: () => void +} + +// Main DialogQuestion - shows list of all questions +export function DialogQuestion(props: QuestionDialogProps) { + const dialog = useDialog() + const [answers, setAnswers] = createStore(props.initialAnswers ?? {}) + + const questions = () => props.question.questions + const answeredCount = () => + Object.keys(answers).filter((k) => answers[k]?.value !== undefined && answers[k]?.value !== null).length + + onMount(() => { + dialog.setSize("medium") + }) + + function openQuestion(item: Question.QuestionItem) { + const Component = { + select: DialogQuestionSelect, + "multi-select": DialogQuestionMultiSelect, + confirm: DialogQuestionConfirm, + text: DialogQuestionText, + }[item.type] + + dialog.push(() => ( + { + // Merge partial answer with existing answer + const mergedAnswer = { + ...answers[item.id], + ...partialAnswer, + } + setAnswers(item.id, mergedAnswer) + // Sync to initialAnswers if provided + if (props.initialAnswers) { + props.initialAnswers[item.id] = mergedAnswer + } + }} + onCancel={() => dialog.pop()} + /> + )) + } + + function submit(finalAnswers?: Answers) { + props.onSubmit(finalAnswers ?? answers) + } + + // Convert questions to DialogSelect options + const selectOptions = createMemo[]>(() => { + // Access all keys to subscribe to any changes + const answerKeys = Object.keys(answers) + + return 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: (ctx) => openQuestion(item), + } + }) + }) + + // Handle submission with ctrl+enter keybind + useKeyboard((evt) => { + if (evt.ctrl && evt.name === "return") { + evt.preventDefault() + submit() + return + } + }) + + return ( + { + if (option.onSelect) option.onSelect(dialog) + }, + }, + { + keybind: { name: "return", ctrl: true, meta: false, shift: false, super: false, leader: false }, + title: "submit", + onTrigger: (option) => { + submit() + return false + }, + }, + ]} + /> + ) +} + +// 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(() => ( + { + const trimmedComment = c.trim() || undefined + setComment(trimmedComment) + // Update just the comment, merging with existing answer + props.onAnswer({ comment: trimmedComment }) + dialog.pop() + }} + onCancel={() => dialog.pop()} + /> + )) + } + + function confirmSelection(option: { value: string; label: string; recommended?: boolean }) { + props.onAnswer({ value: option.value }) + dialog.pop() + } + + // Convert options to DialogSelectOption format + const selectOptions = createMemo[]>(() => + sortedOptions().map((option) => ({ + title: option.label, + value: option, + footer: option.recommended ? "(Recommended)" : undefined, + onSelect: () => confirmSelection(option), + })), + ) + + return ( + o.value === currentValue())} + hideSearch={true} + beforeFooter={ + comment() ? ( + + 💬 "{truncate(comment()!, 40)}" + + ) : undefined + } + keybind={[ + { + keybind: { name: "c", ctrl: false, meta: false, shift: false, super: false, leader: false }, + title: "add comment", + onTrigger: () => openComment(), + }, + ]} + /> + ) +} + +// 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(() => ( + { + const trimmedComment = c.trim() || undefined + setComment(trimmedComment) + // Update just the comment, merging with existing answer + props.onAnswer({ comment: trimmedComment }) + 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() }) + dialog.pop() + } + + // 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 + })), + ) + + return ( + confirmSelection()} + beforeFooter={ + comment() ? ( + + 💬 "{truncate(comment()!, 40)}" + + ) : undefined + } + 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: "c", ctrl: false, meta: false, shift: false, super: false, leader: false }, + title: "add comment", + onTrigger: () => openComment(), + }, + ]} + /> + ) +} + +// Confirm (Yes/No) Question Dialog +function DialogQuestionConfirm(props: SingleQuestionProps) { + const dialog = useDialog() + const { theme } = useTheme() + const [comment, setComment] = createSignal(props.currentAnswer?.comment) + + // Find current selection value + const currentValue = createMemo(() => props.currentAnswer?.value) + + onMount(() => { + dialog.setSize("medium") + }) + + function openComment() { + dialog.push(() => ( + { + const trimmedComment = c.trim() || undefined + setComment(trimmedComment) + // Update just the comment, merging with existing answer + props.onAnswer({ comment: trimmedComment }) + dialog.pop() + }} + onCancel={() => dialog.pop()} + /> + )) + } + + function confirmSelection(value: boolean) { + props.onAnswer({ value }) + dialog.pop() + } + + // Yes/No options + const options = createMemo[]>(() => [ + { + title: "Yes", + value: true, + onSelect: () => confirmSelection(true), + }, + { + title: "No", + value: false, + onSelect: () => confirmSelection(false), + }, + ]) + + return ( + + 💬 "{truncate(comment()!, 40)}" + + ) : undefined + } + keybind={[ + { + keybind: { name: "c", ctrl: false, meta: false, shift: false, super: false, leader: false }, + title: "add comment", + onTrigger: () => openComment(), + }, + ]} + /> + ) +} + +// Text Input Question Dialog +function DialogQuestionText(props: SingleQuestionProps) { + const dialog = useDialog() + const placeholder = typeof props.item.default === "string" ? props.item.default : "Enter your response..." + const initialValue = typeof props.currentAnswer?.value === "string" ? props.currentAnswer.value : "" + + return ( + { + const trimmedValue = value.trim() + props.onAnswer({ value: trimmedValue || null }) + dialog.pop() + }} + onCancel={() => props.onCancel()} + /> + ) +} + +// Comment Dialog (nested) +function DialogQuestionComment(props: { + question: string + value?: string + onSave: (comment: string) => void + onCancel: () => void +}) { + const { theme } = useTheme() + + return ( + Add comment} + placeholder="Optional comment..." + value={props.value} + onConfirm={(value) => props.onSave(value)} + onCancel={() => props.onCancel()} + /> + ) +} + +// Static method to show the dialog +DialogQuestion.show = ( + dialog: DialogContext, + question: Question.Info, +): Promise<{ answers: Answers; cancelled: boolean }> => { + // Create a persistent answers object that will be shared across re-renders + const persistentAnswers: Answers = {} + + return new Promise((resolve) => { + let resolved = false + + dialog.replace( + () => ( + { + if (resolved) return + resolved = true + resolve({ answers, cancelled: false }) + // Clear dialog after resolving to prevent onClose from firing + setTimeout(() => dialog.clear(), 0) + }} + onCancel={() => { + if (resolved) return + resolved = true + dialog.clear() + resolve({ answers: {}, cancelled: true }) + }} + /> + ), + () => { + if (resolved) return + resolved = true + resolve({ answers: {}, cancelled: true }) + }, + ) + }) +} + +// Helper functions +function formatAnswerPreview(item: Question.QuestionItem, answer?: Answer): string { + if (!answer || answer.value === undefined || answer.value === null) { + return "(not answered)" + } + + if (item.type === "confirm") { + return answer.value ? "Yes" : "No" + } + + if (item.type === "multi-select" && Array.isArray(answer.value)) { + if (answer.value.length === 0) return "(none selected)" + const labels = answer.value + .map((v) => item.options?.find((o) => o.value === v)?.label ?? v) + .slice(0, 2) + .join(", ") + if (answer.value.length > 2) { + return `${labels} +${answer.value.length - 2} more` + } + return labels + } + + if (item.type === "select" && typeof answer.value === "string") { + const option = item.options?.find((o) => o.value === answer.value) + return option?.label ?? answer.value + } + + if (typeof answer.value === "string") { + return truncate(answer.value, 40) + } + + return String(answer.value) +} + +function truncate(str: string, maxLength: number): string { + if (str.length <= maxLength) return str + return str.slice(0, maxLength - 3) + "..." +} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 3f49a7c3219..31229fd597a 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -26,6 +26,8 @@ export interface DialogSelectProps { onTrigger: (option: DialogSelectOption) => void }[] current?: T + hideSearch?: boolean + beforeFooter?: JSX.Element } export interface DialogSelectOption { @@ -138,28 +140,32 @@ export function DialogSelect(props: DialogSelectProps) { } const keybind = useKeybind() + const allKeybinds = createMemo(() => [ + { + keybind: { name: "return", ctrl: false, meta: false, shift: false, super: false, leader: false }, + title: "select", + disabled: false, + onTrigger: (option?: DialogSelectOption) => { + if (option) { + option.onSelect?.(dialog) + props.onSelect?.(option) + } + }, + }, + ...(props.keybind ?? []), + ]) + useKeyboard((evt) => { if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1) if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1) if (evt.name === "pageup") move(-10) if (evt.name === "pagedown") move(10) - if (evt.name === "return") { - const option = selected() - if (option) { - // evt.preventDefault() - if (option.onSelect) option.onSelect(dialog) - props.onSelect?.(option) - } - } - for (const item of props.keybind ?? []) { + for (const item of allKeybinds()) { if (item.disabled) continue if (Keybind.match(item.keybind, keybind.parse(evt))) { - const s = selected() - if (s) { - evt.preventDefault() - item.onTrigger(s) - } + evt.preventDefault() + item.onTrigger(selected()) } } }) @@ -175,7 +181,7 @@ export function DialogSelect(props: DialogSelectProps) { } props.ref?.(ref) - const keybinds = createMemo(() => props.keybind?.filter((x) => !x.disabled) ?? []) + const keybinds = createMemo(() => allKeybinds().filter((x) => !x.disabled)) return ( @@ -186,24 +192,36 @@ export function DialogSelect(props: DialogSelectProps) { esc - - { - batch(() => { - setStore("filter", e) - props.onFilter?.(e) - }) - }} - focusedBackgroundColor={theme.backgroundPanel} - cursorColor={theme.primary} - focusedTextColor={theme.textMuted} - ref={(r) => { - input = r - setTimeout(() => input.focus(), 1) - }} - placeholder={props.placeholder ?? "Search"} - /> - + {!props.hideSearch ? ( + + { + batch(() => { + setStore("filter", e) + props.onFilter?.(e) + }) + }} + focusedBackgroundColor={theme.backgroundPanel} + cursorColor={theme.primary} + focusedTextColor={theme.textMuted} + ref={(r) => { + input = r + setTimeout(() => input.focus(), 1) + }} + placeholder={props.placeholder ?? "Search"} + /> + + ) : ( + + {}} + ref={(r) => { + input = r + setTimeout(() => input.focus(), 1) + }} + /> + + )} (props: DialogSelectProps) { )} - }> - - - {(item) => ( - - - {item.title}{" "} - - {Keybind.toString(item.keybind)} - - )} - + }> + + {props.beforeFooter} + }> + + + {(item) => ( + + + {item.title}{" "} + + {Keybind.toString(item.keybind)} + + )} + + + diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 9b773111c35..306ac154609 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -62,7 +62,7 @@ function init() { current.onClose?.() setStore("stack", store.stack.slice(0, -1)) evt.preventDefault() - refocus() + if (store.stack.length === 0) refocus() } }) @@ -97,6 +97,7 @@ function init() { refocus() }, replace(input: any, onClose?: () => void) { + console.log("replace dialog", store.stack.length) if (store.stack.length === 0) { focus = renderer.currentFocusedRenderable } @@ -111,6 +112,25 @@ function init() { }, ]) }, + push(input: any, onClose?: () => void) { + console.log("push dialog", store.stack.length) + if (store.stack.length === 0) { + focus = renderer.currentFocusedRenderable + } + setStore("stack", [ + ...store.stack, + { + element: input, + onClose, + }, + ]) + }, + pop() { + console.log("popping dialog", store.stack.length) + if (store.stack.length === 0) return + setStore("stack", store.stack.slice(0, -1)) + if (store.stack.length === 0) refocus() + }, get stack() { return store.stack }, diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts index ad6e22e1bee..2fddd2d6543 100644 --- a/packages/opencode/src/id/id.ts +++ b/packages/opencode/src/id/id.ts @@ -6,6 +6,7 @@ export namespace Identifier { session: "ses", message: "msg", permission: "per", + question: "qst", user: "usr", part: "prt", pty: "pty", diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts new file mode 100644 index 00000000000..0f3178b9b55 --- /dev/null +++ b/packages/opencode/src/question/index.ts @@ -0,0 +1,175 @@ +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import z from "zod" +import { Log } from "../util/log" +import { Identifier } from "../id/id" +import { Instance } from "../project/instance" + +export namespace Question { + const log = Log.create({ service: "question" }) + + export const Option = z.object({ + value: z.string(), + label: z.string(), + recommended: z.boolean().optional(), + }) + export type Option = z.infer + + export const QuestionItem = z.object({ + id: z.string(), + type: z.enum(["select", "multi-select", "confirm", "text"]), + question: z.string(), + options: z.array(Option).optional(), + default: z.union([z.string(), z.array(z.string()), z.boolean()]).optional(), + }) + export type QuestionItem = z.infer + + export const Answer = z.object({ + value: z.union([z.string(), z.array(z.string()), z.boolean(), z.null()]), + comment: z.string().optional(), + }) + export type Answer = z.infer + + export const Answers = z.record(z.string(), Answer) + export type Answers = z.infer + + export const Info = z + .object({ + id: z.string(), + sessionID: z.string(), + messageID: z.string(), + callID: z.string().optional(), + questions: z.array(QuestionItem), + time: z.object({ + created: z.number(), + }), + }) + .meta({ + ref: "Question", + }) + export type Info = z.infer + + export const Event = { + Updated: BusEvent.define("question.updated", Info), + Replied: BusEvent.define( + "question.replied", + z.object({ + sessionID: z.string(), + questionID: z.string(), + answers: Answers, + }), + ), + } + + interface PendingQuestion { + info: Info + resolve: (answers: Answers) => void + reject: (e: Error) => void + } + + const state = Instance.state( + () => { + const pending: { + [sessionID: string]: { + [questionID: string]: PendingQuestion + } + } = {} + + return { pending } + }, + async (state) => { + for (const pending of Object.values(state.pending)) { + for (const item of Object.values(pending)) { + item.reject(new RejectedError(item.info.sessionID, item.info.id, item.info.callID)) + } + } + }, + ) + + export function pending() { + return state().pending + } + + export async function ask(input: { + sessionID: Info["sessionID"] + messageID: Info["messageID"] + callID?: Info["callID"] + questions: Info["questions"] + }): Promise { + const { pending } = state() + log.info("asking", { + sessionID: input.sessionID, + messageID: input.messageID, + callID: input.callID, + questionCount: input.questions.length, + }) + + const info: Info = { + id: Identifier.ascending("question"), + sessionID: input.sessionID, + messageID: input.messageID, + callID: input.callID, + questions: input.questions, + time: { + created: Date.now(), + }, + } + + pending[input.sessionID] = pending[input.sessionID] || {} + return new Promise((resolve, reject) => { + pending[input.sessionID][info.id] = { + info, + resolve, + reject, + } + Bus.publish(Event.Updated, info) + }) + } + + export function respond(input: { sessionID: Info["sessionID"]; questionID: Info["id"]; answers: Answers }) { + log.info("response", input) + const { pending } = state() + const match = pending[input.sessionID]?.[input.questionID] + if (!match) return + delete pending[input.sessionID][input.questionID] + Bus.publish(Event.Replied, { + sessionID: input.sessionID, + questionID: input.questionID, + answers: input.answers, + }) + match.resolve(input.answers) + } + + export function reject(input: { sessionID: Info["sessionID"]; questionID: Info["id"] }) { + log.info("reject", input) + const { pending } = state() + const match = pending[input.sessionID]?.[input.questionID] + if (!match) return + delete pending[input.sessionID][input.questionID] + Bus.publish(Event.Replied, { + sessionID: input.sessionID, + questionID: input.questionID, + answers: {}, + }) + match.reject(new RejectedError(input.sessionID, input.questionID, match.info.callID)) + } + + export function rejectAll(sessionID: string) { + const { pending } = state() + const questions = pending[sessionID] + if (!questions) return + for (const questionID of Object.keys(questions)) { + reject({ sessionID, questionID }) + } + } + + export class RejectedError extends Error { + constructor( + public readonly sessionID: string, + public readonly questionID: string, + public readonly callID?: string, + ) { + super("The user cancelled the question. You may ask again if needed.") + } + } +} diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index f1485ec0150..158fa54ab47 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -21,6 +21,7 @@ import { Format } from "../format" import { MessageV2 } from "../session/message-v2" import { TuiRoute } from "./tui" import { Permission } from "../permission" +import { Question } from "../question" import { Instance } from "../project/instance" import { Vcs } from "../project/vcs" import { Agent } from "../agent/agent" @@ -1413,6 +1414,76 @@ export namespace Server { return c.json(true) }, ) + .post( + "/session/:sessionID/questions/:questionID", + describeRoute({ + summary: "Respond to question", + description: "Submit answers to a question asked by the AI assistant.", + operationId: "question.respond", + responses: { + 200: { + description: "Question answered successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string(), + questionID: z.string(), + }), + ), + validator("json", z.object({ answers: Question.Answers })), + async (c) => { + const params = c.req.valid("param") + Question.respond({ + sessionID: params.sessionID, + questionID: params.questionID, + answers: c.req.valid("json").answers, + }) + return c.json(true) + }, + ) + .post( + "/session/:sessionID/questions/:questionID/reject", + describeRoute({ + summary: "Reject question", + description: "Cancel a question asked by the AI assistant.", + operationId: "question.reject", + responses: { + 200: { + description: "Question rejected successfully", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400, 404), + }, + }), + validator( + "param", + z.object({ + sessionID: z.string(), + questionID: z.string(), + }), + ), + async (c) => { + const params = c.req.valid("param") + Question.reject({ + sessionID: params.sessionID, + questionID: params.questionID, + }) + return c.json(true) + }, + ) .get( "/command", describeRoute({ diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index e71162d0b5d..d37bf344a14 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -37,6 +37,7 @@ import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/util/error" import { fn } from "@/util/fn" import { SessionProcessor } from "./processor" +import { Question } from "../question" import { TaskTool } from "@/tool/task" import { SessionStatus } from "./status" import { LLM } from "./llm" @@ -221,6 +222,7 @@ export namespace SessionPrompt { for (const item of match.callbacks) { item.reject() } + Question.rejectAll(sessionID) delete s[sessionID] SessionStatus.set(sessionID, { type: "idle" }) return diff --git a/packages/opencode/src/session/prompt/anthropic.txt b/packages/opencode/src/session/prompt/anthropic.txt index 43b11250acc..5d51d3f8ad2 100644 --- a/packages/opencode/src/session/prompt/anthropic.txt +++ b/packages/opencode/src/session/prompt/anthropic.txt @@ -95,6 +95,16 @@ assistant: [Uses the Task tool] IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation. +# Asking the User Questions + +When you encounter ambiguous requirements, multiple valid approaches, or need user input before proceeding, use the Ask tool to gather the information you need. This is especially useful when: +- Requirements are unclear and making an assumption could lead to incorrect implementation +- There are multiple valid solutions and user preference matters +- You need to confirm important architectural or design decisions +- Additional context would significantly improve the quality of your response + +Keep questions concise and actionable. Group related questions together in a single Ask call when appropriate. The user can skip any question, so design your workflow to handle partial or missing responses gracefully. + # Code References When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location. diff --git a/packages/opencode/src/session/prompt/beast.txt b/packages/opencode/src/session/prompt/beast.txt index 21db5dcb59c..285bf288372 100644 --- a/packages/opencode/src/session/prompt/beast.txt +++ b/packages/opencode/src/session/prompt/beast.txt @@ -94,6 +94,9 @@ Carefully read the issue and think hard about a plan to solve it before coding. - Revisit your assumptions if unexpected behavior occurs. +# Asking the User Questions +When you encounter ambiguous requirements or need user input before proceeding, use the Ask tool. This is useful for clarifying unclear requirements, choosing between multiple valid approaches, or confirming important decisions. Keep questions concise and actionable. + # Communication Guidelines Always communicate clearly and concisely in a casual, friendly yet professional tone. diff --git a/packages/opencode/src/session/prompt/gemini.txt b/packages/opencode/src/session/prompt/gemini.txt index 87fe422bc75..4ca6fb70131 100644 --- a/packages/opencode/src/session/prompt/gemini.txt +++ b/packages/opencode/src/session/prompt/gemini.txt @@ -9,6 +9,7 @@ You are opencode, an interactive CLI agent specializing in software engineering - **Comments:** Add code comments sparingly. Focus on *why* something is done, especially for complex logic, rather than *what* is done. Only add high-value comments if necessary for clarity or if requested by the user. Do not edit comments that are separate from the code you are changing. *NEVER* talk to the user or describe your changes through comments. - **Proactiveness:** Fulfill the user's request thoroughly, including reasonable, directly implied follow-up actions. - **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If asked *how* to do something, explain first, don't just do it. +- **Asking Questions:** When you need clarification or user input before proceeding, use the Ask tool. This is useful for clarifying ambiguous requirements, choosing between approaches, or confirming important decisions. - **Explaining Changes:** After completing a code modification or file operation *do not* provide summaries unless asked. - **Path Construction:** Before using any file system tool (e.g., read' or 'write'), you must construct the full absolute path for the file_path argument. Always combine the absolute path of the project's root directory with the file's path relative to the root. For example, if the project root is /path/to/project/ and the file is foo/bar/baz.txt, the final path you must use is /path/to/project/foo/bar/baz.txt. If the user provides a relative path, you must resolve it against the root directory to create an absolute path. - **Do Not revert changes:** Do not revert changes to the codebase unless asked to do so by the user. Only revert changes made by you if they have resulted in an error or if the user has explicitly asked you to revert the changes. diff --git a/packages/opencode/src/session/prompt/qwen.txt b/packages/opencode/src/session/prompt/qwen.txt index a34fdb01a05..3e45baae831 100644 --- a/packages/opencode/src/session/prompt/qwen.txt +++ b/packages/opencode/src/session/prompt/qwen.txt @@ -69,6 +69,9 @@ You are allowed to be proactive, but only when the user asks you to do something For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions. 3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did. +# Asking Questions +When you need clarification or user input before proceeding, use the Ask tool. This is useful for clarifying ambiguous requirements, choosing between approaches, or confirming important decisions. Keep questions concise. + # Following conventions When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns. - NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language). diff --git a/packages/opencode/src/tool/ask.ts b/packages/opencode/src/tool/ask.ts new file mode 100644 index 00000000000..6c1d1cc50ac --- /dev/null +++ b/packages/opencode/src/tool/ask.ts @@ -0,0 +1,59 @@ +import z from "zod" +import { Tool } from "./tool" +import { Question } from "../question" +import DESCRIPTION from "./ask.txt" + +export const AskTool = Tool.define("ask", { + description: DESCRIPTION, + parameters: z.object({ + questions: z + .array( + z.object({ + id: z.string().describe("Unique identifier for this question"), + type: z.enum(["select", "multi-select", "confirm", "text"]).describe("The type of question"), + question: z.string().describe("The question to ask the user"), + options: z + .array( + z.object({ + value: z.string().describe("The value returned when this option is selected"), + label: z.string().describe("The label shown to the user"), + recommended: z.boolean().optional().describe("Mark this as the recommended option"), + }), + ) + .optional() + .describe("Options for select/multi-select types"), + default: z + .union([z.string(), z.array(z.string()), z.boolean()]) + .optional() + .describe("Default value shown as hint (not pre-selected)"), + }), + ) + .min(1) + .describe("The questions to ask"), + }), + async execute(params, ctx) { + ctx.metadata({ + title: `Asking ${params.questions.length} question${params.questions.length !== 1 ? "s" : ""}...`, + metadata: { + questions: params.questions, + answers: {}, + }, + }) + + const answers = await Question.ask({ + sessionID: ctx.sessionID, + messageID: ctx.messageID, + callID: ctx.callID, + questions: params.questions, + }) + + return { + title: `Asked ${params.questions.length} question${params.questions.length !== 1 ? "s" : ""}`, + metadata: { + questions: params.questions, + answers, + }, + output: JSON.stringify(answers, null, 2), + } + }, +}) diff --git a/packages/opencode/src/tool/ask.txt b/packages/opencode/src/tool/ask.txt new file mode 100644 index 00000000000..2f13ba90dab --- /dev/null +++ b/packages/opencode/src/tool/ask.txt @@ -0,0 +1,21 @@ +Ask the user one or more questions when you need clarification or input before proceeding. + +Use this tool when: +- You need to clarify ambiguous requirements +- There are multiple valid approaches and user preference matters +- You need additional context or information to proceed +- You want to confirm important decisions before implementing + +Question types: +- "select": Single selection from a list of options +- "multi-select": Multiple selections from a list of options +- "confirm": Yes/No question +- "text": Free-form text input + +Guidelines: +- Each question needs a unique `id` to identify it in the response +- For select/multi-select, provide clear `options` with `value` and `label` +- Mark the recommended option with `recommended: true` when appropriate +- Use `default` to suggest a value (shown as hint, not pre-selected) +- Keep questions concise and actionable +- Group related questions in a single call when possible diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 647c7426715..5c00a5a02d4 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -1,3 +1,4 @@ +import { AskTool } from "./ask" import { BashTool } from "./bash" import { EditTool } from "./edit" import { GlobTool } from "./glob" @@ -89,6 +90,7 @@ export namespace ToolRegistry { return [ InvalidTool, + AskTool, BashTool, ReadTool, GlobTool, diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 16fe07ae4a8..04ffe49457a 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -71,6 +71,10 @@ import type { PtyRemoveResponses, PtyUpdateErrors, PtyUpdateResponses, + QuestionRejectErrors, + QuestionRejectResponses, + QuestionRespondErrors, + QuestionRespondResponses, SessionAbortErrors, SessionAbortResponses, SessionChildrenErrors, @@ -1527,6 +1531,84 @@ export class Permission extends HeyApiClient { } } +export class Question extends HeyApiClient { + /** + * Respond to question + * + * Submit answers to a question asked by the AI assistant. + */ + public respond( + parameters: { + sessionID: string + questionID: string + directory?: string + answers?: { + [key: string]: { + value: string | Array | boolean | null + comment?: string + } + } + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "questionID" }, + { in: "query", key: "directory" }, + { in: "body", key: "answers" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/questions/{questionID}", + ...options, + ...params, + headers: { + "Content-Type": "application/json", + ...options?.headers, + ...params.headers, + }, + }) + } + + /** + * Reject question + * + * Cancel a question asked by the AI assistant. + */ + public reject( + parameters: { + sessionID: string + questionID: string + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "sessionID" }, + { in: "path", key: "questionID" }, + { in: "query", key: "directory" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/session/{sessionID}/questions/{questionID}/reject", + ...options, + ...params, + }) + } +} + export class Command extends HeyApiClient { /** * List commands @@ -2590,6 +2672,8 @@ export class OpencodeClient extends HeyApiClient { permission = new Permission({ client: this.client }) + question = new Question({ client: this.client }) + command = new Command({ client: this.client }) provider = new Provider({ client: this.client }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index a9270956140..a20e7c45535 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -476,6 +476,46 @@ export type EventPermissionReplied = { } } +export type Question = { + id: string + sessionID: string + messageID: string + callID?: string + questions: Array<{ + id: string + type: "select" | "multi-select" | "confirm" | "text" + question: string + options?: Array<{ + value: string + label: string + recommended?: boolean + }> + default?: string | Array | boolean + }> + time: { + created: number + } +} + +export type EventQuestionUpdated = { + type: "question.updated" + properties: Question +} + +export type EventQuestionReplied = { + type: "question.replied" + properties: { + sessionID: string + questionID: string + answers: { + [key: string]: { + value: string | Array | boolean | null + comment?: string + } + } + } +} + export type EventFileEdited = { type: "file.edited" properties: { @@ -746,6 +786,8 @@ export type Event = | EventMessagePartRemoved | EventPermissionUpdated | EventPermissionReplied + | EventQuestionUpdated + | EventQuestionReplied | EventFileEdited | EventTodoUpdated | EventSessionStatus @@ -3135,6 +3177,81 @@ export type PermissionRespondResponses = { export type PermissionRespondResponse = PermissionRespondResponses[keyof PermissionRespondResponses] +export type QuestionRespondData = { + body?: { + answers: { + [key: string]: { + value: string | Array | boolean | null + comment?: string + } + } + } + path: { + sessionID: string + questionID: string + } + query?: { + directory?: string + } + url: "/session/{sessionID}/questions/{questionID}" +} + +export type QuestionRespondErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type QuestionRespondError = QuestionRespondErrors[keyof QuestionRespondErrors] + +export type QuestionRespondResponses = { + /** + * Question answered successfully + */ + 200: boolean +} + +export type QuestionRespondResponse = QuestionRespondResponses[keyof QuestionRespondResponses] + +export type QuestionRejectData = { + body?: never + path: { + sessionID: string + questionID: string + } + query?: { + directory?: string + } + url: "/session/{sessionID}/questions/{questionID}/reject" +} + +export type QuestionRejectErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors] + +export type QuestionRejectResponses = { + /** + * Question rejected successfully + */ + 200: boolean +} + +export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses] + export type CommandListData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 5a978c69c61..e20504997d1 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -2673,6 +2673,192 @@ ] } }, + "/session/{sessionID}/questions/{questionID}": { + "post": { + "operationId": "question.respond", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "questionID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Respond to question", + "description": "Submit answers to a question asked by the AI assistant.", + "responses": { + "200": { + "description": "Question answered successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "answers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "comment": { + "type": "string" + } + }, + "required": ["value"] + } + } + }, + "required": ["answers"] + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.respond({\n ...\n})" + } + ] + } + }, + "/session/{sessionID}/questions/{questionID}/reject": { + "post": { + "operationId": "question.reject", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "sessionID", + "schema": { + "type": "string" + }, + "required": true + }, + { + "in": "path", + "name": "questionID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Reject question", + "description": "Cancel a question asked by the AI assistant.", + "responses": { + "200": { + "description": "Question rejected successfully", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.question.reject({\n ...\n})" + } + ] + } + }, "/command": { "get": { "operationId": "command.list", @@ -6156,6 +6342,155 @@ }, "required": ["type", "properties"] }, + "Question": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sessionID": { + "type": "string" + }, + "messageID": { + "type": "string" + }, + "callID": { + "type": "string" + }, + "questions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": ["select", "multi-select", "confirm", "text"] + }, + "question": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "label": { + "type": "string" + }, + "recommended": { + "type": "boolean" + } + }, + "required": ["value", "label"] + } + }, + "default": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "boolean" + } + ] + } + }, + "required": ["id", "type", "question"] + } + }, + "time": { + "type": "object", + "properties": { + "created": { + "type": "number" + } + }, + "required": ["created"] + } + }, + "required": ["id", "sessionID", "messageID", "questions", "time"] + }, + "Event.question.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.updated" + }, + "properties": { + "$ref": "#/components/schemas/Question" + } + }, + "required": ["type", "properties"] + }, + "Event.question.replied": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "question.replied" + }, + "properties": { + "type": "object", + "properties": { + "sessionID": { + "type": "string" + }, + "questionID": { + "type": "string" + }, + "answers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "comment": { + "type": "string" + } + }, + "required": ["value"] + } + } + }, + "required": ["sessionID", "questionID", "answers"] + } + }, + "required": ["type", "properties"] + }, "Event.file.edited": { "type": "object", "properties": { @@ -6890,6 +7225,12 @@ { "$ref": "#/components/schemas/Event.permission.replied" }, + { + "$ref": "#/components/schemas/Event.question.updated" + }, + { + "$ref": "#/components/schemas/Event.question.replied" + }, { "$ref": "#/components/schemas/Event.file.edited" },