mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
feat(askquestion): fix race condition and improve TUI UX
This commit is contained in:
parent
c6e9a5c800
commit
3a23fec31e
10 changed files with 873 additions and 12 deletions
161
packages/opencode/src/askquestion/index.ts
Normal file
161
packages/opencode/src/askquestion/index.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { BusEvent } from "@/bus/bus-event"
|
||||
import z from "zod"
|
||||
|
||||
export namespace AskQuestion {
|
||||
/**
|
||||
* Schema for a single question option
|
||||
*/
|
||||
export const OptionSchema = z.object({
|
||||
value: z.string().describe("Short identifier for the option"),
|
||||
label: z.string().describe("Display label for the option"),
|
||||
description: z.string().optional().describe("Additional context for the option"),
|
||||
})
|
||||
export type Option = z.infer<typeof OptionSchema>
|
||||
|
||||
/**
|
||||
* Schema for a single question in the wizard
|
||||
*/
|
||||
export const QuestionSchema = z.object({
|
||||
id: z.string().describe("Unique identifier for the question"),
|
||||
label: z.string().describe("Short tab label, e.g. 'UI Framework'"),
|
||||
question: z.string().describe("The full question to ask the user"),
|
||||
options: z.array(OptionSchema).min(2).max(8).describe("2-8 suggested answer options"),
|
||||
multiSelect: z.boolean().optional().describe("Allow selecting multiple options"),
|
||||
})
|
||||
export type Question = z.infer<typeof QuestionSchema>
|
||||
|
||||
/**
|
||||
* Schema for a single answer from the user
|
||||
*/
|
||||
export const AnswerSchema = z.object({
|
||||
questionId: z.string().describe("ID of the question being answered"),
|
||||
values: z.array(z.string()).describe("Selected option value(s)"),
|
||||
customText: z.string().optional().describe("Custom text if user typed their own response"),
|
||||
})
|
||||
export type Answer = z.infer<typeof AnswerSchema>
|
||||
|
||||
/**
|
||||
* Bus events for askquestion flow
|
||||
*/
|
||||
export const Event = {
|
||||
/**
|
||||
* Published by the askquestion tool when it needs user input
|
||||
*/
|
||||
Requested: BusEvent.define(
|
||||
"askquestion.requested",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
messageID: z.string(),
|
||||
callID: z.string(),
|
||||
questions: z.array(QuestionSchema),
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Published by the TUI when user submits answers
|
||||
*/
|
||||
Answered: BusEvent.define(
|
||||
"askquestion.answered",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
callID: z.string(),
|
||||
answers: z.array(AnswerSchema),
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Published when user cancels the question wizard
|
||||
*/
|
||||
Cancelled: BusEvent.define(
|
||||
"askquestion.cancelled",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
callID: z.string(),
|
||||
}),
|
||||
),
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending askquestion requests waiting for user response
|
||||
*/
|
||||
interface PendingRequest {
|
||||
sessionID: string
|
||||
messageID: string
|
||||
callID: string
|
||||
questions: Question[]
|
||||
resolve: (answers: Answer[]) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
// Global map of pending requests by callID
|
||||
const pendingRequests = new Map<string, PendingRequest>()
|
||||
|
||||
/**
|
||||
* Register a pending askquestion request
|
||||
*/
|
||||
export function register(
|
||||
callID: string,
|
||||
sessionID: string,
|
||||
messageID: string,
|
||||
questions: Question[],
|
||||
): Promise<Answer[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingRequests.set(callID, {
|
||||
sessionID,
|
||||
messageID,
|
||||
callID,
|
||||
questions,
|
||||
resolve,
|
||||
reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a pending request
|
||||
*/
|
||||
export function get(callID: string): PendingRequest | undefined {
|
||||
return pendingRequests.get(callID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pending requests for a session
|
||||
*/
|
||||
export function getForSession(sessionID: string): PendingRequest[] {
|
||||
return Array.from(pendingRequests.values()).filter((r) => r.sessionID === sessionID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Respond to a pending askquestion request
|
||||
*/
|
||||
export function respond(callID: string, answers: Answer[]): boolean {
|
||||
const pending = pendingRequests.get(callID)
|
||||
if (!pending) return false
|
||||
pending.resolve(answers)
|
||||
pendingRequests.delete(callID)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a pending askquestion request
|
||||
*/
|
||||
export function cancel(callID: string): boolean {
|
||||
const pending = pendingRequests.get(callID)
|
||||
if (!pending) return false
|
||||
pending.reject(new Error("User cancelled the question wizard"))
|
||||
pendingRequests.delete(callID)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up pending requests for a session (e.g., on abort)
|
||||
*/
|
||||
export function cleanup(sessionID: string): void {
|
||||
for (const [callID, request] of pendingRequests) {
|
||||
if (request.sessionID === sessionID) {
|
||||
request.reject(new Error("Session aborted"))
|
||||
pendingRequests.delete(callID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -67,6 +67,8 @@ import { Footer } from "./footer.tsx"
|
|||
import { usePromptRef } from "../../context/prompt"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { DialogSubagent } from "./dialog-subagent.tsx"
|
||||
import { DialogAskQuestion } from "../../ui/dialog-askquestion.tsx"
|
||||
import type { AskQuestion } from "@/askquestion"
|
||||
|
||||
addDefaultParsers(parsers.parsers)
|
||||
|
||||
|
|
@ -197,6 +199,38 @@ export function Session() {
|
|||
}
|
||||
})
|
||||
|
||||
// Detect pending askquestion tools from synced message parts
|
||||
// Access via session.messages -> parts for proper Solid.js reactivity
|
||||
const pendingAskQuestionFromSync = createMemo(() => {
|
||||
const sessionMessages = sync.data.message[route.sessionID] ?? []
|
||||
|
||||
for (const message of sessionMessages) {
|
||||
const parts = sync.data.part[message.id] ?? []
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type !== "tool") continue
|
||||
const toolPart = part as ToolPart
|
||||
|
||||
if (toolPart.tool !== "askquestion") continue
|
||||
if (toolPart.state.status !== "running") continue
|
||||
|
||||
const metadata = toolPart.state.metadata as
|
||||
| { status?: string; questions?: AskQuestion.Question[] }
|
||||
| undefined
|
||||
|
||||
if (metadata?.status !== "waiting") continue
|
||||
|
||||
return {
|
||||
callID: toolPart.callID,
|
||||
messageId: toolPart.messageID,
|
||||
questions: (metadata.questions ?? []) as AskQuestion.Question[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
let scroll: ScrollBoxRenderable
|
||||
let prompt: PromptRef
|
||||
const keybind = useKeybind()
|
||||
|
|
@ -1000,17 +1034,59 @@ export function Session() {
|
|||
</For>
|
||||
</scrollbox>
|
||||
<box flexShrink={0}>
|
||||
<Prompt
|
||||
ref={(r) => {
|
||||
prompt = r
|
||||
promptRef.set(r)
|
||||
}}
|
||||
disabled={permissions().length > 0}
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
}}
|
||||
sessionID={route.sessionID}
|
||||
/>
|
||||
<Switch>
|
||||
<Match when={pendingAskQuestionFromSync()}>
|
||||
{(pending) => (
|
||||
<DialogAskQuestion
|
||||
questions={pending().questions}
|
||||
onSubmit={async (answers) => {
|
||||
await fetch(`${sdk.url}/askquestion/respond`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
callID: pending().callID,
|
||||
sessionID: route.sessionID,
|
||||
answers,
|
||||
}),
|
||||
}).catch(() => {
|
||||
toast.show({
|
||||
message: "Failed to submit answers",
|
||||
variant: "error",
|
||||
})
|
||||
})
|
||||
}}
|
||||
onCancel={async () => {
|
||||
await fetch(`${sdk.url}/askquestion/cancel`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
callID: pending().callID,
|
||||
sessionID: route.sessionID,
|
||||
}),
|
||||
}).catch(() => {
|
||||
toast.show({
|
||||
message: "Failed to cancel",
|
||||
variant: "error",
|
||||
})
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={!pendingAskQuestionFromSync()}>
|
||||
<Prompt
|
||||
ref={(r) => {
|
||||
prompt = r
|
||||
promptRef.set(r)
|
||||
}}
|
||||
disabled={permissions().length > 0}
|
||||
onSubmit={() => {
|
||||
toBottom()
|
||||
}}
|
||||
sessionID={route.sessionID}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<Show when={!sidebarVisible()}>
|
||||
<Footer />
|
||||
|
|
|
|||
413
packages/opencode/src/cli/cmd/tui/ui/dialog-askquestion.tsx
Normal file
413
packages/opencode/src/cli/cmd/tui/ui/dialog-askquestion.tsx
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
import { InputRenderable, ScrollBoxRenderable, TextAttributes, RGBA } from "@opentui/core"
|
||||
import { useTheme, selectedForeground } from "@tui/context/theme"
|
||||
import { batch, createEffect, createMemo, For, Show, on, type JSX } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||
import type { AskQuestion } from "@/askquestion"
|
||||
|
||||
export interface DialogAskQuestionProps {
|
||||
questions: AskQuestion.Question[]
|
||||
onSubmit: (answers: AskQuestion.Answer[]) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
interface QuestionState {
|
||||
selectedOption: number
|
||||
selectedValues: string[]
|
||||
customText?: string
|
||||
}
|
||||
|
||||
export function DialogAskQuestion(props: DialogAskQuestionProps) {
|
||||
const { theme } = useTheme()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const fg = selectedForeground(theme)
|
||||
|
||||
// State for the wizard
|
||||
const [store, setStore] = createStore({
|
||||
activeTab: 0,
|
||||
questionStates: props.questions.map(() => ({
|
||||
selectedOption: 0,
|
||||
selectedValues: [] as string[],
|
||||
customText: undefined as string | undefined,
|
||||
})) as QuestionState[],
|
||||
isTypingCustom: false,
|
||||
customInputValue: "",
|
||||
})
|
||||
|
||||
// Current question based on active tab
|
||||
const currentQuestion = createMemo(() => props.questions[store.activeTab])
|
||||
const currentState = createMemo(() => store.questionStates[store.activeTab])
|
||||
|
||||
// Options including "Type something..." at the end
|
||||
const optionsWithCustom = createMemo(() => [
|
||||
...currentQuestion().options,
|
||||
{ value: "__custom__", label: "Type something.", description: "Enter your own response" },
|
||||
])
|
||||
|
||||
// Check if all questions have at least one answer
|
||||
const allAnswered = createMemo(() =>
|
||||
store.questionStates.every((state) => state.selectedValues.length > 0 || state.customText),
|
||||
)
|
||||
|
||||
// Check if current question is answered
|
||||
const currentAnswered = createMemo(() => {
|
||||
const state = currentState()
|
||||
return state.selectedValues.length > 0 || state.customText
|
||||
})
|
||||
|
||||
let scrollRef: ScrollBoxRenderable
|
||||
let inputRef: InputRenderable
|
||||
|
||||
// Handle keyboard navigation
|
||||
useKeyboard((evt) => {
|
||||
if (store.isTypingCustom) {
|
||||
// In custom input mode
|
||||
if (evt.name === "escape") {
|
||||
setStore("isTypingCustom", false)
|
||||
setStore("customInputValue", "")
|
||||
} else if (evt.name === "return") {
|
||||
const value = store.customInputValue.trim()
|
||||
if (value) {
|
||||
setStore(
|
||||
produce((s) => {
|
||||
s.questionStates[s.activeTab].customText = value
|
||||
s.questionStates[s.activeTab].selectedValues = []
|
||||
}),
|
||||
)
|
||||
}
|
||||
setStore("isTypingCustom", false)
|
||||
setStore("customInputValue", "")
|
||||
// Auto-advance to next question or submit
|
||||
if (store.activeTab < props.questions.length - 1) {
|
||||
setStore("activeTab", store.activeTab + 1)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Tab/arrow navigation between questions
|
||||
if (evt.name === "tab" || evt.name === "right") {
|
||||
if (store.activeTab < props.questions.length - 1) {
|
||||
setStore("activeTab", store.activeTab + 1)
|
||||
} else if (allAnswered()) {
|
||||
// Submit when on last tab and all answered
|
||||
handleSubmit()
|
||||
}
|
||||
} else if (evt.shift && evt.name === "tab") {
|
||||
if (store.activeTab > 0) {
|
||||
setStore("activeTab", store.activeTab - 1)
|
||||
}
|
||||
} else if (evt.name === "left") {
|
||||
if (store.activeTab > 0) {
|
||||
setStore("activeTab", store.activeTab - 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Up/down navigation within options
|
||||
else if (evt.name === "up" || (evt.ctrl && evt.name === "p")) {
|
||||
const current = currentState().selectedOption
|
||||
const max = optionsWithCustom().length - 1
|
||||
setStore(
|
||||
produce((s) => {
|
||||
s.questionStates[s.activeTab].selectedOption = current > 0 ? current - 1 : max
|
||||
}),
|
||||
)
|
||||
} else if (evt.name === "down" || (evt.ctrl && evt.name === "n")) {
|
||||
const current = currentState().selectedOption
|
||||
const max = optionsWithCustom().length - 1
|
||||
setStore(
|
||||
produce((s) => {
|
||||
s.questionStates[s.activeTab].selectedOption = current < max ? current + 1 : 0
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Space to toggle selection (especially useful for multi-select)
|
||||
else if (evt.name === "space") {
|
||||
const selectedIdx = currentState().selectedOption
|
||||
const option = optionsWithCustom()[selectedIdx]
|
||||
|
||||
if (option.value === "__custom__") {
|
||||
// Open custom input
|
||||
setStore("isTypingCustom", true)
|
||||
setTimeout(() => inputRef?.focus(), 10)
|
||||
} else {
|
||||
const question = currentQuestion()
|
||||
setStore(
|
||||
produce((s) => {
|
||||
const state = s.questionStates[s.activeTab]
|
||||
state.customText = undefined
|
||||
|
||||
if (question.multiSelect) {
|
||||
// Toggle for multi-select
|
||||
const idx = state.selectedValues.indexOf(option.value)
|
||||
if (idx >= 0) {
|
||||
state.selectedValues.splice(idx, 1)
|
||||
} else {
|
||||
state.selectedValues.push(option.value)
|
||||
}
|
||||
} else {
|
||||
// Select for single-select (same as Enter)
|
||||
state.selectedValues = [option.value]
|
||||
if (s.activeTab < props.questions.length - 1) {
|
||||
s.activeTab++
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
// Auto-submit if single-select on last question
|
||||
if (!currentQuestion().multiSelect) {
|
||||
setTimeout(() => {
|
||||
if (allAnswered()) {
|
||||
handleSubmit()
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enter to select option (single-select) or confirm and advance (multi-select)
|
||||
else if (evt.name === "return") {
|
||||
const selectedIdx = currentState().selectedOption
|
||||
const option = optionsWithCustom()[selectedIdx]
|
||||
const question = currentQuestion()
|
||||
|
||||
if (option.value === "__custom__") {
|
||||
// Open custom input
|
||||
setStore("isTypingCustom", true)
|
||||
setTimeout(() => inputRef?.focus(), 10)
|
||||
} else if (question.multiSelect) {
|
||||
// For multi-select: Enter confirms current selections and advances
|
||||
if (currentAnswered()) {
|
||||
if (store.activeTab < props.questions.length - 1) {
|
||||
setStore("activeTab", store.activeTab + 1)
|
||||
} else if (allAnswered()) {
|
||||
handleSubmit()
|
||||
}
|
||||
} else {
|
||||
// If nothing selected yet, toggle the current option
|
||||
setStore(
|
||||
produce((s) => {
|
||||
const state = s.questionStates[s.activeTab]
|
||||
state.customText = undefined
|
||||
state.selectedValues.push(option.value)
|
||||
}),
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Single-select: select and advance
|
||||
setStore(
|
||||
produce((s) => {
|
||||
const state = s.questionStates[s.activeTab]
|
||||
state.customText = undefined
|
||||
state.selectedValues = [option.value]
|
||||
if (s.activeTab < props.questions.length - 1) {
|
||||
s.activeTab++
|
||||
}
|
||||
}),
|
||||
)
|
||||
// Auto-submit if this was the last question
|
||||
setTimeout(() => {
|
||||
if (allAnswered()) {
|
||||
handleSubmit()
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
// Number keys for quick selection (1-5)
|
||||
else if (evt.name >= "1" && evt.name <= "5") {
|
||||
const idx = parseInt(evt.name) - 1
|
||||
if (idx < currentQuestion().options.length) {
|
||||
const option = currentQuestion().options[idx]
|
||||
setStore(
|
||||
produce((s) => {
|
||||
const state = s.questionStates[s.activeTab]
|
||||
state.customText = undefined
|
||||
if (currentQuestion().multiSelect) {
|
||||
const existingIdx = state.selectedValues.indexOf(option.value)
|
||||
if (existingIdx >= 0) {
|
||||
state.selectedValues.splice(existingIdx, 1)
|
||||
} else {
|
||||
state.selectedValues.push(option.value)
|
||||
}
|
||||
} else {
|
||||
state.selectedValues = [option.value]
|
||||
if (s.activeTab < props.questions.length - 1) {
|
||||
s.activeTab++
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Escape to cancel
|
||||
else if (evt.name === "escape") {
|
||||
props.onCancel()
|
||||
}
|
||||
|
||||
// Ctrl+Enter to submit
|
||||
else if (evt.ctrl && evt.name === "return") {
|
||||
if (allAnswered()) {
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
const answers: AskQuestion.Answer[] = props.questions.map((q, i) => {
|
||||
const state = store.questionStates[i]
|
||||
return {
|
||||
questionId: q.id,
|
||||
values: state.selectedValues,
|
||||
customText: state.customText,
|
||||
}
|
||||
})
|
||||
props.onSubmit(answers)
|
||||
}
|
||||
|
||||
const height = createMemo(() => Math.min(15, Math.floor(dimensions().height / 2)))
|
||||
|
||||
return (
|
||||
<box flexDirection="column" gap={1}>
|
||||
{/* Tab bar */}
|
||||
<box flexDirection="row" paddingLeft={2} paddingRight={2} gap={2}>
|
||||
<text fg={theme.textMuted}>←</text>
|
||||
<For each={props.questions}>
|
||||
{(question, index) => {
|
||||
const isActive = createMemo(() => store.activeTab === index())
|
||||
const isAnswered = createMemo(() => {
|
||||
const state = store.questionStates[index()]
|
||||
return state.selectedValues.length > 0 || state.customText
|
||||
})
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
paddingRight={1}
|
||||
onMouseUp={() => setStore("activeTab", index())}
|
||||
>
|
||||
<text fg={isAnswered() ? theme.success : theme.textMuted}>
|
||||
{isAnswered() ? "●" : "○"}
|
||||
</text>
|
||||
<text
|
||||
fg={isActive() ? theme.text : theme.textMuted}
|
||||
attributes={isActive() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{question.label}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={allAnswered()}>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.success}>✓</text>
|
||||
<text
|
||||
fg={theme.success}
|
||||
attributes={TextAttributes.BOLD}
|
||||
onMouseUp={handleSubmit}
|
||||
>
|
||||
Submit
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<text fg={theme.textMuted}>→</text>
|
||||
</box>
|
||||
|
||||
{/* Current question */}
|
||||
<box paddingLeft={2} paddingRight={2} flexDirection="column" gap={0}>
|
||||
<text fg={theme.primary} attributes={TextAttributes.BOLD}>
|
||||
{currentQuestion().question}
|
||||
</text>
|
||||
<Show when={currentQuestion().multiSelect}>
|
||||
<text fg={theme.textMuted}>(select multiple, press Enter to confirm)</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
{/* Options */}
|
||||
<scrollbox
|
||||
ref={(r: ScrollBoxRenderable) => (scrollRef = r)}
|
||||
maxHeight={height()}
|
||||
paddingLeft={2}
|
||||
paddingRight={2}
|
||||
scrollbarOptions={{ visible: false }}
|
||||
>
|
||||
<For each={optionsWithCustom()}>
|
||||
{(option, index) => {
|
||||
const isSelected = createMemo(() => currentState().selectedOption === index())
|
||||
const isChosen = createMemo(() => {
|
||||
if (option.value === "__custom__") {
|
||||
return !!currentState().customText
|
||||
}
|
||||
return currentState().selectedValues.includes(option.value)
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
backgroundColor={isSelected() ? theme.primary : RGBA.fromInts(0, 0, 0, 0)}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
gap={1}
|
||||
onMouseUp={() => {
|
||||
setStore(
|
||||
produce((s) => {
|
||||
s.questionStates[s.activeTab].selectedOption = index()
|
||||
}),
|
||||
)
|
||||
}}
|
||||
>
|
||||
{/* Use different icons for single vs multi select */}
|
||||
<text fg={isSelected() ? fg : theme.textMuted} flexShrink={0}>
|
||||
{option.value === "__custom__"
|
||||
? "›"
|
||||
: currentQuestion().multiSelect
|
||||
? (isChosen() ? "[✓]" : "[ ]")
|
||||
: (isChosen() ? "●" : "○")
|
||||
}
|
||||
</text>
|
||||
<text
|
||||
fg={isSelected() ? fg : isChosen() ? theme.success : theme.text}
|
||||
attributes={isChosen() ? TextAttributes.BOLD : undefined}
|
||||
>
|
||||
{option.label}
|
||||
</text>
|
||||
<Show when={option.description && option.value !== "__custom__"}>
|
||||
<text fg={isSelected() ? fg : theme.textMuted}>
|
||||
{option.description}
|
||||
</text>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</scrollbox>
|
||||
|
||||
{/* Custom input (when active) */}
|
||||
<Show when={store.isTypingCustom}>
|
||||
<box paddingLeft={2} paddingRight={2}>
|
||||
<input
|
||||
ref={(r) => (inputRef = r)}
|
||||
placeholder="Type your response..."
|
||||
cursorColor={theme.primary}
|
||||
focusedTextColor={theme.text}
|
||||
focusedBackgroundColor={theme.backgroundPanel}
|
||||
onInput={(value) => setStore("customInputValue", value)}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
{/* Instructions */}
|
||||
<box paddingLeft={2} paddingRight={2} paddingTop={1}>
|
||||
<text fg={theme.textMuted}>
|
||||
{currentQuestion().multiSelect
|
||||
? "Space to toggle · Enter to confirm · ↑↓ to navigate · Esc to cancel"
|
||||
: "Enter/Space to select · ↑↓ to navigate · Esc to cancel"
|
||||
}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
@ -47,6 +47,7 @@ import { SessionStatus } from "@/session/status"
|
|||
import { upgradeWebSocket, websocket } from "hono/bun"
|
||||
import { errors } from "./error"
|
||||
import { Pty } from "@/pty"
|
||||
import { AskQuestion } from "@/askquestion"
|
||||
|
||||
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||
|
|
@ -1506,6 +1507,76 @@ export namespace Server {
|
|||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/askquestion/respond",
|
||||
describeRoute({
|
||||
summary: "Respond to askquestion",
|
||||
description: "Submit answers to a pending askquestion tool call.",
|
||||
operationId: "askquestion.respond",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Response submitted successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
callID: z.string(),
|
||||
answers: AskQuestion.AnswerSchema.array(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { callID, answers } = c.req.valid("json")
|
||||
// The partID is the callID for the askquestion
|
||||
const success = AskQuestion.respond(callID, answers)
|
||||
if (!success) {
|
||||
throw new Error("No pending askquestion found with this ID")
|
||||
}
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"/askquestion/cancel",
|
||||
describeRoute({
|
||||
summary: "Cancel askquestion",
|
||||
description: "Cancel a pending askquestion tool call.",
|
||||
operationId: "askquestion.cancel",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Cancelled successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(z.boolean()),
|
||||
},
|
||||
},
|
||||
},
|
||||
...errors(400, 404),
|
||||
},
|
||||
}),
|
||||
validator(
|
||||
"json",
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
callID: z.string(),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { callID } = c.req.valid("json")
|
||||
const success = AskQuestion.cancel(callID)
|
||||
if (!success) {
|
||||
throw new Error("No pending askquestion found with this ID")
|
||||
}
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"/command",
|
||||
describeRoute({
|
||||
|
|
|
|||
|
|
@ -371,6 +371,7 @@ export namespace Session {
|
|||
export const updatePart = fn(UpdatePartInput, async (input) => {
|
||||
const part = "delta" in input ? input.part : input
|
||||
const delta = "delta" in input ? input.delta : undefined
|
||||
|
||||
await Storage.write(["part", part.messageID, part.id], part)
|
||||
Bus.publish(MessageV2.Event.PartUpdated, {
|
||||
part,
|
||||
|
|
|
|||
|
|
@ -614,7 +614,19 @@ export namespace SessionPrompt {
|
|||
extra: { model: input.model },
|
||||
agent: input.agent.name,
|
||||
metadata: async (val) => {
|
||||
const match = input.processor.partFromToolCall(options.toolCallId)
|
||||
// Wait for the part to be in "running" state with retries
|
||||
// This handles the race condition where execute() is called before
|
||||
// the tool-call event has been processed
|
||||
let match = input.processor.partFromToolCall(options.toolCallId)
|
||||
let retries = 0
|
||||
const maxRetries = 20 // 20 * 50ms = 1 second max wait
|
||||
|
||||
while ((!match || match.state.status !== "running") && retries < maxRetries) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
match = input.processor.partFromToolCall(options.toolCallId)
|
||||
retries++
|
||||
}
|
||||
|
||||
if (match && match.state.status === "running") {
|
||||
await Session.updatePart({
|
||||
...match,
|
||||
|
|
@ -628,6 +640,11 @@ export namespace SessionPrompt {
|
|||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
log.warn("metadata: part not found or not running after waiting", {
|
||||
toolCallId: options.toolCallId,
|
||||
retries,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,6 +14,14 @@ is a critical violation. ZERO exceptions.
|
|||
|
||||
Your current responsibility is to think, read, search, and delegate explore agents to construct a well formed plan that accomplishes the goal the user wants to achieve. Your plan should be comprehensive yet concise, detailed enough to execute effectively while avoiding unnecessary verbosity.
|
||||
|
||||
## Clarifying Questions
|
||||
|
||||
Use the `askquestion` tool to gather requirements before finalizing your plan. This tool presents a wizard-style interface with multiple questions. Generate descriptive tab labels like "UI Framework", "Database", "Auth Strategy". Questions should help you understand:
|
||||
- Technology preferences and constraints
|
||||
- Scale and performance requirements
|
||||
- Existing integrations or dependencies
|
||||
- Priority tradeoffs when multiple approaches exist
|
||||
|
||||
Ask the user clarifying questions or ask for their opinion when weighing tradeoffs.
|
||||
|
||||
**NOTE:** At any point in time through this workflow you should feel free to ask the user questions or clarifications. Don't make large assumptions about user intent. The goal is to present a well researched plan to the user, and tie any loose ends before implementation begins.
|
||||
|
|
|
|||
80
packages/opencode/src/tool/askquestion.ts
Normal file
80
packages/opencode/src/tool/askquestion.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { Tool } from "./tool"
|
||||
import DESCRIPTION from "./askquestion.txt"
|
||||
import z from "zod"
|
||||
import { AskQuestion } from "../askquestion"
|
||||
import { defer } from "@/util/defer"
|
||||
|
||||
export const AskQuestionTool = Tool.define(
|
||||
"askquestion",
|
||||
{
|
||||
description: DESCRIPTION,
|
||||
parameters: z.object({
|
||||
questions: z
|
||||
.array(AskQuestion.QuestionSchema)
|
||||
.min(1)
|
||||
.max(6)
|
||||
.describe("1-6 questions to ask in wizard flow"),
|
||||
}),
|
||||
|
||||
async execute(params, ctx) {
|
||||
// Update the tool metadata to show what we're waiting for
|
||||
// IMPORTANT: Must await to ensure Part is synced before we block waiting for response
|
||||
await ctx.metadata({
|
||||
title: `Asking ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
|
||||
metadata: {
|
||||
questions: params.questions,
|
||||
status: "waiting",
|
||||
},
|
||||
})
|
||||
|
||||
// Register the pending request and wait for response
|
||||
const answerPromise = AskQuestion.register(
|
||||
ctx.callID!,
|
||||
ctx.sessionID,
|
||||
ctx.messageID,
|
||||
params.questions,
|
||||
)
|
||||
|
||||
// Handle abort signal
|
||||
const abortHandler = () => {
|
||||
AskQuestion.cancel(ctx.callID!)
|
||||
}
|
||||
ctx.abort.addEventListener("abort", abortHandler)
|
||||
using _ = defer(() => ctx.abort.removeEventListener("abort", abortHandler))
|
||||
|
||||
// Wait for user response
|
||||
const answers = await answerPromise
|
||||
|
||||
// Format the answers for the LLM
|
||||
const formattedAnswers = answers
|
||||
.map((answer) => {
|
||||
const question = params.questions.find((q) => q.id === answer.questionId)
|
||||
const questionLabel = question?.label ?? answer.questionId
|
||||
|
||||
if (answer.customText) {
|
||||
return `**${questionLabel}**: ${answer.customText} (custom response)`
|
||||
}
|
||||
|
||||
const selectedLabels = answer.values
|
||||
.map((v) => {
|
||||
const option = question?.options.find((o) => o.value === v)
|
||||
return option?.label ?? v
|
||||
})
|
||||
.join(", ")
|
||||
|
||||
return `**${questionLabel}**: ${selectedLabels}`
|
||||
})
|
||||
.join("\n")
|
||||
|
||||
return {
|
||||
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
|
||||
metadata: {
|
||||
questions: params.questions.map((q) => q.label),
|
||||
answers: answers,
|
||||
status: "completed",
|
||||
},
|
||||
output: `User responses:\n\n${formattedAnswers}`,
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
32
packages/opencode/src/tool/askquestion.txt
Normal file
32
packages/opencode/src/tool/askquestion.txt
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# AskUserQuestion
|
||||
|
||||
Use this tool during planning to ask the user multiple clarifying questions in a wizard-style flow. This enables you to:
|
||||
1. Gather user preferences on multiple dimensions at once
|
||||
2. Clarify ambiguous requirements before planning
|
||||
3. Get decisions on implementation choices (framework, deployment, etc.)
|
||||
4. Present tradeoffs and let the user decide
|
||||
|
||||
## Parameters
|
||||
|
||||
- **questions**: An array of 1-6 questions, each with:
|
||||
- **id**: Unique identifier for the question
|
||||
- **label**: Short tab label (2-3 words), e.g. "UI Framework", "Database", "Auth Strategy"
|
||||
- **question**: The full question text to display
|
||||
- **options**: 2-5 answer options, each with value, label, and optional description
|
||||
- **multiSelect**: (optional) Set to true to allow multiple selections
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Generate descriptive tab labels that clearly indicate what each question is about
|
||||
- Provide 2-8 distinct, actionable answer options per question
|
||||
- Users can always type a custom response for any question
|
||||
- Order questions logically (broad decisions before specific ones)
|
||||
- Keep questions focused - don't ask too many at once (max 6)
|
||||
|
||||
## Example
|
||||
|
||||
When user says "Build a new web app", you might ask:
|
||||
- "UI Framework" → React, Vue, Svelte, Vanilla JS
|
||||
- "Styling" → Tailwind CSS, CSS Modules, Styled Components
|
||||
- "Backend" → Node.js, Python, Go, Serverless
|
||||
- "Database" → PostgreSQL, MongoDB, SQLite, None
|
||||
|
|
@ -10,6 +10,7 @@ import { TodoWriteTool, TodoReadTool } from "./todo"
|
|||
import { WebFetchTool } from "./webfetch"
|
||||
import { WriteTool } from "./write"
|
||||
import { InvalidTool } from "./invalid"
|
||||
import { AskQuestionTool } from "./askquestion"
|
||||
import type { Agent } from "../agent/agent"
|
||||
import { Tool } from "./tool"
|
||||
import { Instance } from "../project/instance"
|
||||
|
|
@ -103,6 +104,7 @@ export namespace ToolRegistry {
|
|||
TodoReadTool,
|
||||
WebSearchTool,
|
||||
CodeSearchTool,
|
||||
AskQuestionTool,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
|
||||
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
|
||||
...custom,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue