Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions docs/changes/0017-add-dialog-text-input-and-composition.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
2 changes: 1 addition & 1 deletion docs/index.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
24 changes: 21 additions & 3 deletions docs/manual/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -245,8 +252,15 @@ type Dialogs = {
readonly options: readonly {
readonly label: string
readonly value: T
readonly fields?: readonly TextField[]
}[]
}): Promise<T | undefined>
}): Promise<
| {
readonly value: T
readonly values: Readonly<Record<string, string>>
}
| undefined
>
}
```

Expand All @@ -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 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.

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.

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

Expand Down
3 changes: 2 additions & 1 deletion docs/specs/dialogs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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) |
Comment thread
fx marked this conversation as resolved.
Loading
Loading