diff --git a/AMICODE-PATCHES.md b/AMICODE-PATCHES.md index 7de875daa3..5cc739cd1c 100644 --- a/AMICODE-PATCHES.md +++ b/AMICODE-PATCHES.md @@ -404,6 +404,8 @@ Rebuilt with the exact T3 recipe (`OPENCODE_VERSION=1.17.3 bun run script/build. - `text-shimmer.tsx` untouched — it has many other live callers (`basic-tool.tsx`, `message-part.tsx` ×7, `session-turn.tsx`, `v2/components/basic-tool-v2.tsx` via `text-shimmer-v2.tsx`, plus stories), confirmed via `rg -n 'TextShimmer' packages/`. Not a candidate for deletion. - The dropped `offset` prop existed only so the shimmer's gradient phase stayed continuous across the prefix/tail split; with no shimmer there's nothing to phase-align, so it has no replacement. Verified live (Storybook, `UI/AnimatedCountList` stories) that the prefix and tail still read as one unbroken word with no seam: in the swap-mode `Playground` story, driving an active→done transition showed the mid-animation frame rendering both the active and done spans simultaneously under the animating width (`data-ready="true"`, container `style="width: 0px"` mid-transition, then settling) — the word-morph is intact. The suffix/prefix-tail mode (`data-mode="suffix"`) is currently unreachable from any real call site — both app usages (`message-part.tsx` context-tool-group and shell-group titles) and the only story pass `split={false}` — so it was verified by transiently flipping one story's `split` prop off (`Done` export, "Exploring"/"Explored"), confirming `data-mode="suffix"` renders `"Explor"` + `"ed"` as an unbroken "Explored" with no visible gap, then reverting that story edit before commit (`git status` shows only the two files below). - Tests: ui `bun test src` → 402 pass / 0 fail (unchanged). typecheck (tsgo) clean in `packages/ui`. `oxlint packages/ui/src` → 498 warnings / 0 errors both before and after (no new warnings once `prefixLen` was dropped). +26. (text-card questions — free-form asks get a first-class card, 2026-08-02) — amicode#245 + ADR `docs/adr/0002-free-form-questions-render-as-text-cards.md`: the question schema grows an optional `kind` (`"choice"` default | `"text"`) on the agent-facing AND server shapes (v1 `packages/schema/src/v1/question.ts` + v2 `packages/schema/src/question.ts`, both in `base` so `Prompt` and `Info` carry it; absent → choice, unknown kind fails decoding loudly). Three renderers branch a text card (header + bare text input + submit; NO option rows, NO "Type your own answer" pseudo-option; submit gated on non-empty trimmed text; answers ride the EXISTING typed-custom-answer path; dismissal unchanged): app dock (`session-question-dock.tsx` + new `session-question-dock.helpers.ts` — ALSO honors the `custom` flag for CHOICE cards, the TUI pattern it previously ignored), TUI (`question.tsx`), CLI (`question.shared.ts` + `footer.question.tsx`). Prose guard (`session/prompt.ts`) behavior UNCHANGED (still fires on prose questions in active interviews, once per assistant message, never after a question-tool call); predicate + nudge extracted to `session/prose-guard.ts` (turn-output.ts pattern); nudge text now bilingual (options-with-recommended-first for choice, `kind: "text"` for free-form). Tool descriptions teach the text kind (`tool/question.txt` + core `tool/question.ts`). Legacy SDK + client codegen regenerated (QuestionInfo/QuestionV2Info gain `kind?: "choice" | "text"`). Upstream-sync watch: the dock's question component is already a high-conflict file — keep the text-card branch and the `customRow()` gate honest on syncs. + - Tests: schema `test/question.test.ts` (decode ×4 shapes: absent/choice/text/unknown), opencode `test/cli/run/question.shared.test.ts` (+5: text card shape/submit/empty-blocked, choice regression, custom:false), tui `test/routes/session/question.test.tsx` (+5: render coverage incl. typed-answer submit through a stub server), app `session-question-dock.helpers.test.ts` (+6), opencode `test/session/prose-guard.test.ts` (+10: predicate truth table + bilingual nudge), tool tests (+2 description, +1 kind passthrough). ## Feature-branch recovery 2026-08-01 (after the upstream sync) diff --git a/packages/app/src/pages/session/composer/session-question-dock.helpers.test.ts b/packages/app/src/pages/session/composer/session-question-dock.helpers.test.ts new file mode 100644 index 0000000000..ab57e5752d --- /dev/null +++ b/packages/app/src/pages/session/composer/session-question-dock.helpers.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test" +import { questionCustomRow, questionText, questionTextReady } from "./session-question-dock.helpers" + +// The dock rendering contract (amicode#245): text cards carry no option rows +// and no pseudo-option; choice cards show the typed-custom-answer row only +// when the question allows it; a text card's submit waits for non-empty text. + +describe("questionText", () => { + test("a text-kind question renders as a text card", () => { + expect(questionText({ kind: "text" })).toBe(true) + }) + + test("an absent or explicit choice kind renders as a choice card", () => { + expect(questionText({})).toBe(false) + expect(questionText({ kind: "choice" })).toBe(false) + expect(questionText(undefined)).toBe(false) + }) +}) + +describe("questionCustomRow", () => { + test("a text card never shows the pseudo-option", () => { + expect(questionCustomRow({ kind: "text" })).toBe(false) + expect(questionCustomRow({ kind: "text", custom: true })).toBe(false) + }) + + test("a choice question shows the pseudo-option by default", () => { + expect(questionCustomRow({})).toBe(true) + expect(questionCustomRow({ kind: "choice" })).toBe(true) + expect(questionCustomRow({ custom: true })).toBe(true) + }) + + test("a choice question with custom disabled shows no pseudo-option", () => { + expect(questionCustomRow({ custom: false })).toBe(false) + expect(questionCustomRow({ kind: "choice", custom: false })).toBe(false) + }) +}) + +describe("questionTextReady", () => { + test("submit is enabled only once the trimmed text is non-empty", () => { + expect(questionTextReady("")).toBe(false) + expect(questionTextReady(" \n ")).toBe(false) + expect(questionTextReady("JJ")).toBe(true) + expect(questionTextReady(" JJ ")).toBe(true) + }) +}) diff --git a/packages/app/src/pages/session/composer/session-question-dock.helpers.ts b/packages/app/src/pages/session/composer/session-question-dock.helpers.ts new file mode 100644 index 0000000000..00787da07f --- /dev/null +++ b/packages/app/src/pages/session/composer/session-question-dock.helpers.ts @@ -0,0 +1,25 @@ +import type { QuestionInfo } from "@opencode-ai/sdk/v2" + +// Question-shape rules for the session question dock (amicode#245). Extracted +// from the component so the rendering contract is unit-testable: a Free-form +// Question (kind: "text") renders a text card — the header plus a bare text +// input with submit, no option rows and no typed-custom-answer pseudo-option — +// while a Choice Question renders its options and shows the pseudo-option row +// only when the question allows a custom answer. + +/** True when the question renders as a text card (no option list). */ +export function questionText(info: Pick | undefined): boolean { + return info?.kind === "text" +} + +/** True when the typed-custom-answer row renders: choice questions that allow + * a custom answer (the TUI flag check the dock previously ignored). */ +export function questionCustomRow(info: Pick | undefined): boolean { + if (questionText(info)) return false + return info?.custom !== false +} + +/** A text card's submit is enabled only once the trimmed text is non-empty. */ +export function questionTextReady(input: string): boolean { + return input.trim().length > 0 +} diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 077f1e218e..50653ece84 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -13,6 +13,7 @@ import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" import { useServerSDK } from "@/context/server-sdk" import { ScopedKey } from "@/utils/server-scope" +import { questionCustomRow, questionText, questionTextReady } from "./session-question-dock.helpers" const cache = new Map() @@ -94,7 +95,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const input = createMemo(() => store.custom[store.tab] ?? "") const on = createMemo(() => store.customOn[store.tab] === true) const multi = createMemo(() => question()?.multiple === true) - const count = createMemo(() => options().length + 1) + // 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. Its + // answer rides the typed-custom-answer path; submit waits for non-empty text. + const text = createMemo(() => questionText(question())) + const customRow = createMemo(() => questionCustomRow(question())) + const count = createMemo(() => (text() ? 0 : options().length + 1)) const summary = createMemo(() => { const n = Math.min(store.tab + 1, total()) @@ -339,7 +345,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const target = event.target instanceof HTMLElement ? event.target.closest('[data-slot="question-options"]') : undefined - if (store.editing) return + if (store.editing || text()) return if (!(target instanceof HTMLElement)) return if (event.altKey || event.ctrlKey || event.metaKey) return @@ -416,6 +422,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const next = () => { if (sending()) return if (store.editing) commitCustom() + if (text() && !questionTextReady(input())) return if (store.tab >= total() - 1) { submit() @@ -534,7 +541,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit