diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index ddac1f2286e..c11edd292d1 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" @@ -49,6 +50,9 @@ type State = { permission: { [sessionID: string]: PermissionRequest[] } + question: { + [sessionID: string]: QuestionRequest[] + } mcp: { [name: string]: McpStatus } @@ -98,6 +102,7 @@ function createGlobalSync() { session_diff: {}, todo: {}, permission: {}, + question: {}, mcp: {}, lsp: [], vcs: undefined, @@ -208,6 +213,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") }) @@ -396,6 +433,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..dca02489a8a 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() @@ -27,6 +28,11 @@ export default function Layout(props: ParentProps) { response: "once" | "always" | "reject" }) => sdk.client.permission.respond(input) + const replyToQuestion = (input: { requestID: string; answers: QuestionAnswer[] }) => + sdk.client.question.reply(input) + + const rejectQuestion = (input: { requestID: string }) => sdk.client.question.reject(input) + const navigateToSession = (sessionID: string) => { navigate(`/${params.dir}/session/${sessionID}`) } @@ -36,6 +42,8 @@ export default function Layout(props: ParentProps) { data={sync.data} directory={directory()} onPermissionRespond={respond} + onQuestionReply={replyToQuestion} + onQuestionReject={rejectQuestion} onNavigateToSession={navigateToSession} > {props.children} diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 82bf7f56328..24faed7f0e2 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -93,7 +93,7 @@ export namespace ToolRegistry { return [ InvalidTool, - ...(Flag.OPENCODE_CLIENT === "cli" ? [QuestionTool] : []), + ...(["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) ? [QuestionTool] : []), BashTool, ReadTool, GlobTool, diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 15b5d48671d..725a7d0d6e5 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -25,6 +25,7 @@ export interface BasicToolProps { hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean + locked?: boolean onSubtitleClick?: () => void } @@ -35,8 +36,13 @@ export function BasicTool(props: BasicToolProps) { if (props.forceOpen) setOpen(true) }) + const handleOpenChange = (value: boolean) => { + if (props.locked && !value) return + setOpen(value) + } + return ( - +
@@ -95,7 +101,7 @@ export function BasicTool(props: BasicToolProps) {
- + diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index b087b59e17d..71d33de318c 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -405,7 +405,8 @@ [data-component="tool-part-wrapper"] { width: 100%; - &[data-permission="true"] { + &[data-permission="true"], + &[data-question="true"] { position: sticky; top: calc(2px + var(--sticky-header-height, 40px)); bottom: 0px; @@ -490,3 +491,193 @@ justify-content: flex-end; } } + +[data-component="question-prompt"] { + display: flex; + flex-direction: column; + padding: 12px; + background-color: var(--surface-inset-base); + border-radius: 0 0 6px 6px; + gap: 12px; + + [data-slot="question-tabs"] { + display: flex; + gap: 4px; + flex-wrap: wrap; + + [data-slot="question-tab"] { + padding: 4px 12px; + font-size: 13px; + border-radius: 4px; + background-color: var(--surface-base); + color: var(--text-base); + border: none; + cursor: pointer; + transition: + color 0.15s, + background-color 0.15s; + + &:hover { + background-color: var(--surface-base-hover); + } + + &[data-active="true"] { + background-color: var(--surface-raised-base); + } + + &[data-answered="true"] { + color: var(--text-strong); + } + } + } + + [data-slot="question-content"] { + display: flex; + flex-direction: column; + gap: 8px; + + [data-slot="question-text"] { + font-size: 14px; + color: var(--text-base); + line-height: 1.5; + } + } + + [data-slot="question-options"] { + display: flex; + flex-direction: column; + gap: 4px; + + [data-slot="question-option"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + padding: 8px 12px; + background-color: var(--surface-base); + border: 1px solid var(--border-weaker-base); + border-radius: 6px; + cursor: pointer; + text-align: left; + width: 100%; + transition: + background-color 0.15s, + border-color 0.15s; + position: relative; + + &:hover { + background-color: var(--surface-base-hover); + border-color: var(--border-default); + } + + &[data-picked="true"] { + [data-component="icon"] { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + color: var(--text-strong); + } + } + + [data-slot="option-label"] { + font-size: 14px; + color: var(--text-base); + font-weight: 500; + } + + [data-slot="option-description"] { + font-size: 12px; + color: var(--text-weak); + } + } + + [data-slot="custom-input-form"] { + display: flex; + gap: 8px; + padding: 8px 0; + align-items: stretch; + + [data-slot="custom-input"] { + flex: 1; + padding: 8px 12px; + font-size: 14px; + border: 1px solid var(--border-default); + border-radius: 6px; + background-color: var(--surface-base); + color: var(--text-base); + outline: none; + + &:focus { + border-color: var(--border-focus); + } + + &::placeholder { + color: var(--text-weak); + } + } + + [data-component="button"] { + height: auto; + } + } + } + + [data-slot="question-review"] { + display: flex; + flex-direction: column; + gap: 12px; + + [data-slot="review-title"] { + display: none; + } + + [data-slot="review-item"] { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 13px; + + [data-slot="review-label"] { + color: var(--text-weak); + } + + [data-slot="review-value"] { + color: var(--text-strong); + + &[data-answered="false"] { + color: var(--text-weak); + } + } + } + } + + [data-slot="question-actions"] { + display: flex; + align-items: center; + gap: 8px; + justify-content: flex-end; + } +} + +[data-component="question-answers"] { + display: flex; + flex-direction: column; + gap: 12px; + padding: 8px 12px; + + [data-slot="question-answer-item"] { + display: flex; + flex-direction: column; + gap: 2px; + font-size: 13px; + + [data-slot="question-text"] { + color: var(--text-weak); + } + + [data-slot="answer-text"] { + color: var(--text-strong); + } + } +} diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 71ff37161fa..e1a34a3241a 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -22,7 +22,11 @@ import { ToolPart, UserMessage, Todo, + QuestionRequest, + QuestionAnswer, + QuestionInfo, } from "@opencode-ai/sdk/v2" +import { createStore } from "solid-js/store" import { useData } from "../context" import { useDiffComponent } from "../context/diff" import { useCodeComponent } from "../context/code" @@ -238,6 +242,11 @@ export function getToolInfo(tool: string, input: any = {}): ToolInfo { icon: "checklist", title: "Read to-dos", } + case "question": + return { + icon: "bubble-5", + title: "Questions", + } default: return { icon: "mcp", @@ -438,6 +447,7 @@ export interface ToolProps { hideDetails?: boolean defaultOpen?: boolean forceOpen?: boolean + locked?: boolean } export type ToolComponent = Component @@ -475,7 +485,15 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { return next }) + const questionRequest = 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() @@ -487,9 +505,19 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { } }) + createEffect(() => { + const question = questionRequest() + if (question) { + const timeout = setTimeout(() => setShowQuestion(true), 50) + onCleanup(() => clearTimeout(timeout)) + } else { + setShowQuestion(false) + } + }) + const [forceOpen, setForceOpen] = createSignal(false) createEffect(() => { - if (permission()) setForceOpen(true) + if (permission() || questionRequest()) setForceOpen(true) }) const respond = (response: "once" | "always" | "reject") => { @@ -512,7 +540,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const render = ToolRegistry.render(part.tool) ?? GenericTool return ( -
+
{(error) => { @@ -549,6 +577,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { status={part.state.status} hideDetails={props.hideDetails} forceOpen={forceOpen()} + locked={showPermission() || showQuestion()} defaultOpen={props.defaultOpen} /> @@ -568,6 +597,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
+ {(request) => } ) } @@ -1042,3 +1072,288 @@ ToolRegistry.register({ ) }, }) + +ToolRegistry.register({ + name: "question", + render(props) { + const questions = createMemo(() => (props.input.questions ?? []) as QuestionInfo[]) + const answers = createMemo(() => (props.metadata.answers ?? []) as QuestionAnswer[]) + const completed = createMemo(() => answers().length > 0) + + const subtitle = createMemo(() => { + const count = questions().length + if (count === 0) return "" + if (completed()) return `${count} answered` + return `${count} question${count > 1 ? "s" : ""}` + }) + + return ( + + +
+ + {(q, i) => { + const answer = () => answers()[i()] ?? [] + return ( +
+
{q.question}
+
{answer().join(", ") || "(no answer)"}
+
+ ) + }} +
+
+
+
+ ) + }, +}) + +function QuestionPrompt(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[], + editing: false, + }) + + 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 customPicked = createMemo(() => { + const value = input() + if (!value) return false + return store.answers[store.tab]?.includes(value) ?? false + }) + + function submit() { + const answers = questions().map((_, i) => store.answers[i] ?? []) + data.replyToQuestion?.({ + requestID: props.request.id, + answers, + }) + } + + function reject() { + data.rejectQuestion?.({ + requestID: props.request.id, + }) + } + + function 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()) { + data.replyToQuestion?.({ + requestID: props.request.id, + answers: [[answer]], + }) + return + } + setStore("tab", store.tab + 1) + } + + function 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) + } + + function selectTab(index: number) { + setStore("tab", index) + setStore("editing", false) + } + + function selectOption(optIndex: number) { + if (optIndex === options().length) { + setStore("editing", true) + return + } + const opt = options()[optIndex] + if (!opt) return + if (multi()) { + toggle(opt.label) + return + } + pick(opt.label) + } + + function handleCustomSubmit(e: Event) { + e.preventDefault() + const value = input().trim() + if (!value) { + setStore("editing", false) + return + } + if (multi()) { + const existing = store.answers[store.tab] ?? [] + const next = [...existing] + if (!next.includes(value)) next.push(value) + const answers = [...store.answers] + answers[store.tab] = next + setStore("answers", answers) + setStore("editing", false) + return + } + pick(value, true) + setStore("editing", false) + } + + return ( +
+ +
+ + {(q, index) => { + const active = () => index() === store.tab + const answered = () => (store.answers[index()]?.length ?? 0) > 0 + return ( + + ) + }} + + +
+
+ + +
+
+ {question()?.question} + {multi() ? " (select all that apply)" : ""} +
+
+ + {(opt, i) => { + const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false + return ( + + ) + }} + + + +
+ setTimeout(() => el.focus(), 0)} + type="text" + data-slot="custom-input" + placeholder="Type your answer..." + value={input()} + onInput={(e) => { + const inputs = [...store.custom] + inputs[store.tab] = e.currentTarget.value + setStore("custom", inputs) + }} + /> + + +
+
+
+
+
+ + +
+
Review your answers
+ + {(q, index) => { + const value = () => store.answers[index()]?.join(", ") ?? "" + const answered = () => Boolean(value()) + return ( +
+ {q.question} + + {answered() ? value() : "(not answered)"} + +
+ ) + }} +
+
+
+ +
+ + + + + + + + + +
+
+ ) +} diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index acab99fe8f6..dcb9adb39c8 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 QuestionReplyFn = (input: { requestID: string; answers: QuestionAnswer[] }) => void + +export type QuestionRejectFn = (input: { requestID: 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 + onQuestionReply?: QuestionReplyFn + onQuestionReject?: QuestionRejectFn onNavigateToSession?: NavigateToSessionFn }) => { return { @@ -48,6 +66,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return props.directory }, respondToPermission: props.onPermissionRespond, + replyToQuestion: props.onQuestionReply, + rejectQuestion: props.onQuestionReject, navigateToSession: props.onNavigateToSession, } },