diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index a5655902a47..3778143d867 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1270,6 +1270,12 @@ export const PromptInput: Component = (props) => { clearInput() addOptimisticMessage() + // Reject any pending questions for this session + const pendingQuestions = sync.data.question?.[session.id] ?? [] + for (const q of pendingQuestions) { + sdk.client.question.reject({ requestID: q.id }).catch(() => {}) + } + client.session .prompt({ sessionID: session.id, diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index a0656c5fc60..6f521246982 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -16,6 +16,7 @@ import { type LspStatus, type VcsInfo, type PermissionRequest, + type QuestionRequest, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" @@ -48,6 +49,9 @@ type State = { permission: { [sessionID: string]: PermissionRequest[] } + question: { + [sessionID: string]: QuestionRequest[] + } mcp: { [name: string]: McpStatus } @@ -96,6 +100,7 @@ function createGlobalSync() { session_diff: {}, todo: {}, permission: {}, + question: {}, mcp: {}, lsp: [], vcs: undefined, @@ -205,6 +210,38 @@ function createGlobalSync() { } }) }), + sdk.question.list().then((x) => { + const grouped: Record = {} + for (const question of x.data ?? []) { + if (!question?.id || !question.sessionID) continue + const existing = grouped[question.sessionID] + if (existing) { + existing.push(question) + continue + } + grouped[question.sessionID] = [question] + } + + batch(() => { + for (const sessionID of Object.keys(store.question)) { + if (grouped[sessionID]) continue + setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + setStore( + "question", + sessionID, + reconcile( + questions + .filter((q) => !!q?.id) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)), + { key: "id" }, + ), + ) + } + }) + }), ]).then(() => { setStore("status", "complete") }) @@ -393,6 +430,44 @@ function createGlobalSync() { ) break } + case "question.asked": { + const sessionID = event.properties.sessionID + const questions = store.question[sessionID] + if (!questions) { + setStore("question", sessionID, [event.properties]) + break + } + + const result = Binary.search(questions, event.properties.id, (q) => q.id) + if (result.found) { + setStore("question", sessionID, result.index, reconcile(event.properties)) + break + } + + setStore( + "question", + sessionID, + produce((draft) => { + draft.splice(result.index, 0, event.properties) + }), + ) + break + } + case "question.replied": + case "question.rejected": { + const questions = store.question[event.properties.sessionID] + if (!questions) break + const result = Binary.search(questions, event.properties.requestID, (q) => q.id) + if (!result.found) break + setStore( + "question", + event.properties.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + break + } case "lsp.updated": { const sdk = createOpencodeClient({ baseUrl: globalSDK.url, diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 39124637c26..6898c88f972 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -7,6 +7,7 @@ import { LocalProvider } from "@/context/local" import { base64Decode } from "@opencode-ai/util/encode" import { DataProvider } from "@opencode-ai/ui/context" import { iife } from "@opencode-ai/util/iife" +import type { QuestionAnswer } from "@opencode-ai/sdk/v2" export default function Layout(props: ParentProps) { const params = useParams() @@ -21,12 +22,23 @@ export default function Layout(props: ParentProps) { {iife(() => { const sync = useSync() const sdk = useSDK() - const respond = (input: { + const respondPermission = (input: { sessionID: string permissionID: string response: "once" | "always" | "reject" }) => sdk.client.permission.respond(input) + const respondQuestion = (input: { sessionID: string; questionID: string; answers: QuestionAnswer[] }) => + sdk.client.question.reply({ + requestID: input.questionID, + answers: input.answers, + }) + + const rejectQuestion = (input: { sessionID: string; questionID: string }) => + sdk.client.question.reject({ + requestID: input.questionID, + }) + const navigateToSession = (sessionID: string) => { navigate(`/${params.dir}/session/${sessionID}`) } @@ -35,7 +47,9 @@ export default function Layout(props: ParentProps) { {props.children} diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 9628fec8073..5037596bdf8 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -466,17 +466,257 @@ } } -[data-component="permission-prompt"] { +[data-component="tool-actions"] { display: flex; flex-direction: column; padding: 8px 12px; background-color: var(--surface-raised-strong); border-radius: 0 0 6px 6px; - [data-slot="permission-actions"] { + [data-slot="tool-actions-buttons"] { display: flex; align-items: center; gap: 8px; justify-content: flex-end; } } + +/* Question tool styles */ +[data-component="question-content"] { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 12px; + outline: none; +} + +[data-slot="question-tabs"] { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 4px; +} + +[data-slot="question-tab"] { + padding: 4px 10px; + font-size: 12px; + font-weight: 500; + color: var(--color-text-weak); + background-color: transparent; + border: none; + border-left: 2px solid transparent; + border-radius: 0; + cursor: pointer; + transition: all 0.15s ease; + + &:hover { + color: var(--color-text-base); + border-left-color: var(--color-border-base); + } + + &[data-active="true"] { + color: var(--color-accent); + border-left-color: var(--color-accent); + } + + &[data-answered="true"]:not([data-active="true"]) { + color: var(--color-text-strong); + } +} + +[data-slot="question-body"] { + display: flex; + flex-direction: column; + gap: 8px; +} + +[data-slot="question-text"] { + font-size: 13px; + font-weight: 500; + color: var(--color-text-strong); + line-height: 1.4; +} + +[data-slot="question-options"] { + display: flex; + flex-direction: column; + gap: 6px; +} + +[data-slot="question-option"] { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 10px; + background-color: transparent; + border: 1px solid var(--color-border-weak-base); + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; + + &:hover, + &[data-focused="true"] { + background-color: var(--color-surface-base); + border-color: var(--color-border-base); + } + + &[data-picked="true"] { + background-color: color-mix(in srgb, var(--color-accent) 8%, transparent); + border-color: var(--color-accent); + } +} + +[data-slot="question-radio"] { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: 2px solid var(--color-border-base); + border-radius: 50%; + flex-shrink: 0; + margin-top: 1px; + transition: all 0.15s ease; + + &[data-checked="true"] { + border-color: var(--color-accent); + } +} + +[data-slot="question-option"]:hover [data-slot="question-radio"] { + border-color: var(--color-text-weak); +} + +[data-slot="question-radio-dot"] { + width: 8px; + height: 8px; + background-color: var(--color-accent); + border-radius: 50%; +} + +[data-slot="question-option-content"] { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; + min-width: 0; +} + +[data-slot="question-option-label"] { + font-size: 13px; + font-weight: 500; + color: var(--color-text-strong); +} + +[data-slot="question-option-description"] { + font-size: 11px; + color: var(--color-text-weak); + line-height: 1.3; +} + +[data-slot="question-custom"] { + display: flex; + gap: 6px; + margin-top: 4px; + + [data-component="text-field"] { + flex: 1; + } +} + +[data-slot="question-review"] { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px; + background-color: var(--color-surface-base); + border: 1px solid var(--color-border-weak-base); + border-radius: 6px; +} + +[data-slot="question-review-title"] { + font-size: 12px; + font-weight: 600; + color: var(--color-text-strong); + margin-bottom: 4px; +} + +[data-slot="question-review-item"] { + display: flex; + gap: 8px; + font-size: 12px; + padding: 4px 0; + border-bottom: 1px solid var(--color-border-weak-base); + + &:last-child { + border-bottom: none; + padding-bottom: 0; + } +} + +[data-slot="question-review-label"] { + color: var(--color-text-weak); + min-width: 80px; + flex-shrink: 0; +} + +[data-slot="question-review-value"] { + color: var(--color-text-strong); + flex: 1; + + &[data-empty="true"] { + color: var(--color-error); + font-style: italic; + } +} + +/* Question summary (after answered) */ +[data-component="question-summary"] { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 12px; +} + +[data-slot="question-summary-item"] { + display: flex; + flex-direction: column; + gap: 2px; +} + +[data-slot="question-summary-header"] { + font-size: 10px; + font-weight: 600; + color: var(--color-text-weak); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +[data-slot="question-summary-text"] { + font-size: 13px; + color: var(--color-text-strong); +} + +/* Custom checkbox for question options */ +[data-slot="question-checkbox"] { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: 2px solid var(--color-border-base); + border-radius: 3px; + flex-shrink: 0; + margin-top: 1px; + transition: all 0.15s ease; + color: var(--color-text-on-accent); + + &[data-checked="true"] { + background-color: var(--color-accent); + border-color: var(--color-accent); + } +} + +[data-slot="question-option"]:hover [data-slot="question-checkbox"] { + border-color: var(--color-text-weak); +} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index d59f5cfa3e3..86f9e18af50 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -11,6 +11,7 @@ import { type JSX, } from "solid-js" import { Dynamic } from "solid-js/web" +import { createStore } from "solid-js/store" import { AgentPart, AssistantMessage, @@ -22,6 +23,8 @@ import { ToolPart, UserMessage, Todo, + QuestionRequest, + QuestionAnswer, } from "@opencode-ai/sdk/v2" import { useData } from "../context" import { useDiffComponent } from "../context/diff" @@ -33,6 +36,7 @@ import { Button } from "./button" import { Card } from "./card" import { Icon } from "./icon" import { Checkbox } from "./checkbox" +import { TextField } from "./text-field" import { DiffChanges } from "./diff-changes" import { Markdown } from "./markdown" import { ImagePreview } from "./image-preview" @@ -449,6 +453,346 @@ export const ToolRegistry = { render: getTool, } +function QuestionPromptInline(props: { request: QuestionRequest }) { + const data = useData() + const questions = createMemo(() => props.request.questions ?? []) + const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) + + const [store, setStore] = createStore({ + tab: 0, + answers: [] as QuestionAnswer[], + custom: [] as string[], + focusedOption: 0, + }) + + let containerRef: HTMLDivElement | undefined + + // Auto-focus container when mounted + createEffect(() => { + if (containerRef) { + containerRef.focus() + } + }) + + const question = createMemo(() => questions()[store.tab]) + const confirm = createMemo(() => !single() && store.tab === questions().length) + const options = createMemo(() => question()?.options ?? []) + const input = createMemo(() => store.custom[store.tab] ?? "") + const multi = createMemo(() => question()?.multiple === true) + const totalOptions = createMemo(() => options().length + 1) // +1 for custom input + + // Get final answers for a question, including custom text if present + const getFinalAnswers = (index: number) => { + const selected = store.answers[index] ?? [] + const customText = store.custom[index]?.trim() + if (!customText) return selected + if (selected.includes(customText)) return selected + // For multi-select, add custom text to selections + // For single-select, custom text replaces selections (already handled in onInput) + const q = questions()[index] + if (q?.multiple) { + return [...selected, customText] + } + return customText ? [customText] : selected + } + + const submit = () => { + if (!data.respondToQuestion) return + const answers = questions().map((_: unknown, i: number) => getFinalAnswers(i)) + data.respondToQuestion({ + sessionID: props.request.sessionID, + questionID: props.request.id, + answers, + }) + } + + const reject = () => { + if (!data.rejectQuestion) return + data.rejectQuestion({ + sessionID: props.request.sessionID, + questionID: props.request.id, + }) + } + + const pick = (answer: string, custom: boolean = false) => { + const answers = [...store.answers] + answers[store.tab] = [answer] + setStore("answers", answers) + if (custom) { + const inputs = [...store.custom] + inputs[store.tab] = answer + setStore("custom", inputs) + } + if (single()) { + if (!data.respondToQuestion) return + data.respondToQuestion({ + sessionID: props.request.sessionID, + questionID: props.request.id, + answers: [[answer]], + }) + return + } + setStore("tab", store.tab + 1) + } + + const toggle = (answer: string) => { + const existing = store.answers[store.tab] ?? [] + const next = [...existing] + const index = next.indexOf(answer) + if (index === -1) next.push(answer) + if (index !== -1) next.splice(index, 1) + const answers = [...store.answers] + answers[store.tab] = next + setStore("answers", answers) + } + + const handleKeyDown = (e: KeyboardEvent) => { + // Don't handle if typing in text field + if ((e.target as HTMLElement).tagName === "INPUT") { + if (e.key === "Escape") { + containerRef?.focus() + } + return + } + + e.preventDefault() + + switch (e.key) { + case "ArrowUp": + if (!confirm()) { + setStore("focusedOption", Math.max(0, store.focusedOption - 1)) + } + break + case "ArrowDown": + if (!confirm()) { + setStore("focusedOption", Math.min(totalOptions() - 1, store.focusedOption + 1)) + } + break + case "ArrowLeft": + if (!single() && store.tab > 0) { + setStore("tab", store.tab - 1) + setStore("focusedOption", 0) + } + break + case "ArrowRight": + if (!single() && store.tab < questions().length) { + setStore("tab", store.tab + 1) + setStore("focusedOption", 0) + } + break + case "Enter": + if (confirm()) { + submit() + } else if (store.focusedOption < options().length) { + const opt = options()[store.focusedOption] + if (multi()) { + toggle(opt.label) + } else { + pick(opt.label) + } + } else { + // Focus the custom input + const inputEl = containerRef?.querySelector("input") + inputEl?.focus() + } + break + case "Escape": + reject() + break + } + } + + // Reset focused option and re-focus container when tab changes + createEffect(() => { + store.tab // track tab changes + setStore("focusedOption", 0) + containerRef?.focus() + }) + + return ( + <> +
+ +
+ + {(q, index) => { + const isActive = () => index() === store.tab + const isAnswered = () => getFinalAnswers(index()).length > 0 + return ( + + ) + }} + + +
+
+ + +
+
+ {question()?.question} + {multi() ? " (select all that apply)" : ""} +
+ +
+ + {(opt, index) => { + const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false + const focused = () => store.focusedOption === index() + return ( +
(multi() ? toggle(opt.label) : pick(opt.label))} + > + + +
+ +
+ } + > +
+ + + + + +
+
+
+
{opt.label}
+ +
{opt.description}
+
+
+
+ ) + }} +
+ +
+ { + const value = (e.currentTarget as HTMLInputElement).value + const inputs = [...store.custom] + inputs[store.tab] = value + setStore("custom", inputs) + // For single-select, typing clears other selections + if (!multi() && value.trim()) { + const answers = [...store.answers] + answers[store.tab] = [] + setStore("answers", answers) + } + }} + onKeyDown={(e: KeyboardEvent) => { + if (e.key === "Enter" && input().trim()) { + e.preventDefault() + const text = input().trim() + const answers = [...store.answers] + if (multi()) { + // For multi-select, add custom text to existing selections + const existing = answers[store.tab] ?? [] + if (!existing.includes(text)) { + answers[store.tab] = [...existing, text] + } + } else { + // For single-select, replace with custom text + answers[store.tab] = [text] + } + setStore("answers", answers) + if (single()) { + submit() + } else { + setStore("tab", store.tab + 1) + } + } + }} + /> +
+
+
+
+ + +
+
Review your answers
+ + {(q, index) => { + const value = () => getFinalAnswers(index()).join(", ") + const answered = () => Boolean(value()) + return ( +
+ {q.header}: + + {answered() ? value() : "(not answered)"} + +
+ ) + }} +
+
+
+
+ +
+
+ + 0}> + + + + + + + + + + +
+
+ + ) +} + PART_MAPPING["tool"] = function ToolPartDisplay(props) { const data = useData() const part = props.part as ToolPart @@ -460,7 +804,15 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { return next }) + const question = createMemo(() => { + const next = data.store.question?.[props.message.sessionID]?.[0] + if (!next || !next.tool) return undefined + if (next.tool!.callID !== part.callID) return undefined + return next + }) + const [showPermission, setShowPermission] = createSignal(false) + const [showQuestion, setShowQuestion] = createSignal(false) createEffect(() => { const perm = permission() @@ -472,9 +824,19 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { } }) + createEffect(() => { + const q = question() + if (q) { + const timeout = setTimeout(() => setShowQuestion(true), 50) + onCleanup(() => clearTimeout(timeout)) + } else { + setShowQuestion(false) + } + }) + const [forceOpen, setForceOpen] = createSignal(false) createEffect(() => { - if (permission()) setForceOpen(true) + if (permission() || question()) setForceOpen(true) }) const respond = (response: "once" | "always" | "reject") => { @@ -496,8 +858,10 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const render = ToolRegistry.render(part.tool) ?? GenericTool + const hasActivePrompt = () => showPermission() || showQuestion() + return ( -
+
{(error) => { @@ -539,8 +903,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { -
-
+
+
@@ -553,6 +917,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
+ {(q) => }
) } @@ -810,8 +1175,8 @@ ToolRegistry.register({ > {renderChildToolPart()} -
-
+
+
@@ -1027,3 +1392,39 @@ ToolRegistry.register({ ) }, }) + +ToolRegistry.register({ + name: "question", + render(props) { + const questions = createMemo(() => props.input.questions ?? []) + const answers = createMemo(() => props.metadata?.answers ?? []) + const hasAnswers = createMemo(() => answers().some((a: string[]) => a?.length > 0)) + + return ( + 1 ? `${questions().length} questions` : "", + }} + > + +
+ + {(q, i) => { + const answer = () => answers()[i()] + return ( +
+
{q.header}
+
{answer()?.length ? answer().join(", ") : "Unanswered"}
+
+ ) + }} +
+
+
+
+ ) + }, +}) diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index acab99fe8f6..4e9733c8677 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -1,4 +1,13 @@ -import type { Message, Session, Part, FileDiff, SessionStatus, PermissionRequest } from "@opencode-ai/sdk/v2" +import type { + Message, + Session, + Part, + FileDiff, + SessionStatus, + PermissionRequest, + QuestionRequest, + QuestionAnswer, +} from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -16,6 +25,9 @@ type Data = { permission?: { [sessionID: string]: PermissionRequest[] } + question?: { + [sessionID: string]: QuestionRequest[] + } message: { [sessionID: string]: Message[] } @@ -30,6 +42,10 @@ export type PermissionRespondFn = (input: { response: "once" | "always" | "reject" }) => void +export type QuestionRespondFn = (input: { sessionID: string; questionID: string; answers: QuestionAnswer[] }) => void + +export type QuestionRejectFn = (input: { sessionID: string; questionID: string }) => void + export type NavigateToSessionFn = (sessionID: string) => void export const { use: useData, provider: DataProvider } = createSimpleContext({ @@ -38,6 +54,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ data: Data directory: string onPermissionRespond?: PermissionRespondFn + onQuestionRespond?: QuestionRespondFn + onQuestionReject?: QuestionRejectFn onNavigateToSession?: NavigateToSessionFn }) => { return { @@ -48,6 +66,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return props.directory }, respondToPermission: props.onPermissionRespond, + respondToQuestion: props.onQuestionRespond, + rejectQuestion: props.onQuestionReject, navigateToSession: props.onNavigateToSession, } },