mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-09 10:24:11 +00:00
Compare commits
3 Commits
v1.1.45
...
feat/quest
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5f5eec7db | ||
|
|
a70132a7f3 | ||
|
|
59a5eba370 |
@@ -1840,6 +1840,12 @@ function TodoWrite(props: ToolProps<typeof TodoWriteTool>) {
|
||||
function Question(props: ToolProps<typeof QuestionTool>) {
|
||||
const { theme } = useTheme()
|
||||
const count = createMemo(() => props.input.questions?.length ?? 0)
|
||||
|
||||
function format(answer?: string[]) {
|
||||
if (!answer?.length) return "(no answer)"
|
||||
return answer.join(", ")
|
||||
}
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.metadata.answers}>
|
||||
@@ -1849,7 +1855,7 @@ function Question(props: ToolProps<typeof QuestionTool>) {
|
||||
{(q, i) => (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={theme.textMuted}>{q.question}</text>
|
||||
<text fg={theme.text}>{props.metadata.answers?.[i()] || "(no answer)"}</text>
|
||||
<text fg={theme.text}>{format(props.metadata.answers?.[i()])}</text>
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useKeyboard } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useKeybind } from "../../context/keybind"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import type { QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { SplitBorder } from "../../component/border"
|
||||
import { useTextareaKeybindings } from "../../component/textarea-keybindings"
|
||||
@@ -17,11 +17,11 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
const bindings = useTextareaKeybindings()
|
||||
|
||||
const questions = createMemo(() => props.request.questions)
|
||||
const single = createMemo(() => questions().length === 1)
|
||||
const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single)
|
||||
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
|
||||
const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single select)
|
||||
const [store, setStore] = createStore({
|
||||
tab: 0,
|
||||
answers: [] as string[],
|
||||
answers: [] as QuestionAnswer[],
|
||||
custom: [] as string[],
|
||||
selected: 0,
|
||||
editing: false,
|
||||
@@ -34,10 +34,15 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
const options = createMemo(() => question()?.options ?? [])
|
||||
const other = createMemo(() => store.selected === options().length)
|
||||
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() {
|
||||
// Fill in empty answers with empty strings
|
||||
const answers = questions().map((_, i) => store.answers[i] ?? "")
|
||||
const answers = questions().map((_, i) => store.answers[i] ?? [])
|
||||
sdk.client.question.reply({
|
||||
requestID: props.request.id,
|
||||
answers,
|
||||
@@ -52,7 +57,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
|
||||
function pick(answer: string, custom: boolean = false) {
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = answer
|
||||
answers[store.tab] = [answer]
|
||||
setStore("answers", answers)
|
||||
if (custom) {
|
||||
const inputs = [...store.custom]
|
||||
@@ -62,7 +67,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
if (single()) {
|
||||
sdk.client.question.reply({
|
||||
requestID: props.request.id,
|
||||
answers: [answer],
|
||||
answers: [[answer]],
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -70,6 +75,17 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
setStore("selected", 0)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const dialog = useDialog()
|
||||
|
||||
useKeyboard((evt) => {
|
||||
@@ -82,11 +98,49 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
}
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
const text = textarea?.plainText?.trim()
|
||||
if (text) {
|
||||
pick(text, true)
|
||||
const text = textarea?.plainText?.trim() ?? ""
|
||||
const prev = store.custom[store.tab]
|
||||
|
||||
if (!text) {
|
||||
if (prev) {
|
||||
const inputs = [...store.custom]
|
||||
inputs[store.tab] = ""
|
||||
setStore("custom", inputs)
|
||||
}
|
||||
|
||||
const answers = [...store.answers]
|
||||
if (prev) {
|
||||
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
|
||||
}
|
||||
if (!prev) {
|
||||
answers[store.tab] = []
|
||||
}
|
||||
setStore("answers", answers)
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
|
||||
if (multi()) {
|
||||
const inputs = [...store.custom]
|
||||
inputs[store.tab] = text
|
||||
setStore("custom", inputs)
|
||||
|
||||
const existing = store.answers[store.tab] ?? []
|
||||
const next = [...existing]
|
||||
if (prev) {
|
||||
const index = next.indexOf(prev)
|
||||
if (index !== -1) next.splice(index, 1)
|
||||
}
|
||||
if (!next.includes(text)) next.push(text)
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = next
|
||||
setStore("answers", answers)
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
|
||||
pick(text, true)
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
// Let textarea handle all other keys
|
||||
@@ -133,13 +187,25 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
if (evt.name === "return") {
|
||||
evt.preventDefault()
|
||||
if (other()) {
|
||||
setStore("editing", true)
|
||||
} else {
|
||||
const opt = opts[store.selected]
|
||||
if (opt) {
|
||||
pick(opt.label)
|
||||
if (!multi()) {
|
||||
setStore("editing", true)
|
||||
return
|
||||
}
|
||||
const value = input()
|
||||
if (value && customPicked()) {
|
||||
toggle(value)
|
||||
return
|
||||
}
|
||||
setStore("editing", true)
|
||||
return
|
||||
}
|
||||
const opt = opts[store.selected]
|
||||
if (!opt) return
|
||||
if (multi()) {
|
||||
toggle(opt.label)
|
||||
return
|
||||
}
|
||||
pick(opt.label)
|
||||
}
|
||||
|
||||
if (evt.name === "escape" || keybind.match("app_exit", evt)) {
|
||||
@@ -162,7 +228,9 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
<For each={questions()}>
|
||||
{(q, index) => {
|
||||
const isActive = () => index() === store.tab
|
||||
const isAnswered = () => store.answers[index()] !== undefined
|
||||
const isAnswered = () => {
|
||||
return (store.answers[index()]?.length ?? 0) > 0
|
||||
}
|
||||
return (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
@@ -185,13 +253,16 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
<Show when={!confirm()}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<box>
|
||||
<text fg={theme.text}>{question()?.question}</text>
|
||||
<text fg={theme.text}>
|
||||
{question()?.question}
|
||||
{multi() ? " (select all that apply)" : ""}
|
||||
</text>
|
||||
</box>
|
||||
<box>
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const active = () => i() === store.selected
|
||||
const picked = () => store.answers[store.tab] === opt.label
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
@@ -212,17 +283,18 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
<box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
|
||||
<text fg={other() ? theme.secondary : input() ? theme.success : theme.text}>
|
||||
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
|
||||
{options().length + 1}. Type your own answer
|
||||
</text>
|
||||
</box>
|
||||
<text fg={theme.success}>{input() ? "✓" : ""}</text>
|
||||
<text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
|
||||
</box>
|
||||
<Show when={store.editing}>
|
||||
<box paddingLeft={3}>
|
||||
<textarea
|
||||
ref={(val: TextareaRenderable) => (textarea = val)}
|
||||
focused
|
||||
initialValue={input()}
|
||||
placeholder="Type your own answer"
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
@@ -247,11 +319,12 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
</box>
|
||||
<For each={questions()}>
|
||||
{(q, index) => {
|
||||
const answer = () => store.answers[index()]
|
||||
const value = () => store.answers[index()]?.join(", ") ?? ""
|
||||
const answered = () => Boolean(value())
|
||||
return (
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<text fg={theme.textMuted}>{q.header}:</text>
|
||||
<text fg={answer() ? theme.text : theme.error}>{answer() ?? "(not answered)"}</text>
|
||||
<text fg={answered() ? theme.text : theme.error}>{answered() ? value() : "(not answered)"}</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
@@ -279,8 +352,12 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
enter <span style={{ fg: theme.textMuted }}>{confirm() ? "submit" : single() ? "submit" : "confirm"}</span>
|
||||
enter{" "}
|
||||
<span style={{ fg: theme.textMuted }}>
|
||||
{confirm() ? "submit" : multi() ? "toggle" : single() ? "submit" : "confirm"}
|
||||
</span>
|
||||
</text>
|
||||
|
||||
<text fg={theme.text}>
|
||||
esc <span style={{ fg: theme.textMuted }}>dismiss</span>
|
||||
</text>
|
||||
|
||||
@@ -23,6 +23,7 @@ export namespace Question {
|
||||
question: z.string().describe("Complete question"),
|
||||
header: z.string().max(12).describe("Very short label (max 12 chars)"),
|
||||
options: z.array(Option).describe("Available choices"),
|
||||
multiple: z.boolean().optional().describe("Allow selecting multiple choices"),
|
||||
})
|
||||
.meta({
|
||||
ref: "QuestionInfo",
|
||||
@@ -46,8 +47,15 @@ export namespace Question {
|
||||
})
|
||||
export type Request = z.infer<typeof Request>
|
||||
|
||||
export const Answer = z.array(z.string()).meta({
|
||||
ref: "QuestionAnswer",
|
||||
})
|
||||
export type Answer = z.infer<typeof Answer>
|
||||
|
||||
export const Reply = z.object({
|
||||
answers: z.array(z.string()).describe("User answers in order of questions"),
|
||||
answers: z
|
||||
.array(Answer)
|
||||
.describe("User answers in order of questions (each answer is an array of selected labels)"),
|
||||
})
|
||||
export type Reply = z.infer<typeof Reply>
|
||||
|
||||
@@ -58,7 +66,7 @@ export namespace Question {
|
||||
z.object({
|
||||
sessionID: z.string(),
|
||||
requestID: z.string(),
|
||||
answers: z.array(z.string()),
|
||||
answers: z.array(Answer),
|
||||
}),
|
||||
),
|
||||
Rejected: BusEvent.define(
|
||||
@@ -75,7 +83,7 @@ export namespace Question {
|
||||
string,
|
||||
{
|
||||
info: Request
|
||||
resolve: (answers: string[]) => void
|
||||
resolve: (answers: Answer[]) => void
|
||||
reject: (e: any) => void
|
||||
}
|
||||
> = {}
|
||||
@@ -89,13 +97,13 @@ export namespace Question {
|
||||
sessionID: string
|
||||
questions: Info[]
|
||||
tool?: { messageID: string; callID: string }
|
||||
}): Promise<string[]> {
|
||||
}): Promise<Answer[]> {
|
||||
const s = await state()
|
||||
const id = Identifier.ascending("question")
|
||||
|
||||
log.info("asking", { id, questions: input.questions.length })
|
||||
|
||||
return new Promise<string[]>((resolve, reject) => {
|
||||
return new Promise<Answer[]>((resolve, reject) => {
|
||||
const info: Request = {
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
@@ -111,7 +119,7 @@ export namespace Question {
|
||||
})
|
||||
}
|
||||
|
||||
export async function reply(input: { requestID: string; answers: string[] }): Promise<void> {
|
||||
export async function reply(input: { requestID: string; answers: Answer[] }): Promise<void> {
|
||||
const s = await state()
|
||||
const existing = s.pending[input.requestID]
|
||||
if (!existing) {
|
||||
|
||||
@@ -52,7 +52,7 @@ export const QuestionRoute = new Hono()
|
||||
requestID: z.string(),
|
||||
}),
|
||||
),
|
||||
validator("json", z.object({ answers: z.array(z.string()) })),
|
||||
validator("json", Question.Reply),
|
||||
async (c) => {
|
||||
const params = c.req.valid("param")
|
||||
const json = c.req.valid("json")
|
||||
|
||||
@@ -15,7 +15,12 @@ export const QuestionTool = Tool.define("question", {
|
||||
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
|
||||
})
|
||||
|
||||
const formatted = params.questions.map((q, i) => `"${q.question}"="${answers[i] ?? "Unanswered"}"`).join(", ")
|
||||
function format(answer: Question.Answer | undefined) {
|
||||
if (!answer?.length) return "Unanswered"
|
||||
return answer.join(", ")
|
||||
}
|
||||
|
||||
const formatted = params.questions.map((q, i) => `"${q.question}"="${format(answers[i])}"`).join(", ")
|
||||
|
||||
return {
|
||||
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
|
||||
|
||||
@@ -6,4 +6,5 @@ Use this tool when you need to ask the user questions during execution. This all
|
||||
|
||||
Usage notes:
|
||||
- Users will always be able to select "Other" to provide custom text input
|
||||
- Answers are returned as arrays of labels; set `multiple: true` to allow selecting more than one
|
||||
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label
|
||||
|
||||
@@ -82,11 +82,11 @@ test("reply - resolves the pending ask with answers", async () => {
|
||||
|
||||
await Question.reply({
|
||||
requestID,
|
||||
answers: ["Option 1"],
|
||||
answers: [["Option 1"]],
|
||||
})
|
||||
|
||||
const answers = await askPromise
|
||||
expect(answers).toEqual(["Option 1"])
|
||||
expect(answers).toEqual([["Option 1"]])
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -115,7 +115,7 @@ test("reply - removes from pending list", async () => {
|
||||
|
||||
await Question.reply({
|
||||
requestID: pending[0].id,
|
||||
answers: ["Option 1"],
|
||||
answers: [["Option 1"]],
|
||||
})
|
||||
|
||||
const pendingAfter = await Question.list()
|
||||
@@ -131,7 +131,7 @@ test("reply - does nothing for unknown requestID", async () => {
|
||||
fn: async () => {
|
||||
await Question.reply({
|
||||
requestID: "que_unknown",
|
||||
answers: ["Option 1"],
|
||||
answers: [["Option 1"]],
|
||||
})
|
||||
// Should not throw
|
||||
},
|
||||
@@ -244,11 +244,11 @@ test("ask - handles multiple questions", async () => {
|
||||
|
||||
await Question.reply({
|
||||
requestID: pending[0].id,
|
||||
answers: ["Build", "Dev"],
|
||||
answers: [["Build"], ["Dev"]],
|
||||
})
|
||||
|
||||
const answers = await askPromise
|
||||
expect(answers).toEqual(["Build", "Dev"])
|
||||
expect(answers).toEqual([["Build"], ["Dev"]])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -84,6 +84,7 @@ import type {
|
||||
PtyRemoveResponses,
|
||||
PtyUpdateErrors,
|
||||
PtyUpdateResponses,
|
||||
QuestionAnswer,
|
||||
QuestionListResponses,
|
||||
QuestionRejectErrors,
|
||||
QuestionRejectResponses,
|
||||
@@ -1815,7 +1816,7 @@ export class Question extends HeyApiClient {
|
||||
parameters: {
|
||||
requestID: string
|
||||
directory?: string
|
||||
answers?: Array<string>
|
||||
answers?: Array<QuestionAnswer>
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
|
||||
@@ -541,6 +541,10 @@ export type QuestionInfo = {
|
||||
* Available choices
|
||||
*/
|
||||
options: Array<QuestionOption>
|
||||
/**
|
||||
* Allow selecting multiple choices
|
||||
*/
|
||||
multiple?: boolean
|
||||
}
|
||||
|
||||
export type QuestionRequest = {
|
||||
@@ -561,12 +565,14 @@ export type EventQuestionAsked = {
|
||||
properties: QuestionRequest
|
||||
}
|
||||
|
||||
export type QuestionAnswer = Array<string>
|
||||
|
||||
export type EventQuestionReplied = {
|
||||
type: "question.replied"
|
||||
properties: {
|
||||
sessionID: string
|
||||
requestID: string
|
||||
answers: Array<string>
|
||||
answers: Array<QuestionAnswer>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3630,7 +3636,10 @@ export type QuestionListResponse = QuestionListResponses[keyof QuestionListRespo
|
||||
|
||||
export type QuestionReplyData = {
|
||||
body?: {
|
||||
answers: Array<string>
|
||||
/**
|
||||
* User answers in order of questions (each answer is an array of selected labels)
|
||||
*/
|
||||
answers: Array<QuestionAnswer>
|
||||
}
|
||||
path: {
|
||||
requestID: string
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user