diff --git a/AGENTS.md b/AGENTS.md index 8b7fb11564..7e1a027cae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,13 @@ - The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` TUI storage channel so tabs and other client-local state match the installed client. - Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it. +## V2 TUI Stories + +- When a user asks for a TUI story, add a fixture-driven story under `packages/tui/src/feature-plugins/system/storybook` and register it in `index.tsx`. +- Render the real production component rather than a visual copy. Keep submissions and other side effects local to the story so it is safe to explore repeatedly. +- Expose the meaningful state dimensions through story keybindings and list them in `StoryFooter`; include a reset command when combinations can leave the fixture in a confusing state. +- Run a specific story with `OPENCODE_STORY= bun run dev:live` from the development worktree, and exercise narrow and wide terminal sizes when layout is relevant. + ## Branch Names Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`. diff --git a/packages/tui/src/routes/session/form.tsx b/packages/tui/src/routes/session/form.tsx index 83ef476791..861e0c3da5 100644 --- a/packages/tui/src/routes/session/form.tsx +++ b/packages/tui/src/routes/session/form.tsx @@ -1,10 +1,17 @@ import { createStore } from "solid-js/store" -import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" -import { useRenderer, useTerminalDimensions } from "@opentui/solid" -import type { ScrollBoxRenderable, TextareaRenderable } from "@opentui/core" +import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" +import { usePaste, useRenderer, useTerminalDimensions } from "@opentui/solid" +import { + CliRenderEvents, + decodePasteBytes, + stripAnsiSequences, + TextAttributes, + type ScrollBoxRenderable, + type TextareaRenderable, +} from "@opentui/core" import open from "open" import { useTheme, useThemes } from "../../context/theme" -import type { FormField, FormValue } from "@opencode-ai/client" +import type { FormAnswer, FormField, FormValue } from "@opencode-ai/client" import type { FormWithLocation } from "../../context/data" import { useClient } from "../../context/client" import { useClipboard } from "../../context/clipboard" @@ -12,6 +19,7 @@ import { SplitBorder } from "../../ui/border" import { useToast } from "../../ui/toast" import { Keymap } from "../../context/keymap" import { useConfig } from "../../config" +import { errorMessage } from "../../util/error" import { formCustom, formDisplayValue, @@ -27,7 +35,7 @@ import { } from "../../util/form" import type { FormAnswerField } from "../../util/form" -const FORM_MODE = "form" +export const FORM_MODE = "form" function truncate(label: string, max: number) { return label.length > max ? label.slice(0, max - 1).trimEnd() + "…" : label @@ -43,7 +51,11 @@ function requestOptions(form: FormWithLocation) { } } -export function FormPrompt(props: { form: FormWithLocation }) { +export function FormPrompt(props: { + form: FormWithLocation + onReply?: (answer: FormAnswer) => void | Promise + onCancel?: () => void | Promise +}) { const client = useClient() const themes = useThemes() const theme = useTheme("elevated") @@ -58,6 +70,8 @@ export function FormPrompt(props: { form: FormWithLocation }) { const initial = formInitialValues(props.form.fields) const [tabHover, setTabHover] = createSignal(null) + const [reviewHeight, setReviewHeight] = createSignal(1) + const [reviewScrollable, setReviewScrollable] = createSignal(false) const [store, setStore] = createStore({ tab: 0, answers: initial.answers, @@ -70,6 +84,7 @@ export function FormPrompt(props: { form: FormWithLocation }) { let textarea: TextareaRenderable | undefined let review: ScrollBoxRenderable | undefined + let measureReview: (() => void) | undefined const message = createMemo(() => { const value = props.form.metadata?.["message"] @@ -98,16 +113,16 @@ export function FormPrompt(props: { form: FormWithLocation }) { }) const tabs = createMemo(() => (single() ? 1 : fields().length + 1)) const tabbed = createMemo(() => { - const width = fields().reduce((sum, item) => sum + truncate(formLabel(item), 24).length + 3, "Confirm".length + 3) + const width = fields().reduce((sum, item) => sum + truncate(formLabel(item), 24).length + 3, "Submit".length + 3) return width <= dimensions().width - 8 }) - const answered = createMemo( - () => - fields().filter((item) => { - const value = store.answers[item.key] - return value !== undefined - }).length, - ) + const completed = (item: FormField) => { + const value = store.answers[item.key] + if (value === undefined) return false + if (item.type === "external") return value === true + return formValidateValue(item, value) === undefined + } + const answered = createMemo(() => fields().filter(completed).length) const field = createMemo(() => fields()[store.tab]) const answerField = createMemo(() => { const current = field() @@ -118,16 +133,22 @@ export function FormPrompt(props: { form: FormWithLocation }) { return current?.type === "external" ? current : undefined }) const confirm = createMemo(() => !single() && store.tab >= fields().length) + const configuredRows = createMemo(() => { + const current = answerField() + return current ? formRows(current) : [] + }) const rows = createMemo(() => { const current = answerField() if (!current) return [] - const configured = formRows(current) + const configured = configuredRows() const value = store.answers[current.key] if (current.type !== "multiselect" || !Array.isArray(value)) return configured const known = new Set(configured.map((row) => row.value)) return [ ...configured, - ...value.filter((item) => !known.has(item)).map((item) => ({ value: item, label: item, description: undefined })), + ...value + .filter((item) => !known.has(item) && item !== store.custom[current.key]) + .map((item) => ({ value: item, label: item, description: undefined })), ] }) const textual = createMemo(() => { @@ -138,17 +159,6 @@ export function FormPrompt(props: { form: FormWithLocation }) { return formCustom(answerField()) }) const multi = createMemo(() => answerField()?.type === "multiselect") - const actionLabel = createMemo(() => { - if (confirm()) return "submit" - const external = externalField() - if (external) { - if (store.answers[external.key] === true) return "continue" - return store.externalReady[external.key] ? "I finished" : "open link" - } - if (multi()) return "toggle" - if (single()) return "submit" - return "confirm" - }) const placeholder = createMemo(() => { const current = answerField() if (current?.type === "string") { @@ -176,30 +186,92 @@ export function FormPrompt(props: { form: FormWithLocation }) { if (Array.isArray(answer)) return answer.includes(value) return answer === value }) + const customChecked = createMemo(() => customPicked() || (multi() && other() && store.editing)) + const actionLabel = createMemo(() => { + if (confirm()) return "submit" + const external = externalField() + if (external) { + if (store.answers[external.key] === true) return "continue" + return store.externalReady[external.key] ? "I finished" : "open link" + } + if (multi()) { + if (other() && store.editing) return "done" + if (other() && !input()) return "edit" + return "toggle" + } + if (single()) return "submit" + return "confirm" + }) + + createEffect(() => { + if (measureReview) renderer.off(CliRenderEvents.FRAME, measureReview) + if (!confirm()) { + measureReview = undefined + review = undefined + setReviewScrollable(false) + return + } + const limit = Math.max(3, dimensions().height - 14) + const initial = Math.min(Math.max(1, fields().length), limit) + Object.values(store.answers) + setReviewHeight(initial) + setReviewScrollable(false) + measureReview = () => { + measureReview = undefined + const content = review?.scrollHeight ?? initial + const height = Math.min(Math.max(1, content), limit) + setReviewHeight(height) + setReviewScrollable(content > height) + } + renderer.once(CliRenderEvents.FRAME, measureReview) + renderer.requestRender() + }) + + onCleanup(() => { + if (measureReview) renderer.off(CliRenderEvents.FRAME, measureReview) + }) + + onCleanup( + keymap.intercept("key", ({ event, consume }) => { + if (keymap.mode.current() !== FORM_MODE) return + if (textual() || !other() || (store.editing && renderer.currentFocusedEditor === textarea)) return + if (event.ctrl || event.meta || event.option || event.super || event.hyper) return + if ((!store.editing && event.sequence === " ") || !/^[^\p{C}\p{Zl}\p{Zp}]$/u.test(event.sequence)) return + const current = answerField() + if (!current) return + updateCustom(current, input() + event.sequence) + if (!store.editing) setStore("editing", true) + consume() + }), + ) function answer(key: string, value: FormValue | undefined) { - setStore("answers", { ...store.answers, [key]: value }) + const field = fields().find((item) => item.key === key) + setStore( + "answers", + key, + Array.isArray(value) && value.length === 0 && field?.type === "multiselect" && !field.required + ? undefined + : value, + ) setStore("error", "") } - function replySingle(field: FormAnswerField, value: FormValue) { - client.api.form - .reply( - { - sessionID: props.form.sessionID, - formID: props.form.id, - answer: { [field.key]: value }, - }, - requestOptions(props.form), + function reply(answer: FormAnswer) { + void Promise.resolve() + .then(() => + props.onReply + ? props.onReply(answer) + : client.api.form.reply( + { sessionID: props.form.sessionID, formID: props.form.id, answer }, + requestOptions(props.form), + ), ) - .catch((error: unknown) => { - setStore( - "error", - typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" - ? error.message - : "Invalid answer", - ) - }) + .catch((error: unknown) => setStore("error", errorMessage(error))) + } + + function replySingle(field: FormAnswerField, value: FormValue) { + reply({ [field.key]: value }) } function pick(value: FormValue, customValue?: string) { @@ -211,7 +283,7 @@ export function FormPrompt(props: { form: FormWithLocation }) { return } answer(current.key, value) - if (customValue !== undefined) setStore("custom", { ...store.custom, [current.key]: customValue }) + if (customValue !== undefined) setStore("custom", current.key, customValue) if (single()) { replySingle(current, value) return @@ -225,18 +297,19 @@ export function FormPrompt(props: { form: FormWithLocation }) { answer(current.key, formToggleMultiselect(store.answers[current.key], value)) } - function validateCurrent() { - if (confirm()) return true - const current = answerField() - if (!current) return true - const invalid = formValidateValue(current, store.answers[current.key]) - if (!invalid) return true - setStore("error", invalid) - return false + function updateCustom(current: FormAnswerField, value: string) { + const previous = store.custom[current.key] + if (previous === value) return + setStore("custom", current.key, value) + answer( + current.key, + current.type === "multiselect" + ? formSetMultiselectCustom(store.answers[current.key], previous, value) + : value || undefined, + ) } function selectTab(index: number) { - if (!confirm() && index > store.tab && !validateCurrent()) return const next = fields()[index] setStore("tab", index) setStore("selected", next && isFormAnswerField(next) ? formSelected(next, store.answers[next.key]) : 0) @@ -267,23 +340,35 @@ export function FormPrompt(props: { form: FormWithLocation }) { pick(row.value) } + usePaste((event) => { + if (keymap.mode.current() !== FORM_MODE) return + const current = answerField() + if (!current || textual() || !custom() || confirm()) return + event.preventDefault() + setStore("selected", rows().length) + updateCustom(current, input() + stripAnsiSequences(decodePasteBytes(event.bytes)).replace(/\r\n?/g, "\n")) + setStore("editing", true) + }) + function commitInput(text: string) { const current = answerField() if (!current) return false const isTextual = textual() const isMulti = multi() if (!text) { - const previous = store.custom[current.key] - const existing = store.answers[current.key] - const values = Array.isArray(existing) ? existing.filter((value) => value !== previous) : [] - const value = !isTextual && isMulti && Array.isArray(existing) ? values : undefined - const invalid = formValidateValue(current, value) - if (invalid) { - setStore("error", invalid) - return false + const value = + !isTextual && isMulti + ? formSetMultiselectCustom(store.answers[current.key], store.custom[current.key], "") + : undefined + if (isTextual || !isMulti) { + const invalid = formValidateValue(current, value) + if (invalid) { + setStore("error", invalid) + return false + } } answer(current.key, value) - setStore("custom", { ...store.custom, [current.key]: "" }) + setStore("custom", current.key, "") setStore("editing", false) return true } @@ -321,7 +406,7 @@ export function FormPrompt(props: { form: FormWithLocation }) { } const configured = current.type === "string" && current.options?.some((option) => option.value === text) - setStore("custom", { ...store.custom, [current.key]: isMulti || configured ? "" : text }) + setStore("custom", current.key, configured ? "" : text) setStore("editing", false) return true } @@ -352,6 +437,10 @@ export function FormPrompt(props: { form: FormWithLocation }) { } function cancel() { + if (props.onCancel) { + void props.onCancel() + return + } void client.api.form.cancel({ sessionID: props.form.sessionID, formID: props.form.id }, requestOptions(props.form)) } @@ -404,34 +493,21 @@ export function FormPrompt(props: { form: FormWithLocation }) { setStore("error", formValidateValue(invalid, store.answers[invalid.key]) ?? "Invalid answer") return } - client.api.form - .reply( - { - sessionID: props.form.sessionID, - formID: props.form.id, - answer: Object.fromEntries( - fields().flatMap((field) => { - const value = store.answers[field.key] - return value === undefined ? [] : [[field.key, value] as const] - }), - ), - }, - requestOptions(props.form), - ) - .catch((error: unknown) => { - setStore( - "error", - typeof error === "object" && error !== null && "message" in error && typeof error.message === "string" - ? error.message - : "Invalid answer", - ) - }) + reply( + Object.fromEntries( + fields().flatMap((field) => { + const value = store.answers[field.key] + return value === undefined ? [] : [[field.key, value] as const] + }), + ), + ) } onMount(() => onCleanup(keymap.mode.push(FORM_MODE))) Keymap.createLayer(() => ({ mode: FORM_MODE, + priority: 1, enabled: (store.editing || textual()) && !confirm(), commands: [ { @@ -449,14 +525,11 @@ export function FormPrompt(props: { form: FormWithLocation }) { }, { bind: "escape", - title: "Cancel answer edit", + title: textual() ? "Dismiss form" : "Close answer edit", group: "Form", run: () => { if (textual()) { - void client.api.form.cancel( - { sessionID: props.form.sessionID, formID: props.form.id }, - requestOptions(props.form), - ) + cancel() return } setStore("editing", false) @@ -480,6 +553,17 @@ export function FormPrompt(props: { form: FormWithLocation }) { submitInput(text, -1) }, }, + { + bind: "up", + title: "Leave answer edit", + group: "Form", + run: () => { + if (textual() || !textarea || textarea.isDestroyed || store.selected === 0) return false + if (textarea.scrollY + textarea.visualCursor.visualRow > 0) return false + setStore("editing", false) + setStore("selected", store.selected - 1) + }, + }, { bind: "return", title: "Submit answer edit", @@ -615,6 +699,9 @@ export function FormPrompt(props: { form: FormWithLocation }) { run: () => setStore("selected", (store.selected + 1) % total), }, { bind: "return", title: "Select answer", group: "Form", run: () => selectOption() }, + ...(multi() + ? [{ bind: "space", title: "Toggle answer", group: "Form", run: () => selectOption() }] + : []), { bind: "escape", title: "Dismiss form", @@ -643,7 +730,7 @@ export function FormPrompt(props: { form: FormWithLocation }) { - + {confirm() ? "Review" : `Field ${Math.min(store.tab, fields().length - 1) + 1} of ${fields().length}`} @@ -655,11 +742,16 @@ export function FormPrompt(props: { form: FormWithLocation }) { - + {(item, index) => { const isTab = () => index() === store.tab - const isAnswered = () => store.answers[item.key] !== undefined + const color = () => + isTab() + ? theme.text.default + : tabHover() === index() + ? theme.text.formfield.focused + : theme.text.subdued return ( - + {truncate(formLabel(item), 24)} @@ -709,7 +791,18 @@ export function FormPrompt(props: { form: FormWithLocation }) { selectTabFromMouse() }} > - Confirm + + Submit + @@ -724,7 +817,7 @@ export function FormPrompt(props: { form: FormWithLocation }) { {external().description} { if (renderer.getSelection()?.getSelectedText()) return openExternal() @@ -800,30 +893,32 @@ export function FormPrompt(props: { form: FormWithLocation }) { paddingRight={1} > {`${i() + 1}.`} - - {multi() ? `[${picked() ? "✓" : " "}] ${row.label}` : row.label} + + + [{picked() ? "✓" : " "}] + + + + {row.label} - {picked() ? " ✓" : ""} + {picked() ? " ✓" : ""} - + {row.description} @@ -845,55 +940,66 @@ export function FormPrompt(props: { form: FormWithLocation }) { backgroundColor={other() ? theme.background.formfield.focused : theme.background.default} paddingRight={1} > - + {`${rows().length + 1}.`} - - + + + [{customChecked() ? "✓" : " "}] + + + + + {input() || "Type your own answer"} + + + + + } > - {multi() ? `[${customPicked() ? "✓" : " "}] Type your own answer` : "Type your own answer"} - +