From ba2193f88cea5474e9c4eb5b24626feba0db09ce Mon Sep 17 00:00:00 2001 From: Marian Rudzynski Date: Sat, 29 Aug 2026 09:20:02 -0700 Subject: [PATCH 1/6] feat(dialogs): compose select with text input Let a select option declare text fields, marking it user-provided: choosing it collects those values one at a time in declared order and submits them with the selection, so a list of known choices can offer "let me type it" without the caller stitching two dialogs together. Collection reuses the entry component from the standalone input dialog and stays inside the same render session, so terminal state is never restored between stages. The option list stops accepting navigation and selection the moment collection begins, and Escape or Ctrl-C at any stage cancels the whole dialog and discards collected values. select now resolves { value, values } rather than the bare value, which is how a caller tells a plain option (empty record) from a user-provided one. Empty field lists and repeated field names within one option are rejected before rendering. Completes change 0017. --- ...7-add-dialog-text-input-and-composition.md | 20 +- docs/index.md | 2 +- docs/index.yml | 2 +- docs/manual/plugins.md | 24 +- docs/specs/dialogs/index.md | 3 +- plugins/dialogs/index.ts | 217 +++++++++--- test/dialogs-plugin.test.ts | 315 +++++++++++++++++- 7 files changed, 513 insertions(+), 70 deletions(-) diff --git a/docs/changes/0017-add-dialog-text-input-and-composition.md b/docs/changes/0017-add-dialog-text-input-and-composition.md index 80c6ebc..8c679f6 100644 --- a/docs/changes/0017-add-dialog-text-input-and-composition.md +++ b/docs/changes/0017-add-dialog-text-input-and-composition.md @@ -5,7 +5,7 @@ Add a text `input` dialog to the bundled dialogs capability and let a `select` option be marked as user-provided, so choosing it collects the values it declares and submits them with the selection. [Dialogs](../specs/dialogs/) owns the observable behavior of both. **Specs:** [Dialogs](../specs/dialogs/) -**Status:** draft +**Status:** complete **Depends On:** [0016](./0016-add-plugin-capabilities-and-dialogs.md) ## Motivation @@ -112,15 +112,15 @@ Because the stages share one render session, cancellation, rendering failure, an - [x] Document the standalone `input` dialog in `docs/manual/plugins.md` - [x] Verify 100% coverage and `bun run check` -- [ ] Compose select with input through user-provided options - - [ ] Add the field declaration to `SelectOption` and change `select` to resolve a result carrying the chosen value and the collected values, updating existing callers and test consumers - - [ ] Reject an empty field list or a repeated field name within one option before rendering, alongside the existing empty-options and non-interactive rejections - - [ ] Collect a chosen option's fields sequentially in declared order within the same render session, reusing the entry component, threading each field's optional initial value into it, and refusing option navigation once collection has begun - - [ ] Resolve after the last field with the option's exact value and one collected value per field name, and resolve `undefined` discarding collected values when the user cancels at any stage - - [ ] Add controlled Bun tests for plain versus user-provided results, field ordering, multi-field collection, invalid field declarations, cancellation at each stage, and single-session cleanup - - [ ] Document composition and the new select result in `docs/manual/plugins.md` - - [ ] Keep the bundled plugin boundary and coverage gates passing - - [ ] Verify 100% coverage and `bun run check`, then set this document's status to complete and sync `docs/index.yml` and `docs/index.md` +- [x] Compose select with input through user-provided options + - [x] Add the field declaration to `SelectOption` and change `select` to resolve a result carrying the chosen value and the collected values, updating existing callers and test consumers + - [x] Reject an empty field list or a repeated field name within one option before rendering, alongside the existing empty-options and non-interactive rejections + - [x] Collect a chosen option's fields sequentially in declared order within the same render session, reusing the entry component, threading each field's optional initial value into it, and refusing option navigation once collection has begun + - [x] Resolve after the last field with the option's exact value and one collected value per field name, and resolve `undefined` discarding collected values when the user cancels at any stage + - [x] Add controlled Bun tests for plain versus user-provided results, field ordering, multi-field collection, invalid field declarations, cancellation at each stage, and single-session cleanup + - [x] Document composition and the new select result in `docs/manual/plugins.md` + - [x] Keep the bundled plugin boundary and coverage gates passing + - [x] Verify 100% coverage and `bun run check`, then set this document's status to complete and sync `docs/index.yml` and `docs/index.md` ## Open Questions diff --git a/docs/index.md b/docs/index.md index c4d085d..ef38005 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,4 +29,4 @@ | 0014 | [Pin Marketplace Versions](changes/0014-pin-marketplace-versions.md) | [Updates](specs/updates/) | draft | 0013 | | 0015 | [Update the tx Executable](changes/0015-update-the-tx-executable.md) | [Updates](specs/updates/) | complete | 0012 | | 0016 | [Add Plugin Capabilities and Dialogs](changes/0016-add-plugin-capabilities-and-dialogs.md) | [Dialogs](specs/dialogs/) | complete | — | -| 0017 | [Add Dialog Text Input and Composition](changes/0017-add-dialog-text-input-and-composition.md) | [Dialogs](specs/dialogs/) | draft | 0016 | +| 0017 | [Add Dialog Text Input and Composition](changes/0017-add-dialog-text-input-and-composition.md) | [Dialogs](specs/dialogs/) | complete | 0016 | diff --git a/docs/index.yml b/docs/index.yml index 6e5c8aa..98277e4 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -150,6 +150,6 @@ changes: path: changes/0017-add-dialog-text-input-and-composition.md description: Add a text input dialog and let a select option be marked as user-provided so choosing it collects and submits the values it declares. spec: dialogs - status: draft + status: complete depends_on: - "0016" diff --git a/docs/manual/plugins.md b/docs/manual/plugins.md index bbc394f..9328db5 100644 --- a/docs/manual/plugins.md +++ b/docs/manual/plugins.md @@ -235,6 +235,13 @@ This is not a dependency-injection or lifecycle container. There are no schemas, The namespace-free bundled dialogs provider registers one internal capability under the exact opaque key `dialogs`. Its current local structural shape is: ```ts +type TextField = { + readonly type: "text" + readonly name: string + readonly message: string + readonly initialValue?: string +} + type Dialogs = { input(request: { readonly message: string @@ -245,8 +252,15 @@ type Dialogs = { readonly options: readonly { readonly label: string readonly value: T + readonly fields?: readonly TextField[] }[] - }): Promise + }): Promise< + | { + readonly value: T + readonly values: Readonly> + } + | undefined + > } ``` @@ -256,11 +270,15 @@ Every dialog requires both the provider's injected standard input and standard e `select` additionally rejects an empty options list before rendering, and renders the message plus every label in supplied order. Labels are display text; values are opaque and returned by exact identity, with duplicates retained and the first option initially active. -Up and Down move one position and clamp at the list boundaries. Enter returns the active option's exact value. Escape and Ctrl-C return `undefined`; the provider does not terminate the process, assign an exit code, or print the selected value. Unrelated input is ignored. +Up and Down move one position and clamp at the list boundaries. Enter resolves `{ value, values }`, where `value` is the active option's exact value. Escape and Ctrl-C return `undefined`; the provider does not terminate the process, assign an exit code, or print the selected value. Unrelated input is ignored. + +An option that declares `fields` is user-provided: choosing it collects those values instead of resolving immediately, so one dialog can offer known choices alongside "let me type it". An option declaring no fields is plain and resolves with an empty `values` record, which is how a caller tells the two apart. `select` rejects an option whose field list is empty, or that repeats a field name within itself, before rendering — alongside the empty-options and non-interactive rejections. Field names need only be unique within their own option, and only the chosen option is ever collected. + +Fields are collected one at a time in declared order, each using the `input` behavior below, including its own `initialValue`. The next field appears only after the previous one is submitted, and the option list stops accepting navigation and selection the moment collection begins. After the last field, `select` resolves with the chosen option's exact value and one collected value per declared field, keyed by the field's name rather than by its displayed message. Escape or Ctrl-C at any stage cancels the whole dialog, resolves `undefined`, and discards everything already collected: there is no return to the option list, no back-navigation key, and no partial result. A field's `type` is the extension point for a later field kind; `text` is the only one that exists, and there is no form presenting several fields at once, no focus movement, and no validation — a caller validates what it receives. `input` collects a single line of text. It renders the message and the current value, starting from `initialValue` when one is supplied and from an empty value otherwise. Printable characters append in typed order; input arriving as one multi-character chunk, as a paste does, appends whole, minus any control characters it carries. Backspace drops the last character, counted by code point so a non-BMP character leaves whole, and does nothing when the value is empty. Any other input leaves the value unchanged: arrow keys, Tab, and Ctrl and Alt combinations append nothing. A control sequence Ink does not resolve to a key appends nothing when it arrives in the usual `CSI` form — Ink strips the leading escape before a handler sees it, so that case is recognized by shape, which is also why pasting exactly such a string, `[25~` on its own say, enters nothing. A modifier does not change what Enter, Escape, and Backspace themselves do, matching `select` — Alt-Enter still submits, and a double Escape still cancels. Enter returns the value exactly as entered, including the empty string, so an intentionally empty value stays distinguishable from the `undefined` that Escape and Ctrl-C return. The provider never trims, validates, or transforms the value and never writes it to standard output; whether an empty value is acceptable is the consuming command's decision. There is no caret movement, entry history, completion, or masking. -Both dialogs are built on the same render session and obey the same cleanup contract. Completion, cancellation, rendering failure, and interaction failure all finish renderer unmounting, restoration of the prior terminal/input state, listener teardown, and pending output before the promise fulfills or rejects. If an injected raw-mode disable, unref, or renderer unmount method persistently throws, the provider retries finitely and rejects with the first applicable cleanup failure; restoration or renderer teardown is necessarily best-effort only on that exceptional path. There is no non-interactive fallback, concurrency policy, nested-dialog support, or multi-provider selection policy. +Both dialogs are built on the same render session and obey the same cleanup contract, and a `select` that collects fields is one such session for the whole interaction: it does not unmount, restore terminal state, or settle between its selection and field stages. Completion, cancellation, rendering failure, and interaction failure all finish renderer unmounting, restoration of the prior terminal/input state, listener teardown, and pending output before the promise fulfills or rejects. If an injected raw-mode disable, unref, or renderer unmount method persistently throws, the provider retries finitely and rejects with the first applicable cleanup failure; restoration or renderer teardown is necessarily best-effort only on that exceptional path. There is no non-interactive fallback, concurrency policy, nested-dialog support, or multi-provider selection policy. ## One namespace per plugin diff --git a/docs/specs/dialogs/index.md b/docs/specs/dialogs/index.md index a4ed669..234351a 100644 --- a/docs/specs/dialogs/index.md +++ b/docs/specs/dialogs/index.md @@ -4,7 +4,7 @@ `tx` provides a bundled dialogs plugin for terminal interactions shared by its own plugins. The plugin MUST expose dialogs through the generic registry rather than through core vocabulary, and its contract MUST contain only a single-choice `select` dialog and a single-field text `input` dialog, which compose when a select option is marked as user-provided. -[Change 0016](../../changes/0016-add-plugin-capabilities-and-dialogs.md) implements the generic registry that carries the internal capability and the namespace-free bundled provider that supplies `select`. [Change 0017](../../changes/0017-add-dialog-text-input-and-composition.md) specifies the text `input` dialog and the user-provided option that composes the two; its first pull request implements the standalone `input` dialog, and the requirements below covering fields and user-provided options describe desired behavior that is not implemented yet. +[Change 0016](../../changes/0016-add-plugin-capabilities-and-dialogs.md) implements the generic registry that carries the internal capability and the namespace-free bundled provider that supplies `select`. [Change 0017](../../changes/0017-add-dialog-text-input-and-composition.md) implements the text `input` dialog and the user-provided option that composes the two. Every requirement below is implemented. ## Background @@ -284,3 +284,4 @@ The provider registers during initialization. Consumers read committed values in | 2026-08-22 | Implemented the namespace-free bundled provider and single-choice `select` | [0016-add-plugin-capabilities-and-dialogs](../../changes/0016-add-plugin-capabilities-and-dialogs.md) | | 2026-08-29 | Desired text `input` dialog, the field model, user-provided select options, and the select result carrying collected values | [0017-add-dialog-text-input-and-composition](../../changes/0017-add-dialog-text-input-and-composition.md) | | 2026-08-29 | Implemented the standalone text `input` dialog | [0017-add-dialog-text-input-and-composition](../../changes/0017-add-dialog-text-input-and-composition.md) | +| 2026-08-29 | Implemented user-provided select options, sequential field collection in one render session, and the `select` result carrying collected values | [0017-add-dialog-text-input-and-composition](../../changes/0017-add-dialog-text-input-and-composition.md) | diff --git a/plugins/dialogs/index.ts b/plugins/dialogs/index.ts index 0ba6bc5..0032711 100644 --- a/plugins/dialogs/index.ts +++ b/plugins/dialogs/index.ts @@ -8,9 +8,17 @@ import type { PluginIdentity, } from "@fx/tx/plugin"; +type TextField = { + readonly type: "text"; + readonly name: string; + readonly message: string; + readonly initialValue?: string; +}; + type SelectOption = { readonly label: string; readonly value: T; + readonly fields?: readonly TextField[]; }; type SelectRequest = { @@ -18,6 +26,11 @@ type SelectRequest = { readonly options: readonly SelectOption[]; }; +type SelectResult = { + readonly value: T; + readonly values: Readonly>; +}; + type InputRequest = { readonly message: string; readonly initialValue?: string; @@ -25,7 +38,7 @@ type InputRequest = { type Dialogs = { input(request: InputRequest): Promise; - select(request: SelectRequest): Promise; + select(request: SelectRequest): Promise | undefined>; }; type Outcome = @@ -236,6 +249,31 @@ function requireInteractiveStreams( } } +/** Rejects a declaration that could never be collected, before any terminal + * state changes: an option marked user-provided by an empty field list asks for + * nothing, and a repeated name would let one field overwrite another's value. + * Names only have to be unique within the option declaring them, because only + * one option is ever collected. */ +function requireCollectableFields( + options: readonly SelectOption[], +): void { + for (const { fields } of options) { + if (!fields) continue; + if (fields.length === 0) { + throw new Error( + "A user-provided select option requires at least one field", + ); + } + const names = new Set(); + for (const { name } of fields) { + if (names.has(name)) { + throw new Error(`A select option repeats the field name "${name}"`); + } + names.add(name); + } + } +} + /** A control sequence Ink did not resolve to a key, as it reaches a handler: * Ink strips the leading escape, leaving the introducer, any parameter and * intermediate bytes, and the final byte. Ink reports the sequences it knows @@ -260,7 +298,9 @@ function printableText(entry: string): string { return printable; } -type DialogView = () => ReturnType; +type DialogElement = ReturnType; + +type DialogView = () => DialogElement; type DialogSession = { readonly context: CommandContext; @@ -343,6 +383,64 @@ async function runDialog( return outcome?.type === "completed" ? outcome.value : undefined; } +type EntryProps = { + readonly message: string; + readonly initialValue: string | undefined; + readonly onSubmit: (value: string) => void; + readonly onCancel: () => void; +}; + +/** + * The one text entry implementation, used both by a standalone `input` and by + * each field of a chosen user-provided option, so entry, editing, submission, + * and cancellation behave identically in either place. Remounting it under a + * fresh key starts the next field from that field's own initial value. + */ +function createEntry( + react: CoreDependencies["react"], + ink: CoreDependencies["ink"], +) { + return function Entry({ + message, + initialValue, + onSubmit, + onCancel, + }: EntryProps) { + const entered = react.useRef(initialValue ?? ""); + const [value, setValue] = react.useState(entered.current); + ink.useInput((entry, key) => { + if (key.escape || (key.ctrl && entry === "c")) { + onCancel(); + } else if (key.return) { + onSubmit(entered.current); + } else if (key.backspace) { + entered.current = Array.from(entered.current).slice(0, -1).join(""); + setValue(entered.current); + } else if (!key.ctrl && !key.meta) { + const appended = printableText(entry); + if (appended.length > 0) { + entered.current += appended; + setValue(entered.current); + } + } + }); + + return react.createElement( + ink.Box, + { flexDirection: "column" }, + react.createElement(ink.Text, null, message), + react.createElement(ink.Text, null, value), + ); + }; +} + +/** The option a user-provided choice committed to, held while its fields are + * collected so a later navigation attempt cannot change what is submitted. */ +type Collection = { + readonly value: T; + readonly fields: readonly TextField[]; +}; + const identity: PluginIdentity = Object.freeze({ name: "dialogs" }); const definition: PluginDefinition = Object.freeze({ @@ -351,41 +449,21 @@ const definition: PluginDefinition = Object.freeze({ return ({ context, dependencies, register }) => { const { react, ink } = dependencies; const session: DialogSession = { context, dependencies }; + const Entry = createEntry(react, ink); const dialogs: Dialogs = { async input({ message, initialValue }: InputRequest) { requireInteractiveStreams(context, "An input dialog"); return runDialog(session, "Input", (settle) => { - const Input = () => { - const entered = react.useRef(initialValue ?? ""); - const [value, setValue] = react.useState(entered.current); - ink.useInput((entry, key) => { - if (key.escape || (key.ctrl && entry === "c")) { - settle({ type: "cancelled" }); - } else if (key.return) { - settle({ type: "completed", value: entered.current }); - } else if (key.backspace) { - entered.current = Array.from(entered.current) - .slice(0, -1) - .join(""); - setValue(entered.current); - } else if (!key.ctrl && !key.meta) { - const appended = printableText(entry); - if (appended.length > 0) { - entered.current += appended; - setValue(entered.current); - } - } + const Input = () => + react.createElement(Entry, { + message, + initialValue, + onSubmit: (value: string) => + settle({ type: "completed", value }), + onCancel: () => settle({ type: "cancelled" }), }); - - return react.createElement( - ink.Box, - { flexDirection: "column" }, - react.createElement(ink.Text, null, message), - react.createElement(ink.Text, null, value), - ); - }; return Input; }); }, @@ -394,18 +472,45 @@ const definition: PluginDefinition = Object.freeze({ if (options.length === 0) { throw new Error("A select dialog requires at least one option"); } + requireCollectableFields(options); requireInteractiveStreams(context, "A select dialog"); - return runDialog(session, "Select", (settle) => { + return runDialog>(session, "Select", (settle) => { + const cancel = () => settle({ type: "cancelled" }); + const Select = () => { const active = react.useRef(0); const [activeIndex, setActiveIndex] = react.useState(0); + /** Set the moment a user-provided option is chosen; its presence + * is what makes the option list stop accepting input. */ + const collecting = react.useRef | undefined>( + undefined, + ); + const collected = react.useRef>({}); + const field = react.useRef(0); + const [fieldIndex, setFieldIndex] = react.useState(-1); + ink.useInput((value, key) => { + // Ink delivers every key parsed out of one chunk in a single + // synchronous pass, so this list keeps handling input after the + // Enter that began collection. It must decline all of it. + if (collecting.current) return; if (key.escape || (key.ctrl && value === "c")) { - settle({ type: "cancelled" }); + cancel(); } else if (key.return) { const option = options[active.current] as SelectOption; - settle({ type: "completed", value: option.value }); + if (option.fields) { + collecting.current = { + value: option.value, + fields: option.fields, + }; + setFieldIndex(0); + } else { + settle({ + type: "completed", + value: { value: option.value, values: {} }, + }); + } } else if (key.upArrow) { active.current = Math.max(0, active.current - 1); setActiveIndex(active.current); @@ -418,17 +523,53 @@ const definition: PluginDefinition = Object.freeze({ } }); - return react.createElement( - ink.Box, - { flexDirection: "column" }, - react.createElement(ink.Text, null, message), + const submitField = (entered: string) => { + const collection = collecting.current as Collection; + const current = collection.fields[field.current] as TextField; + collected.current[current.name] = entered; + const next = field.current + 1; + if (next < collection.fields.length) { + field.current = next; + setFieldIndex(next); + } else { + settle({ + type: "completed", + value: { + value: collection.value, + values: { ...collected.current }, + }, + }); + } + }; + + const children: DialogElement[] = [ + react.createElement(ink.Text, { key: "message" }, message), ...options.map((option, index) => react.createElement( ink.Text, - { key: index }, + { key: `option-${index}` }, `${index === activeIndex ? ">" : " "} ${option.label}`, ), ), + ]; + if (fieldIndex >= 0) { + const collection = collecting.current as Collection; + const pending = collection.fields[fieldIndex] as TextField; + children.push( + react.createElement(Entry, { + key: `field-${fieldIndex}`, + message: pending.message, + initialValue: pending.initialValue, + onSubmit: submitField, + onCancel: cancel, + }), + ); + } + + return react.createElement( + ink.Box, + { flexDirection: "column" }, + ...children, ); }; return Select; diff --git a/test/dialogs-plugin.test.ts b/test/dialogs-plugin.test.ts index 3907b44..92874d2 100644 --- a/test/dialogs-plugin.test.ts +++ b/test/dialogs-plugin.test.ts @@ -9,9 +9,22 @@ import type { } from "../src/plugin.ts"; import { coreDependencies } from "../src/plugins.ts"; +type TextField = { + readonly type: "text"; + readonly name: string; + readonly message: string; + readonly initialValue?: string; +}; + type SelectOption = { readonly label: string; readonly value: T; + readonly fields?: readonly TextField[]; +}; + +type SelectResult = { + readonly value: T; + readonly values: Readonly>; }; type InputRequest = { @@ -24,7 +37,7 @@ type Dialogs = { select(request: { readonly message: string; readonly options: readonly SelectOption[]; - }): Promise; + }): Promise | undefined>; }; const ESCAPE = String.fromCharCode(27); @@ -238,6 +251,7 @@ async function runSelection( input: string | readonly string[], ): Promise<{ readonly value: T | undefined; + readonly values: Readonly> | undefined; readonly stdin: TerminalInput; readonly stderr: CapturedOutput; readonly stdout: string; @@ -247,13 +261,13 @@ async function runSelection( const stderr = new CapturedOutput(); const commandContext = context(stdin, stderr); const stdoutText = commandContext.stdoutText; - let value: T | undefined; + let result: SelectResult | undefined; const running = main( ["choose"], [ dialogsPlugin, consumer(async (dialogs) => { - value = await dialogs.select({ message: "Pick one", options }); + result = await dialogs.select({ message: "Pick one", options }); }), ], commandContext, @@ -264,7 +278,14 @@ async function runSelection( await new Promise((resolve) => setTimeout(resolve, 1)); } const exitCode = await running; - return { value, stdin, stderr, stdout: stdoutText(), exitCode }; + return { + value: result?.value, + values: result?.values, + stdin, + stderr, + stdout: stdoutText(), + exitCode, + }; } async function runEntry( @@ -383,6 +404,7 @@ describe("bundled dialogs provider", () => { expect(result.exitCode).toBe(0); expect(result.value).toBe(value); + expect(result.values).toEqual({}); expect(result.stdout).toBe(""); const output = result.stderr.text(); expect(output).toContain("Pick one"); @@ -681,16 +703,20 @@ describe("bundled dialogs provider", () => { dialogsPlugin, consumer(async (dialogs) => { values.push( - await dialogs.select({ - message: "First", - options: [{ label: "One", value: 1 }], - }), + ( + await dialogs.select({ + message: "First", + options: [{ label: "One", value: 1 }], + }) + )?.value, ); values.push( - await dialogs.select({ - message: "Second", - options: [{ label: "Two", value: 2 }], - }), + ( + await dialogs.select({ + message: "Second", + options: [{ label: "Two", value: 2 }], + }) + )?.value, ); }), ], @@ -720,10 +746,12 @@ describe("bundled dialogs provider", () => { [ dialogsPlugin, consumer(async (dialogs) => { - value = await dialogs.select({ - message: "Preserve source", - options: [{ label: "One", value: 1 }], - }); + value = ( + await dialogs.select({ + message: "Preserve source", + options: [{ label: "One", value: 1 }], + }) + )?.value; }), ], context(stdin, stderr), @@ -1327,3 +1355,258 @@ describe("bundled text input dialog", () => { expect(result.stdin.listenerCount("data")).toBe(0); }); }); + +describe("user-provided select options", () => { + const branch: TextField = { + type: "text", + name: "branch", + message: "Branch name", + }; + const owner: TextField = { type: "text", name: "owner", message: "Owner" }; + const repository: TextField = { + type: "text", + name: "repository", + message: "Repository", + }; + + function mixed(): readonly SelectOption[] { + return [ + { label: "Known", value: "known" }, + { label: "Custom", value: "custom", fields: [branch] }, + ]; + } + + test("tells a plain option apart from a user-provided one by its collected values", async () => { + const plain = await runSelection(mixed(), [CARRIAGE_RETURN]); + expect(plain.value).toBe("known"); + expect(plain.values).toEqual({}); + + const provided = await runSelection(mixed(), [ + `${ESCAPE}[B`, + CARRIAGE_RETURN, + "release", + CARRIAGE_RETURN, + ]); + expect(provided.value).toBe("custom"); + expect(provided.values).toEqual({ branch: "release" }); + const output = provided.stderr.text(); + expect(output).toContain("Branch name"); + expect(output).toContain("release"); + expect(provided.stdout).toBe(""); + }); + + test("prompts each field only after the previous one is submitted", async () => { + const stdin = new TerminalInput(); + const stderr = new CapturedOutput(); + let result: SelectResult | undefined; + const running = main( + ["choose"], + [ + dialogsPlugin, + consumer(async (dialogs) => { + result = await dialogs.select({ + message: "Pick one", + options: [ + { + label: "Custom", + value: "custom", + fields: [owner, repository], + }, + ], + }); + }), + ], + context(stdin, stderr), + ); + await until(() => stderr.text().includes("Custom")); + expect(stderr.text()).not.toContain("Owner"); + stdin.write(CARRIAGE_RETURN); + await until(() => stderr.text().includes("Owner")); + expect(stderr.text()).not.toContain("Repository"); + stdin.write("fx"); + await until(() => stderr.text().includes("fx")); + stdin.write(CARRIAGE_RETURN); + await until(() => stderr.text().includes("Repository")); + stdin.write("tx"); + await until(() => stderr.text().includes("tx")); + stdin.write(CARRIAGE_RETURN); + + expect(await running).toBe(0); + expect(result).toEqual({ + value: "custom", + values: { owner: "fx", repository: "tx" }, + }); + }); + + test("starts every collected field from its own initial value", async () => { + const accepted = await runSelection( + [ + { + label: "Custom", + value: "custom", + fields: [ + { ...owner, initialValue: "fx" }, + { ...repository, initialValue: "tx" }, + ], + }, + ], + [CARRIAGE_RETURN, CARRIAGE_RETURN, CARRIAGE_RETURN], + ); + expect(accepted.values).toEqual({ owner: "fx", repository: "tx" }); + + const edited = await runSelection( + [ + { + label: "Custom", + value: "custom", + fields: [{ ...branch, initialValue: "main" }], + }, + ], + [CARRIAGE_RETURN, BACKSPACE, "n", CARRIAGE_RETURN], + ); + expect(edited.values).toEqual({ branch: "main" }); + }); + + test("refuses option navigation once field collection has begun", async () => { + const result = await runSelection( + [ + { label: "Custom", value: "custom", fields: [branch] }, + { label: "Known", value: "known" }, + ], + [CARRIAGE_RETURN, `${ESCAPE}[B`, CARRIAGE_RETURN], + ); + + expect(result.value).toBe("custom"); + expect(result.values).toEqual({ branch: "" }); + expect(result.stderr.text()).not.toContain("> Known"); + }); + + test.each([ + ["Escape", ESCAPE], + ["Ctrl-C", CTRL_C], + ])( + "cancels the whole dialog with %s at the option stage and mid-collection", + async (_label, chunk) => { + const options: readonly SelectOption[] = [ + { + label: "Custom", + value: "custom", + fields: [owner, repository], + }, + ]; + + const atOptions = await runSelection(options, [chunk]); + expect(atOptions.value).toBeUndefined(); + expect(atOptions.values).toBeUndefined(); + expect(atOptions.exitCode).toBe(0); + + const atFirstField = await runSelection(options, [ + CARRIAGE_RETURN, + chunk, + ]); + expect(atFirstField.value).toBeUndefined(); + expect(atFirstField.values).toBeUndefined(); + + const midCollection = await runSelection(options, [ + CARRIAGE_RETURN, + "fx", + CARRIAGE_RETURN, + chunk, + ]); + expect(midCollection.value).toBeUndefined(); + expect(midCollection.values).toBeUndefined(); + expect(midCollection.exitCode).toBe(0); + expect(midCollection.stdin.rawModes).toEqual([true, false]); + expect(process.exitCode).not.toBe(1); + }, + ); + + test("rejects an invalid field declaration before rendering or terminal changes", async () => { + for (const [fields, message] of [ + [[], "A user-provided select option requires at least one field"], + [ + [owner, { ...owner, message: "Owner again" }], + 'A select option repeats the field name "owner"', + ], + ] as const) { + const result = await runRejected([ + { label: "Known", value: 1 }, + { label: "Custom", value: 2, fields }, + ]); + + expect(result.exitCode).toBe(0); + expect(result.failure).toBeInstanceOf(Error); + expect((result.failure as Error).message).toBe(message); + expect(result.stderr.text()).toBe(""); + expect(result.stdin.rawModes).toEqual([]); + } + }); + + test("collects every stage in one render session without tearing down between them", async () => { + const stdin = new TerminalInput(); + const stderr = new CapturedOutput(); + let renders = 0; + let unmounts = 0; + const ink: CoreDependencies["ink"] = { + ...coreDependencies.ink, + render(...args: Parameters) { + renders++; + const renderer = coreDependencies.ink.render(...args); + return { + ...renderer, + unmount() { + unmounts++; + renderer.unmount(); + }, + }; + }, + }; + let result: SelectResult | undefined; + const running = main( + ["choose"], + [ + dialogsPlugin, + consumer(async (dialogs) => { + result = await dialogs.select({ + message: "Pick one", + options: [ + { + label: "Custom", + value: "custom", + fields: [owner, repository], + }, + ], + }); + }), + ], + context(stdin, stderr), + { ...coreDependencies, ink }, + ); + await until(() => stdin.rawModes.includes(true)); + for (const chunk of [ + CARRIAGE_RETURN, + "fx", + CARRIAGE_RETURN, + "tx", + CARRIAGE_RETURN, + ]) { + expect(unmounts).toBe(0); + expect(stdin.rawModes).toEqual([true]); + stdin.write(chunk); + await new Promise((resolve) => setTimeout(resolve, 1)); + } + + expect(await running).toBe(0); + expect(result).toEqual({ + value: "custom", + values: { owner: "fx", repository: "tx" }, + }); + expect(renders).toBe(1); + expect(unmounts).toBe(1); + expect(stdin.rawModes).toEqual([true, false]); + expect(stdin.refs).toBe(1); + expect(stdin.unrefs).toBe(1); + expect(stdin.activeReferences).toBe(0); + expect(stdin.listenerCount("data")).toBe(0); + }); +}); From 0c45fc3396e20ccbef846dfd3e80a01420438de1 Mon Sep 17 00:00:00 2001 From: Marian Rudzynski Date: Sat, 29 Aug 2026 09:22:03 -0700 Subject: [PATCH 2/6] test(dialogs): pin collected values to field names rather than messages --- test/dialogs-plugin.test.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/test/dialogs-plugin.test.ts b/test/dialogs-plugin.test.ts index 92874d2..3726847 100644 --- a/test/dialogs-plugin.test.ts +++ b/test/dialogs-plugin.test.ts @@ -1362,11 +1362,17 @@ describe("user-provided select options", () => { name: "branch", message: "Branch name", }; - const owner: TextField = { type: "text", name: "owner", message: "Owner" }; + // Messages deliberately share no text with their names, so a result keyed by + // the displayed message instead of the field name cannot pass. + const owner: TextField = { + type: "text", + name: "owner", + message: "Which account?", + }; const repository: TextField = { type: "text", name: "repository", - message: "Repository", + message: "Which project?", }; function mixed(): readonly SelectOption[] { @@ -1419,14 +1425,14 @@ describe("user-provided select options", () => { context(stdin, stderr), ); await until(() => stderr.text().includes("Custom")); - expect(stderr.text()).not.toContain("Owner"); + expect(stderr.text()).not.toContain(owner.message); stdin.write(CARRIAGE_RETURN); - await until(() => stderr.text().includes("Owner")); - expect(stderr.text()).not.toContain("Repository"); + await until(() => stderr.text().includes(owner.message)); + expect(stderr.text()).not.toContain(repository.message); stdin.write("fx"); await until(() => stderr.text().includes("fx")); stdin.write(CARRIAGE_RETURN); - await until(() => stderr.text().includes("Repository")); + await until(() => stderr.text().includes(repository.message)); stdin.write("tx"); await until(() => stderr.text().includes("tx")); stdin.write(CARRIAGE_RETURN); @@ -1459,12 +1465,13 @@ describe("user-provided select options", () => { { label: "Custom", value: "custom", - fields: [{ ...branch, initialValue: "main" }], + fields: [{ ...branch, initialValue: "origin" }], }, ], - [CARRIAGE_RETURN, BACKSPACE, "n", CARRIAGE_RETURN], + [CARRIAGE_RETURN, BACKSPACE, "-2", CARRIAGE_RETURN], ); - expect(edited.values).toEqual({ branch: "main" }); + expect(edited.values).toEqual({ branch: "origi-2" }); + expect(edited.stderr.text()).toContain("origi-2"); }); test("refuses option navigation once field collection has begun", async () => { From 0bde6c115d4fa43143cb757f377adb0579ea9ae4 Mon Sep 17 00:00:00 2001 From: Marian Rudzynski Date: Sat, 29 Aug 2026 09:22:28 -0700 Subject: [PATCH 3/6] docs(plugins): scope the select Enter result to a plain option --- docs/manual/plugins.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/manual/plugins.md b/docs/manual/plugins.md index 9328db5..7974528 100644 --- a/docs/manual/plugins.md +++ b/docs/manual/plugins.md @@ -270,7 +270,7 @@ Every dialog requires both the provider's injected standard input and standard e `select` additionally rejects an empty options list before rendering, and renders the message plus every label in supplied order. Labels are display text; values are opaque and returned by exact identity, with duplicates retained and the first option initially active. -Up and Down move one position and clamp at the list boundaries. Enter resolves `{ value, values }`, where `value` is the active option's exact value. Escape and Ctrl-C return `undefined`; the provider does not terminate the process, assign an exit code, or print the selected value. Unrelated input is ignored. +Up and Down move one position and clamp at the list boundaries. Enter on a plain option resolves `{ value, values }`, where `value` is that option's exact value and `values` is empty. Escape and Ctrl-C return `undefined`; the provider does not terminate the process, assign an exit code, or print the selected value. Unrelated input is ignored. An option that declares `fields` is user-provided: choosing it collects those values instead of resolving immediately, so one dialog can offer known choices alongside "let me type it". An option declaring no fields is plain and resolves with an empty `values` record, which is how a caller tells the two apart. `select` rejects an option whose field list is empty, or that repeats a field name within itself, before rendering — alongside the empty-options and non-interactive rejections. Field names need only be unique within their own option, and only the chosen option is ever collected. From 87d94bf674c8b076ecba4282984817b7281902a8 Mon Sep 17 00:00:00 2001 From: Marian Rudzynski Date: Sat, 29 Aug 2026 09:36:03 -0700 Subject: [PATCH 4/6] fix(dialogs): answer cancellation and opaque field names during collection Two defects found reviewing the composition change: Ctrl-C was dropped when Ink delivered it in the same synchronous pass as the Enter that began field collection: the option list had already committed to the option, and the field entry had not mounted yet, so neither handler answered it and the dialog stayed open. The list now answers cancellation before declining everything else, so Escape and Ctrl-C cancel at every stage including that window. A field named __proto__ reached the inherited setter on the accumulator and its value vanished, so the result carried fewer names than the option declared. The accumulator is prototype-free, and the spread that produces the result defines rather than assigns, so any opaque name survives as an own property. --- docs/manual/plugins.md | 2 +- plugins/dialogs/index.ts | 21 ++++++++++---- test/dialogs-plugin.test.ts | 55 +++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 7 deletions(-) diff --git a/docs/manual/plugins.md b/docs/manual/plugins.md index 7974528..b7bf162 100644 --- a/docs/manual/plugins.md +++ b/docs/manual/plugins.md @@ -274,7 +274,7 @@ Up and Down move one position and clamp at the list boundaries. Enter on a plain An option that declares `fields` is user-provided: choosing it collects those values instead of resolving immediately, so one dialog can offer known choices alongside "let me type it". An option declaring no fields is plain and resolves with an empty `values` record, which is how a caller tells the two apart. `select` rejects an option whose field list is empty, or that repeats a field name within itself, before rendering — alongside the empty-options and non-interactive rejections. Field names need only be unique within their own option, and only the chosen option is ever collected. -Fields are collected one at a time in declared order, each using the `input` behavior below, including its own `initialValue`. The next field appears only after the previous one is submitted, and the option list stops accepting navigation and selection the moment collection begins. After the last field, `select` resolves with the chosen option's exact value and one collected value per declared field, keyed by the field's name rather than by its displayed message. Escape or Ctrl-C at any stage cancels the whole dialog, resolves `undefined`, and discards everything already collected: there is no return to the option list, no back-navigation key, and no partial result. A field's `type` is the extension point for a later field kind; `text` is the only one that exists, and there is no form presenting several fields at once, no focus movement, and no validation — a caller validates what it receives. +Fields are collected one at a time in declared order, each using the `input` behavior below, including its own `initialValue`. The next field appears only after the previous one is submitted, and the option list stops accepting navigation and selection the moment collection begins. After the last field, `select` resolves with the chosen option's exact value and one collected value per declared field, keyed by the field's name rather than by its displayed message. A name is an opaque key and may be any string, including one that shadows an inherited object property such as `__proto__`; the collected record carries it as an own property either way. Escape or Ctrl-C at any stage cancels the whole dialog, resolves `undefined`, and discards everything already collected: there is no return to the option list, no back-navigation key, and no partial result. A field's `type` is the extension point for a later field kind; `text` is the only one that exists, and there is no form presenting several fields at once, no focus movement, and no validation — a caller validates what it receives. `input` collects a single line of text. It renders the message and the current value, starting from `initialValue` when one is supplied and from an empty value otherwise. Printable characters append in typed order; input arriving as one multi-character chunk, as a paste does, appends whole, minus any control characters it carries. Backspace drops the last character, counted by code point so a non-BMP character leaves whole, and does nothing when the value is empty. Any other input leaves the value unchanged: arrow keys, Tab, and Ctrl and Alt combinations append nothing. A control sequence Ink does not resolve to a key appends nothing when it arrives in the usual `CSI` form — Ink strips the leading escape before a handler sees it, so that case is recognized by shape, which is also why pasting exactly such a string, `[25~` on its own say, enters nothing. A modifier does not change what Enter, Escape, and Backspace themselves do, matching `select` — Alt-Enter still submits, and a double Escape still cancels. Enter returns the value exactly as entered, including the empty string, so an intentionally empty value stays distinguishable from the `undefined` that Escape and Ctrl-C return. The provider never trims, validates, or transforms the value and never writes it to standard output; whether an empty value is acceptable is the consuming command's decision. There is no caret movement, entry history, completion, or masking. diff --git a/plugins/dialogs/index.ts b/plugins/dialogs/index.ts index 0032711..1b8beb9 100644 --- a/plugins/dialogs/index.ts +++ b/plugins/dialogs/index.ts @@ -486,18 +486,27 @@ const definition: PluginDefinition = Object.freeze({ const collecting = react.useRef | undefined>( undefined, ); - const collected = react.useRef>({}); + /** Prototype-free, because a field name is an opaque caller key: + * `__proto__` would otherwise reach the inherited setter and the + * value would vanish instead of being collected. */ + const collected = react.useRef>( + Object.create(null) as Record, + ); const field = react.useRef(0); const [fieldIndex, setFieldIndex] = react.useState(-1); ink.useInput((value, key) => { - // Ink delivers every key parsed out of one chunk in a single - // synchronous pass, so this list keeps handling input after the - // Enter that began collection. It must decline all of it. - if (collecting.current) return; if (key.escape || (key.ctrl && value === "c")) { cancel(); - } else if (key.return) { + return; + } + // Ink delivers every key parsed out of one chunk in a single + // synchronous pass, so this list keeps receiving input after + // the Enter that began collection, before the field entry has + // mounted. Everything but cancellation is declined from then + // on; cancellation is answered above, at every stage. + if (collecting.current) return; + if (key.return) { const option = options[active.current] as SelectOption; if (option.fields) { collecting.current = { diff --git a/test/dialogs-plugin.test.ts b/test/dialogs-plugin.test.ts index 3726847..b99e990 100644 --- a/test/dialogs-plugin.test.ts +++ b/test/dialogs-plugin.test.ts @@ -1528,6 +1528,61 @@ describe("user-provided select options", () => { }, ); + test("collects a field whose name shadows an inherited property", async () => { + const result = await runSelection( + [ + { + label: "Custom", + value: "custom", + fields: [ + { type: "text", name: "__proto__", message: "Which prototype?" }, + { type: "text", name: "constructor", message: "Which builder?" }, + { type: "text", name: "toString", message: "Which printer?" }, + ], + }, + ], + [ + CARRIAGE_RETURN, + "a", + CARRIAGE_RETURN, + "b", + CARRIAGE_RETURN, + "c", + CARRIAGE_RETURN, + ], + ); + + const values = result.values as Readonly>; + const own = (name: string) => + Object.getOwnPropertyDescriptor(values, name)?.value; + expect(Object.keys(values)).toEqual([ + "__proto__", + "constructor", + "toString", + ]); + expect(own("__proto__")).toBe("a"); + expect(own("constructor")).toBe("b"); + expect(own("toString")).toBe("c"); + }); + + test.each([ + ["Escape", ESCAPE], + ["Ctrl-C", CTRL_C], + ])( + "cancels with %s delivered in the same chunk as the Enter that began collection", + async (_label, chunk) => { + const result = await runSelection( + [{ label: "Custom", value: "custom", fields: [owner] }], + [`${CARRIAGE_RETURN}${BACKSPACE}${chunk}`], + ); + + expect(result.exitCode).toBe(0); + expect(result.value).toBeUndefined(); + expect(result.values).toBeUndefined(); + expect(result.stdin.rawModes).toEqual([true, false]); + }, + ); + test("rejects an invalid field declaration before rendering or terminal changes", async () => { for (const [fields, message] of [ [[], "A user-provided select option requires at least one field"], From 2107de46e6b49de0f5ee5f06f83515c080443c32 Mon Sep 17 00:00:00 2001 From: Marian Rudzynski Date: Sat, 29 Aug 2026 09:48:24 -0700 Subject: [PATCH 5/6] fix(dialogs): decline input aimed at an already submitted field A submitted field's entry stays mounted for the rest of the synchronous pass Ink dispatches one stdin chunk in, so a further Enter in that chunk answered the next field before it was ever presented, and did it with the previous entry's value. Each entry now submits under the field index it was rendered for, and a submission for a field the dialog has already moved past is declined. --- plugins/dialogs/index.ts | 10 ++++++++-- test/dialogs-plugin.test.ts | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/plugins/dialogs/index.ts b/plugins/dialogs/index.ts index 1b8beb9..d4be7a2 100644 --- a/plugins/dialogs/index.ts +++ b/plugins/dialogs/index.ts @@ -532,7 +532,12 @@ const definition: PluginDefinition = Object.freeze({ } }); - const submitField = (entered: string) => { + /** `index` is the field the submitting entry was rendered for. + * The entry stays mounted for the rest of the synchronous pass + * that submitted it, so a later Enter in the same chunk would + * otherwise answer the next field before it is presented. */ + const submitField = (index: number, entered: string) => { + if (index !== field.current) return; const collection = collecting.current as Collection; const current = collection.fields[field.current] as TextField; collected.current[current.name] = entered; @@ -569,7 +574,8 @@ const definition: PluginDefinition = Object.freeze({ key: `field-${fieldIndex}`, message: pending.message, initialValue: pending.initialValue, - onSubmit: submitField, + onSubmit: (entered: string) => + submitField(fieldIndex, entered), onCancel: cancel, }), ); diff --git a/test/dialogs-plugin.test.ts b/test/dialogs-plugin.test.ts index b99e990..53e3ac1 100644 --- a/test/dialogs-plugin.test.ts +++ b/test/dialogs-plugin.test.ts @@ -1583,6 +1583,23 @@ describe("user-provided select options", () => { }, ); + test("declines input aimed at a field that was already submitted", async () => { + const result = await runSelection( + [{ label: "Custom", value: "custom", fields: [owner, repository] }], + [ + CARRIAGE_RETURN, + "a", + `${CARRIAGE_RETURN}${BACKSPACE}${CARRIAGE_RETURN}`, + "b", + CARRIAGE_RETURN, + ], + ); + + expect(result.value).toBe("custom"); + expect(result.values).toEqual({ owner: "a", repository: "b" }); + expect(result.stderr.text()).toContain(repository.message); + }); + test("rejects an invalid field declaration before rendering or terminal changes", async () => { for (const [fields, message] of [ [[], "A user-provided select option requires at least one field"], From c3ded2216c47b97f0bec8a9889d879cfc289a1ab Mon Sep 17 00:00:00 2001 From: Marian Rudzynski Date: Sat, 29 Aug 2026 09:49:09 -0700 Subject: [PATCH 6/6] test(dialogs): pin settlement against input carried past it in one chunk --- test/dialogs-plugin.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/dialogs-plugin.test.ts b/test/dialogs-plugin.test.ts index 53e3ac1..a264a63 100644 --- a/test/dialogs-plugin.test.ts +++ b/test/dialogs-plugin.test.ts @@ -1600,6 +1600,27 @@ describe("user-provided select options", () => { expect(result.stderr.text()).toContain(repository.message); }); + test("keeps the first settlement when one chunk carries input past it", async () => { + const past = `${CARRIAGE_RETURN}${BACKSPACE}${CARRIAGE_RETURN}`; + + const afterLastField = await runSelection( + [{ label: "Custom", value: "custom", fields: [owner] }], + [CARRIAGE_RETURN, "a", past], + ); + expect(afterLastField.value).toBe("custom"); + expect(afterLastField.values).toEqual({ owner: "a" }); + + const afterPlainOption = await runSelection( + [ + { label: "Known", value: "known" }, + { label: "Other", value: "other" }, + ], + [past], + ); + expect(afterPlainOption.value).toBe("known"); + expect(afterPlainOption.values).toEqual({}); + }); + test("rejects an invalid field declaration before rendering or terminal changes", async () => { for (const [fields, message] of [ [[], "A user-provided select option requires at least one field"],