From e5c21767cf01e64d311844399631f403e3ed8274 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sun, 2 Aug 2026 18:08:43 -0400 Subject: [PATCH 1/9] feat(schema): question kind field (amicode#245) --- packages/schema/src/question.ts | 6 +++ packages/schema/src/v1/question.ts | 5 +++ packages/schema/test/question.test.ts | 54 +++++++++++++++++++++++++++ 3 files changed, 65 insertions(+) create mode 100644 packages/schema/test/question.test.ts diff --git a/packages/schema/src/question.ts b/packages/schema/src/question.ts index aba5ebfbaf..0f44af7685 100644 --- a/packages/schema/src/question.ts +++ b/packages/schema/src/question.ts @@ -25,11 +25,17 @@ export const Option = Schema.Struct({ }).annotate({ identifier: "QuestionV2.Option" }) export interface Option extends Schema.Schema.Type {} +export const Kind = Schema.Literals(["choice", "text"]).annotate({ + description: 'Question shape: "choice" (default, an option list) or "text" (a free-form text card, no options)', +}) +export type Kind = typeof Kind.Type + const base = { question: Schema.String.annotate({ description: "Complete question" }), header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }), options: Schema.Array(Option).annotate({ description: "Available choices" }), multiple: Schema.Boolean.pipe(optional).annotate({ description: "Allow selecting multiple choices" }), + kind: Kind.pipe(optional), } export const Info = Schema.Struct({ diff --git a/packages/schema/src/v1/question.ts b/packages/schema/src/v1/question.ts index e566996472..b90503a594 100644 --- a/packages/schema/src/v1/question.ts +++ b/packages/schema/src/v1/question.ts @@ -17,11 +17,16 @@ export const Option = Schema.Struct({ description: Schema.String.annotate({ description: "Explanation of choice" }), }).annotate({ identifier: "QuestionOption" }) +export const Kind = Schema.Literals(["choice", "text"]).annotate({ + description: 'Question shape: "choice" (default, an option list) or "text" (a free-form text card, no options)', +}) + const base = { question: Schema.String.annotate({ description: "Complete question" }), header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }), options: Schema.Array(Option).annotate({ description: "Available choices" }), multiple: Schema.optional(Schema.Boolean).annotate({ description: "Allow selecting multiple choices" }), + kind: Schema.optional(Kind), } export const Info = Schema.Struct({ diff --git a/packages/schema/test/question.test.ts b/packages/schema/test/question.test.ts new file mode 100644 index 0000000000..099bc0319f --- /dev/null +++ b/packages/schema/test/question.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test" +import { Schema } from "effect" +import { Question } from "../src/question" +import { QuestionV1 } from "../src/question-v1" + +// Coverage for the question `kind` field (amicode#245): a Free-form Question +// carries kind: "text" and renders as a text card; a Choice Question omits it +// or carries kind: "choice". Back-compat: a payload WITHOUT kind decodes as a +// choice question; an UNKNOWN kind fails decoding loudly rather than silently +// degrading to choice. + +const choicePayload = { + question: "Which environment?", + header: "Env", + options: [ + { label: "Dev", description: "Development" }, + { label: "Prod", description: "Production" }, + ], +} + +const textPayload = { + question: "What should we call you?", + header: "Name", + options: [], + kind: "text", +} + +const shapes: { name: string; schema: Schema.Decoder }[] = [ + { name: "v1 Info (server)", schema: QuestionV1.Info }, + { name: "v1 Prompt (agent-facing)", schema: QuestionV1.Prompt }, + { name: "v2 Info (server)", schema: Question.Info }, + { name: "v2 Prompt (agent-facing)", schema: Question.Prompt }, +] + +describe.each(shapes)("question kind — $name", ({ schema }) => { + const decode = Schema.decodeUnknownSync(schema) + const kindOf = (input: unknown) => (decode(input) as { kind?: string }).kind + + test("a payload without kind decodes (back-compat: a choice question)", () => { + expect(kindOf(choicePayload)).toBeUndefined() + }) + + test("an explicit choice kind decodes", () => { + expect(kindOf({ ...choicePayload, kind: "choice" })).toBe("choice") + }) + + test("a text kind decodes", () => { + expect(kindOf(textPayload)).toBe("text") + }) + + test("an unknown kind fails decoding loudly", () => { + expect(() => decode({ ...choicePayload, kind: "essay" })).toThrow() + }) +}) From a73af42e95fc7be8f95da8fd8b03642ecbbe5d7f Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sun, 2 Aug 2026 18:09:54 -0400 Subject: [PATCH 2/9] chore(sdk): regenerate question types with kind (amicode#245) --- packages/client/src/generated/types.ts | 2 ++ packages/sdk/js/src/v2/gen/types.gen.ts | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts index 3b3188c874..8370f14b09 100644 --- a/packages/client/src/generated/types.ts +++ b/packages/client/src/generated/types.ts @@ -2707,6 +2707,7 @@ export type QuestionsListRequestsOutput = { readonly header: string readonly options: ReadonlyArray<{ readonly label: string; readonly description: string }> readonly multiple?: boolean + readonly kind?: "choice" | "text" readonly custom?: boolean }> readonly tool?: { readonly messageID: string; readonly callID: string } @@ -2724,6 +2725,7 @@ export type QuestionsListOutput = { readonly header: string readonly options: ReadonlyArray<{ readonly label: string; readonly description: string }> readonly multiple?: boolean + readonly kind?: "choice" | "text" readonly custom?: boolean }> readonly tool?: { readonly messageID: string; readonly callID: string } diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 90c91e9158..31cb4069cf 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -717,6 +717,10 @@ export type QuestionInfo = { */ options: Array multiple?: boolean + /** + * Question shape: "choice" (default, an option list) or "text" (a free-form text card, no options) + */ + kind?: "choice" | "text" custom?: boolean } @@ -3152,6 +3156,10 @@ export type QuestionV2Info = { */ options: Array multiple?: boolean + /** + * Question shape: "choice" (default, an option list) or "text" (a free-form text card, no options) + */ + kind?: "choice" | "text" custom?: boolean } From d5ac54d697bf3726f17ea977e50658920ccc14d7 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Sun, 2 Aug 2026 18:14:14 -0400 Subject: [PATCH 3/9] feat(cli): text-card question rendering in the run footer (amicode#245) --- .../src/cli/cmd/run/footer.question.tsx | 78 ++++++++++++++-- .../src/cli/cmd/run/question.shared.ts | 22 ++++- .../test/cli/run/question.shared.test.ts | 91 +++++++++++++++++++ 3 files changed, 179 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/cli/cmd/run/footer.question.tsx b/packages/opencode/src/cli/cmd/run/footer.question.tsx index 6b0b40bbd0..4039c6aef6 100644 --- a/packages/opencode/src/cli/cmd/run/footer.question.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.question.tsx @@ -38,6 +38,7 @@ import { questionSubmit, questionSync, questionTabs, + questionText, questionTotal, } from "./question.shared" import { footerWidthPolicy } from "./footer.width" @@ -55,6 +56,11 @@ export function RunQuestionBody(props: { const single = createMemo(() => questionSingle(props.request)) const confirm = createMemo(() => questionConfirm(props.request, state())) const info = createMemo(() => questionInfo(props.request, state())) + // A Free-form Question (amicode#245) renders as a text card: the header plus + // a bare text input with submit — no option rows, no pseudo-option. The card + // is always in editing mode; its answer rides the typed-custom-answer path. + const text = createMemo(() => !confirm() && questionText(props.request, state())) + const editing = createMemo(() => state().editing || text()) const input = createMemo(() => questionInput(state())) const other = createMemo(() => questionOther(props.request, state())) const picked = createMemo(() => questionPicked(state())) @@ -171,9 +177,15 @@ export function RunQuestionBody(props: { return } - if (cur.editing) { + if (editing()) { if (event.name === "escape") { - setState((prev) => questionSetEditing(prev, false)) + // A text card has no non-editing mode: esc dismisses the card, the + // same rejection a choice card's esc performs outside editing. + if (text()) { + reject() + } else { + setState((prev) => questionSetEditing(prev, false)) + } event.preventDefault() return } @@ -248,7 +260,7 @@ export function RunQuestionBody(props: { }) createEffect(() => { - if (!state().editing || !area || area.isDestroyed) { + if (!editing() || !area || area.isDestroyed) { return } @@ -258,7 +270,7 @@ export function RunQuestionBody(props: { } queueMicrotask(() => { - if (!area || area.isDestroyed || !state().editing) { + if (!area || area.isDestroyed || !editing()) { return } @@ -361,8 +373,41 @@ export function RunQuestionBody(props: { - - +