From 900bf7af9a4714294ea79c6d68349db172394653 Mon Sep 17 00:00:00 2001 From: Emmz Rendle Date: Sun, 31 May 2026 13:25:39 +0100 Subject: [PATCH 1/5] docs(back-nav-input): add change proposal, design, spec delta & tasks Propose InputRequest.AllowBack with Backspace-on-empty Back trigger, modifying the fixed-region "Awaitable modal dialogs" requirement. Co-Authored-By: Claude Opus 4.8 --- .../changes/back-nav-input/.openspec.yaml | 2 + openspec/changes/back-nav-input/design.md | 101 +++++++++++++++ openspec/changes/back-nav-input/proposal.md | 49 +++++++ .../back-nav-input/specs/fixed-region/spec.md | 122 ++++++++++++++++++ openspec/changes/back-nav-input/tasks.md | 44 +++++++ 5 files changed, 318 insertions(+) create mode 100644 openspec/changes/back-nav-input/.openspec.yaml create mode 100644 openspec/changes/back-nav-input/design.md create mode 100644 openspec/changes/back-nav-input/proposal.md create mode 100644 openspec/changes/back-nav-input/specs/fixed-region/spec.md create mode 100644 openspec/changes/back-nav-input/tasks.md diff --git a/openspec/changes/back-nav-input/.openspec.yaml b/openspec/changes/back-nav-input/.openspec.yaml new file mode 100644 index 0000000..927e3e8 --- /dev/null +++ b/openspec/changes/back-nav-input/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-31 diff --git a/openspec/changes/back-nav-input/design.md b/openspec/changes/back-nav-input/design.md new file mode 100644 index 0000000..35b351d --- /dev/null +++ b/openspec/changes/back-nav-input/design.md @@ -0,0 +1,101 @@ +# Design — back-nav-input + +## Context + +The awaitable-dialog family (`SelectAsync`, `ChoiceAsync`, `MultiSelectAsync`, `InputAsync`) shares a +single close-outcome model: `OverlayCloseKind` (Submit / Back / Cancel) at the overlay layer, +mapped to `DialogOutcome` (Submitted / Back / Cancelled) by `Terminal.OpenModalAsync`. Three of the +four request types already expose an opt-in `AllowBack` flag; `InputRequest` does not. This change +closes that one gap. The `OverlayCloseKind.Back` plumbing (overlay → `OpenModalAsync` → `DialogResult`) +already exists and is exercised by the list dialogs, so the work is confined to the request record, +the input overlay, the `InputAsync` wiring, and one stale doc comment. + +## Goals / Non-Goals + +**Goals** + +- Add `InputRequest.AllowBack` (opt-in, default `false`, source-compatible). +- Emit `DialogOutcome.Back` from the input dialog on **Backspace while the field is currently empty** + when `AllowBack=true`. +- Keep the family's doc surface honest (`DialogOutcome.Back` remarks). + +**Non-Goals** + +- No change to multi-select (`[`-based Back already shipped in rc.3). +- No new `Terminal` API beyond the `AllowBack` pass-through. +- No change to the `OverlayCloseKind` → `DialogOutcome` mapping in `OpenModalAsync` (already generic). + +## Decisions + +### Decision 1 — The unifying family rule: "Backspace when there's nothing left to delete → Back" + +`SelectRequest`/`ChoiceRequest` bind **Backspace-before-movement** to Back. `MultiSelectRequest` uses +`[` because Space-toggle interplay makes a Backspace-position heuristic unreliable. For a text field +the natural analogue is **Backspace-on-empty**: in an empty field Backspace is otherwise a no-op, so +rebinding it is unambiguous and discoverable — the user is already pressing the "go back / delete the +last thing" key, and there is nothing left to delete. This keeps the whole family under one mental +model: *Backspace when there's nothing left to delete → Back.* + +- **Alternative — `[` as the Back key (as in multi-select):** rejected. `[` is a literal character + users routinely type into a free-text field (URLs, JSON, array indices, regexes, keys). Binding it + would steal a printable character and corrupt legitimate input. Multi-select can afford `[` because + it has no text buffer; the input dialog cannot. +- **Alternative — restrict Back to a pristine, never-edited field:** rejected. The trigger is + **currently empty**, not **never edited**. Typing text and then deleting back to empty must still arm + Back — a user who clears the field and presses Backspace once more clearly intends to leave it. Gating + on edit history would make the behaviour depend on invisible state and surprise the user. + +### Decision 2 — Trigger on buffer emptiness, not on `_userEdited` + +`InputDialog` already tracks `_userEdited` (sticky, set by any insert/Backspace/Delete) for masking +semantics. The Back trigger MUST **not** use `_userEdited`; it MUST check the live buffer text length +(`Text.Length == 0`). The Backspace handler runs before any mutation, so the check is "is the field +empty *at the moment Backspace is pressed*". When `AllowBack=true` and the buffer is empty, set +`CloseRequest = OverlayCloseKind.Back` and return consumed; otherwise fall through to the existing +`_buffer.Backspace()` delete path (which sets `_userEdited`). When `AllowBack=false`, Backspace-on-empty +remains a harmless no-op exactly as today. + +### Decision 3 — Place the Back branch inside the existing Backspace case + +The new branch lives in the `NamedKey.Backspace` arm of `HandleKey`, guarded by +`_allowBack && _buffer.Text.Length == 0`. Enter (Submit) and Escape (Cancel) precedence is unchanged +and sits above it; printable-rune insertion is unaffected. Ctrl/Alt-modified Backspace continues to +fall to the modal catch-all (the existing Ctrl/Alt gate runs before the named-key switch), so +`Ctrl+Backspace` never triggers Back — consistent with the printable-key gate. + +### Decision 4 — Constructor surface mirrors `MultiSelectRequest` + +Add `bool AllowBack = false` as the trailing parameter of the primary constructor and of the +convenience overloads that already accept `Default`/`IsSecret` (the `Line?`, `string?`, and +`IReadOnlyList?` forms). The two `params`-tail overloads (`params Line[]`, `params string[]`) +keep their current signatures — `params` must be the last parameter, so `AllowBack` cannot be added +there without a breaking reshape; callers needing `AllowBack` use one of the non-`params` overloads, +exactly as documented for `MultiSelectRequest`. All defaults stay `false`, so existing call sites +recompile unchanged. + +### Decision 5 — `DialogOutcome.Back` docs become family-accurate + +The enum-member and `` text currently name only `SelectAsync`/`ChoiceAsync` and describe +only the Backspace-before-movement trigger. Update them to enumerate all four producers and each +trigger: Backspace-before-movement (Select/Choice), `[` (MultiSelect), Backspace-on-empty (Input). +This is a doc-only edit but is in-scope because the change makes the old text actively wrong. + +## Risks / Trade-offs + +- **A user who clears a pre-filled (`Default`) field and presses Backspace gets Back, not a no-op.** + → Intended under Decision 1; this is the discoverable affordance. Consumers that do not want Back + simply leave `AllowBack=false` (the default). +- **Secret fields (`IsSecret=true`) with `AllowBack=true`:** Backspace-on-empty still arms Back. No + special-casing — the masking layer only affects rendering, not the buffer length the trigger reads. +- **Existing tests assume Backspace-on-empty is a no-op.** → Those tests construct the dialog with the + default `AllowBack=false`, so behaviour is unchanged; the Back path only activates on opt-in. + +## Migration Plan + +Purely additive. No migration for existing consumers — `AllowBack` defaults to `false` everywhere. +Ship as rc.4. Downstream (dmon) adopts by bumping the dcli reference and setting `AllowBack = true` +on input steps in wizard flows. + +## Open Questions + +None — the brief settled the key bindings and the trigger semantics. diff --git a/openspec/changes/back-nav-input/proposal.md b/openspec/changes/back-nav-input/proposal.md new file mode 100644 index 0000000..67a507b --- /dev/null +++ b/openspec/changes/back-nav-input/proposal.md @@ -0,0 +1,49 @@ +# Add back-navigation to the free-text input dialog + +## Why + +`InputRequest` is the only awaitable-dialog request type still missing `AllowBack` — +`SelectRequest`, `ChoiceRequest`, and `MultiSelectRequest` all expose it (multi-select shipped +in `api-ergonomics-pass-2`, rc.3). This blocks wizard flows that interleave text entry with +selection: a consumer can let the user step back through select/choice steps, but the flow hits a +dead end on any `InputAsync` step because the input dialog has no way to emit `DialogOutcome.Back`. + +## What Changes + +- Add an opt-in `bool AllowBack = false` parameter to `InputRequest` — on the primary constructor + and on the convenience overloads that already carry `Default`/`IsSecret` (the `params`-tail + overloads keep their existing signature, mirroring how `MultiSelectRequest` handles `params`). + Default `false` keeps every existing caller source-compatible. +- `InputDialog` SHALL emit `DialogOutcome.Back` when `AllowBack=true` **and** Backspace is pressed + while the input field is **currently empty** (text length zero), regardless of edit history. + Backspace with any text present deletes as normal. The result `Value` is `default` on Back, + consistent with the other dialogs. +- Thread `AllowBack` through `Terminal.InputAsync` into the dialog (mirroring `SelectAsync`'s + `allowBack: req.AllowBack` wiring). `OpenModalAsync` already maps `OverlayCloseKind.Back` to + `DialogOutcome.Back` generically, so no plumbing changes are needed below the dialog. +- Fix the now-stale `DialogOutcome.Back` XML doc remarks, which still say Back is "produced by + `SelectAsync` and `ChoiceAsync`" — update to include `MultiSelectAsync` and `InputAsync` and to + note each type's trigger. +- Bump the package version rc.3 → rc.4 (public-API addition). + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- **fixed-region** — the "Awaitable modal dialogs" requirement (which specifies the opt-in + `AllowBack` family rule for Select/Choice/MultiSelect) is extended to cover `InputRequest` and + the input dialog's Backspace-on-empty Back trigger. + +## Impact + +- **Public API (additive, non-breaking):** `InputRequest.AllowBack`; refreshed `DialogOutcome.Back` + XML docs. +- **Affected code:** `src/Dcli/DialogRequests.cs` (`InputRequest`), `src/Dcli/DialogOutcome.cs` + (docs), `src/Dcli/Internal/FixedRegion/InputDialog.cs` (Backspace-on-empty Back branch), + `src/Dcli/Terminal.cs` (`InputAsync` wiring), `src/Dcli/Dcli.csproj` (version). +- **Consumers:** unblocks the dmon wizard adoption (bump dcli, wire `MultiSelectAsync`, flip + `AllowBack = true` on input steps). diff --git a/openspec/changes/back-nav-input/specs/fixed-region/spec.md b/openspec/changes/back-nav-input/specs/fixed-region/spec.md new file mode 100644 index 0000000..514a33e --- /dev/null +++ b/openspec/changes/back-nav-input/specs/fixed-region/spec.md @@ -0,0 +1,122 @@ +## MODIFIED Requirements + +### Requirement: Awaitable modal dialogs + +The library SHALL expose select, multi-select, input, and choice dialogs as awaitable operations that return a `DialogResult` whose outcome is `Submitted`, `Back`, or `Cancelled`. + +Each dialog request type SHALL carry an optional **multi-line preamble** rendered top-to-bottom above the interactive widget within the overlay: + +- `SelectRequest.Title` SHALL be typed `IReadOnlyList?` and SHALL render as a sequence of styled rows above the list items. +- `MultiSelectRequest.Title` SHALL be typed `IReadOnlyList?` and SHALL render as a sequence of styled rows above the list items. +- `ChoiceRequest.Prompt` SHALL be typed `IReadOnlyList?` and SHALL render as a sequence of styled rows above the options. +- `InputRequest.Prompt` SHALL be typed `IReadOnlyList?` and SHALL render as a sequence of styled rows above the input field. + +Each request type SHALL expose backwards-compatible convenience constructors that accept a single `Line`, a single `string` (converted via `Line.FromText`), an `IReadOnlyList`, a `params Line[]`, an `IReadOnlyList`, or a `params string[]` for the preamble. Single-`Line` and single-`string` forms SHALL be internally equivalent to passing a one-element list. When the preamble is `null` or empty, no preamble row SHALL be painted and the full overlay budget SHALL be available to the interactive widget. + +`SelectRequest`, `ChoiceRequest`, `MultiSelectRequest`, and `InputRequest` SHALL each expose an opt-in `AllowBack` flag (default `false`, backward-compatible). When `AllowBack=false`, no key produces `Back` and existing v1 behaviour is preserved. When `AllowBack=true`: + +- `SelectRequest` and `ChoiceRequest` SHALL produce `DialogOutcome.Back` when **Backspace** is pressed before the selection is moved (the binding introduced in `api-ergonomics-pass-1`), and SHALL additionally accept **`[`** as a secondary Back key with no movement-suppression. +- `MultiSelectRequest` SHALL produce `DialogOutcome.Back` when **`[`** is pressed at any time, regardless of whether items have been toggled. Multi-select SHALL NOT bind Backspace to `Back` — Space-toggle and Backspace interplay makes a Backspace-position heuristic unreliable, so a distinct key (`[`) is used instead. +- `InputRequest` SHALL produce `DialogOutcome.Back` when **Backspace** is pressed while the input field is currently empty (text length zero), regardless of edit history (typing then deleting back to empty SHALL still arm Back). Backspace with any text present SHALL delete the character before the caret as normal and SHALL NOT produce `Back`. Input SHALL NOT bind `[` to `Back` — `[` is a literal character users type into free-text fields (URLs, JSON, keys), so rebinding it would corrupt legitimate input. + +For every request type, `DialogResult.Value` SHALL be `default` when the outcome is `Back`. + +#### Scenario: Select submitted + +- **WHEN** the user highlights an item in a select dialog and presses Enter +- **THEN** the awaited result is `Submitted` carrying the chosen index + +#### Scenario: Cancelled by escape + +- **WHEN** the user presses Escape in a dialog +- **THEN** the awaited result is `Cancelled` + +#### Scenario: Multi-select toggling + +- **WHEN** the user presses space on items in a multi-select dialog +- **THEN** those items toggle in the returned selection set + +#### Scenario: Cancellation token closes the dialog + +- **WHEN** the `CancellationToken` passed to a dialog is cancelled +- **THEN** the overlay closes and the awaited result is `Cancelled` + +#### Scenario: AllowBack=true on Select produces Back + +- **WHEN** a `SelectRequest` with `AllowBack=true` is shown and the user presses Backspace before moving the selection +- **THEN** the awaited result is `DialogOutcome.Back` + +#### Scenario: AllowBack=true on Choice produces Back + +- **WHEN** a `ChoiceRequest` with `AllowBack=true` is shown and the user presses Backspace before moving the selection +- **THEN** the awaited result is `DialogOutcome.Back` + +#### Scenario: AllowBack=true is suppressed after movement + +- **WHEN** the user presses `↓` (consumed by the dialog) and then presses Backspace in a `SelectRequest` with `AllowBack=true` +- **THEN** Backspace is ignored for the rest of that overlay session — neither `Back` nor `Cancelled` is produced + +#### Scenario: AllowBack=false is the default + +- **WHEN** a `SelectRequest`, `ChoiceRequest`, `MultiSelectRequest`, or `InputRequest` is constructed without setting `AllowBack` +- **THEN** Backspace and `[` have no effect on Back navigation and existing v1 behaviour is preserved (for `InputRequest`, Backspace on an empty field remains a no-op) + +#### Scenario: AllowBack=true on MultiSelect produces Back via '[' + +- **WHEN** a `MultiSelectRequest` with `AllowBack=true` is shown and the user presses `[` +- **THEN** the awaited result is `DialogOutcome.Back` + +#### Scenario: MultiSelect Back via '[' survives toggling + +- **WHEN** the user toggles one or more items with Space and then presses `[` in a `MultiSelectRequest` with `AllowBack=true` +- **THEN** the awaited result is still `DialogOutcome.Back` (multi-select applies no movement-suppression to the `[` binding) + +#### Scenario: Select and Choice accept '[' as a secondary Back key + +- **WHEN** a `SelectRequest` or `ChoiceRequest` with `AllowBack=true` is shown and the user presses `[` before moving the selection +- **THEN** the awaited result is `DialogOutcome.Back` + +#### Scenario: AllowBack=true on Input produces Back via Backspace-on-empty + +- **WHEN** an `InputRequest` with `AllowBack=true` is shown with an empty field and the user presses Backspace +- **THEN** the awaited result is `DialogOutcome.Back` and `DialogResult.Value` is `default` + +#### Scenario: Input Back arms after typing then deleting back to empty + +- **WHEN** an `InputRequest` with `AllowBack=true` is shown, the user types text, deletes it all back to empty with Backspace, and then presses Backspace once more +- **THEN** the awaited result is `DialogOutcome.Back` (the trigger is current emptiness, not a pristine never-edited field) + +#### Scenario: Input Backspace with text present deletes normally + +- **WHEN** an `InputRequest` with `AllowBack=true` is shown with non-empty text and the user presses Backspace +- **THEN** the character before the caret is deleted and the dialog remains open — no `Back` is produced + +#### Scenario: Input '[' is a literal character, not Back + +- **WHEN** an `InputRequest` with `AllowBack=true` is shown and the user presses `[` +- **THEN** `[` is inserted into the buffer as ordinary text and the dialog remains open + +#### Scenario: Multi-line preamble renders all lines above the widget + +- **WHEN** a dialog request is constructed with a preamble containing multiple `Line`s +- **THEN** the overlay paints each preamble line in order, top-to-bottom, immediately above the interactive widget (list / options / input field) + +#### Scenario: Single-Line preamble constructor still works + +- **WHEN** a dialog request is constructed via the single-`Line` convenience constructor (e.g. `new ChoiceRequest(options, prompt: someLine)`) +- **THEN** the overlay paints exactly one preamble row, semantically identical to passing a one-element list + +#### Scenario: Single-string preamble constructor still works + +- **WHEN** a dialog request is constructed via the single-`string` convenience constructor (e.g. `new ChoiceRequest(options, prompt: "Permission:")`) +- **THEN** the string is wrapped via `Line.FromText` and a single preamble row is painted with the default style + +#### Scenario: Null or empty preamble paints no preamble row + +- **WHEN** a dialog request is constructed with a `null` or empty-list preamble +- **THEN** no preamble rows are painted and the full overlay budget is available to the interactive widget + +#### Scenario: Multi-line preamble truncates when over budget + +- **WHEN** a preamble's line count plus the interactive widget's minimum height exceeds the overlay's available rows +- **THEN** the preamble truncates per the existing overlay budget arithmetic (the same behaviour live-blocks have shipped since v1); the interactive widget remains usable diff --git a/openspec/changes/back-nav-input/tasks.md b/openspec/changes/back-nav-input/tasks.md new file mode 100644 index 0000000..ea00afe --- /dev/null +++ b/openspec/changes/back-nav-input/tasks.md @@ -0,0 +1,44 @@ +# Tasks — back-nav-input + +## 1. InputRequest.AllowBack + dialog wiring + +- [ ] 1.1 Add `bool AllowBack = false` as the trailing parameter of the `InputRequest` primary + constructor and of the convenience overloads that already accept `Default`/`IsSecret` (the + `Line?`, `string?`, and `IReadOnlyList?` forms). Leave the two `params`-tail overloads + (`params Line[]`, `params string[]`) unchanged — `params` must be last, so they cannot carry + `AllowBack`; document this on each, mirroring `MultiSelectRequest`. Add an ``/`` + XML doc for `AllowBack` describing the Backspace-on-empty trigger. +- [ ] 1.2 Add an `allowBack` constructor parameter to `InputDialog` and store it in a + `private readonly bool _allowBack` field; thread `req.AllowBack` through `Terminal.InputAsync` + via `new InputDialog(req.Prompt, req.Default, req.IsSecret, req.AllowBack)` + (mirror `SelectAsync`'s `allowBack: req.AllowBack` wiring). +- [ ] 1.3 In `InputDialog.HandleKey`, inside the `NamedKey.Backspace` arm, add a Back branch + guarded by `_allowBack && _buffer.Text.Length == 0`: set `CloseRequest = OverlayCloseKind.Back` + and return `true` (consumed). Otherwise fall through to the existing `_buffer.Backspace()` delete + path. Confirm Enter/Escape precedence and the Ctrl/Alt gate above are unaffected (Ctrl+Backspace + must not trigger Back). No change to `OpenModalAsync` — it already maps `OverlayCloseKind.Back`. +- [ ] 1.4 Update the `DialogOutcome.Back` enum-member summary and `` in `DialogOutcome.cs` + to enumerate all four producers (`SelectAsync`, `ChoiceAsync`, `MultiSelectAsync`, `InputAsync`) + and each trigger: Backspace-before-movement (Select/Choice), `[` (MultiSelect), Backspace-on-empty + (Input). Keep the `Value` is `default` note. + +## 2. Tests + +- [ ] 2.1 `InputRequest.AllowBack` defaults to `false` and is settable on the primary ctor and each + non-`params` convenience overload (compile-level + value assertions). +- [ ] 2.2 `AllowBack=true` + empty field + Backspace → `DialogResult` outcome is `Back`, `Value` is + `default` (empty string). +- [ ] 2.3 `AllowBack=true`, type text then Backspace back to empty, then one more Backspace → `Back` + (trigger is current emptiness, not pristine state). +- [ ] 2.4 `AllowBack=true` + non-empty text + Backspace → character deleted, dialog stays open, no + `Back`. +- [ ] 2.5 `AllowBack=false` (default) + empty field + Backspace → no-op, dialog stays open + (v1 behaviour preserved). +- [ ] 2.6 `AllowBack=true` + `[` keypress → `[` inserted as literal text, dialog stays open + (no Back binding for input). + +## 3. Validation & packaging + +- [ ] 3.1 Bump `` in `src/Dcli/Dcli.csproj` from `0.2.0-rc.3` to `0.2.0-rc.4`. +- [ ] 3.2 Gates: `dotnet build` clean (warnings-as-errors), `dotnet test` green, + `openspec validate back-nav-input --strict`, `dotnet format --verify-no-changes` clean. From d67063e4d68ca88e3163bf431fa23ecf51812a17 Mon Sep 17 00:00:00 2001 From: Emmz Rendle Date: Sun, 31 May 2026 13:32:29 +0100 Subject: [PATCH 2/5] feat(back-nav-input): InputRequest.AllowBack + dialog wiring (section 1) - 1.1 Add bool AllowBack=false to InputRequest primary ctor and the Line?/string?/IReadOnlyList? convenience overloads (params-tail overloads documented as unable to carry it) - 1.2 Thread allowBack through InputDialog ctor and Terminal.InputAsync - 1.3 Backspace-on-empty raises OverlayCloseKind.Back when AllowBack=true; Backspace with text deletes normally; Ctrl+Backspace unaffected - 1.4 Refresh DialogOutcome.Back XML docs to list all four producers/triggers Co-Authored-By: Claude Opus 4.8 --- openspec/changes/back-nav-input/tasks.md | 8 ++-- src/Dcli/DialogOutcome.cs | 29 +++++++++---- src/Dcli/DialogRequests.cs | 45 +++++++++++++++----- src/Dcli/Internal/FixedRegion/InputDialog.cs | 13 +++++- src/Dcli/Terminal.cs | 2 +- 5 files changed, 71 insertions(+), 26 deletions(-) diff --git a/openspec/changes/back-nav-input/tasks.md b/openspec/changes/back-nav-input/tasks.md index ea00afe..5682c9e 100644 --- a/openspec/changes/back-nav-input/tasks.md +++ b/openspec/changes/back-nav-input/tasks.md @@ -2,22 +2,22 @@ ## 1. InputRequest.AllowBack + dialog wiring -- [ ] 1.1 Add `bool AllowBack = false` as the trailing parameter of the `InputRequest` primary +- [x] 1.1 Add `bool AllowBack = false` as the trailing parameter of the `InputRequest` primary constructor and of the convenience overloads that already accept `Default`/`IsSecret` (the `Line?`, `string?`, and `IReadOnlyList?` forms). Leave the two `params`-tail overloads (`params Line[]`, `params string[]`) unchanged — `params` must be last, so they cannot carry `AllowBack`; document this on each, mirroring `MultiSelectRequest`. Add an ``/`` XML doc for `AllowBack` describing the Backspace-on-empty trigger. -- [ ] 1.2 Add an `allowBack` constructor parameter to `InputDialog` and store it in a +- [x] 1.2 Add an `allowBack` constructor parameter to `InputDialog` and store it in a `private readonly bool _allowBack` field; thread `req.AllowBack` through `Terminal.InputAsync` via `new InputDialog(req.Prompt, req.Default, req.IsSecret, req.AllowBack)` (mirror `SelectAsync`'s `allowBack: req.AllowBack` wiring). -- [ ] 1.3 In `InputDialog.HandleKey`, inside the `NamedKey.Backspace` arm, add a Back branch +- [x] 1.3 In `InputDialog.HandleKey`, inside the `NamedKey.Backspace` arm, add a Back branch guarded by `_allowBack && _buffer.Text.Length == 0`: set `CloseRequest = OverlayCloseKind.Back` and return `true` (consumed). Otherwise fall through to the existing `_buffer.Backspace()` delete path. Confirm Enter/Escape precedence and the Ctrl/Alt gate above are unaffected (Ctrl+Backspace must not trigger Back). No change to `OpenModalAsync` — it already maps `OverlayCloseKind.Back`. -- [ ] 1.4 Update the `DialogOutcome.Back` enum-member summary and `` in `DialogOutcome.cs` +- [x] 1.4 Update the `DialogOutcome.Back` enum-member summary and `` in `DialogOutcome.cs` to enumerate all four producers (`SelectAsync`, `ChoiceAsync`, `MultiSelectAsync`, `InputAsync`) and each trigger: Backspace-before-movement (Select/Choice), `[` (MultiSelect), Backspace-on-empty (Input). Keep the `Value` is `default` note. diff --git a/src/Dcli/DialogOutcome.cs b/src/Dcli/DialogOutcome.cs index 7f47136..a18ab5c 100644 --- a/src/Dcli/DialogOutcome.cs +++ b/src/Dcli/DialogOutcome.cs @@ -6,9 +6,13 @@ namespace Dcli; /// /// /// and are produced by all v1 list-based dialogs. -/// is produced by and -/// when the request has AllowBack = true and the user -/// presses Backspace before moving the selection cursor. +/// is produced by four methods when the request has AllowBack = true: +/// +/// — Backspace before moving the selection cursor. +/// — Backspace before moving the selection cursor. +/// — pressing [ at any time. +/// — Backspace while the input buffer is empty. +/// /// /// public enum DialogOutcome @@ -17,13 +21,20 @@ public enum DialogOutcome Submitted, /// - /// The user pressed Backspace to navigate back in a wizard flow. - /// Produced by and - /// when the request has AllowBack = true and the user presses Backspace before - /// moving the selection cursor (and before entering any filter text when type-to-filter - /// is active). is when this - /// outcome is returned. + /// The user pressed a back-navigation trigger in a wizard flow. /// + /// + /// + /// Produced by four dialog methods when the request has AllowBack = true: + /// + /// — Backspace before moving the selection cursor (and before entering any filter text when type-to-filter is active). + /// — Backspace before moving the selection cursor. + /// — pressing [ at any time (including after toggling items). + /// — Backspace while the input buffer is empty (typing and deleting back to empty still arms Back). + /// + /// + /// is when this outcome is returned. + /// Back, /// The user dismissed without confirming (Escape or cancellation token). diff --git a/src/Dcli/DialogRequests.cs b/src/Dcli/DialogRequests.cs index 67dc870..fb50a77 100644 --- a/src/Dcli/DialogRequests.cs +++ b/src/Dcli/DialogRequests.cs @@ -391,7 +391,14 @@ private static List ConvertOptions(IReadOnlyList options) /// in the rendered overlay, preserving column-width arithmetic. The /// always carries the real (unmasked) entered text. /// -public sealed record InputRequest(IReadOnlyList? Prompt = null, string? Default = null, bool IsSecret = false) +/// +/// When , pressing Backspace while the input buffer is empty closes the +/// dialog with . Intended for wizard flows where the user can +/// step backwards. Typing text then deleting back to empty still arms Back — emptiness at the +/// moment Backspace is pressed is the only test. Defaults to ; existing +/// callers are unaffected. +/// +public sealed record InputRequest(IReadOnlyList? Prompt = null, string? Default = null, bool IsSecret = false, bool AllowBack = false) { /// /// Constructs an with a single prompt. @@ -404,8 +411,12 @@ public sealed record InputRequest(IReadOnlyList? Prompt = null, string? De /// Optional single-line preamble; means no preamble. /// Optional pre-filled text. /// When , input characters are masked. - public InputRequest(Line? Prompt, string? Default = null, bool IsSecret = false) - : this(Prompt is null ? null : (IReadOnlyList)[Prompt], Default, IsSecret) { } + /// + /// When , Backspace on an empty buffer closes the dialog with + /// . Defaults to . + /// + public InputRequest(Line? Prompt, string? Default = null, bool IsSecret = false, bool AllowBack = false) + : this(Prompt is null ? null : (IReadOnlyList)[Prompt], Default, IsSecret, AllowBack) { } /// /// Constructs an with a plain-text prompt string. @@ -415,28 +426,38 @@ public InputRequest(Line? Prompt, string? Default = null, bool IsSecret = false) /// Optional plain-text prompt string; means no preamble. /// Optional pre-filled text. /// When , input characters are masked. - public InputRequest(string? prompt, string? Default = null, bool IsSecret = false) - : this(prompt is null ? null : Line.FromText(prompt), Default, IsSecret) { } + /// + /// When , Backspace on an empty buffer closes the dialog with + /// . Defaults to . + /// + public InputRequest(string? prompt, string? Default = null, bool IsSecret = false, bool allowBack = false) + : this(prompt is null ? null : Line.FromText(prompt), Default, IsSecret, allowBack) { } /// /// Constructs an with a multi-line string preamble. Each string /// entry is converted via . /// Shorthand equivalent to passing prompt.Select(Line.FromText).ToList() as Prompt. /// No implicit conversion is defined; pass strings explicitly. Note: the - /// form does not accept Default or IsSecret; use this - /// overload when those parameters are needed. + /// form does not accept Default, IsSecret, or + /// AllowBack; use this overload when those parameters are needed. /// /// Optional multi-line string preamble; means no preamble. /// Optional pre-filled text. /// When , input characters are masked. - public InputRequest(IReadOnlyList? prompt, string? Default = null, bool IsSecret = false) - : this(ConvertPreamble(prompt), Default, IsSecret) { } + /// + /// When , Backspace on an empty buffer closes the dialog with + /// . Defaults to . + /// + public InputRequest(IReadOnlyList? prompt, string? Default = null, bool IsSecret = false, bool allowBack = false) + : this(ConvertPreamble(prompt), Default, IsSecret, allowBack) { } /// /// Constructs an with multiple preamble /// entries supplied as a array. Each line is painted in order /// above the input field. No implicit conversion is defined; pass lines explicitly. - /// Note: Default and IsSecret cannot be specified alongside + /// AllowBack cannot be set via this constructor because must + /// be the last parameter; use the primary constructor or another overload. + /// Note: Default, IsSecret, and AllowBack cannot be specified alongside /// ; use the -overload for those. /// /// Preamble lines in top-to-bottom order (may be empty). @@ -446,7 +467,9 @@ public InputRequest(params Line[] prompt) /// /// Constructs an with a string preamble. /// Each string is converted via . - /// Note: Default and IsSecret cannot be specified alongside + /// AllowBack cannot be set via this constructor because must + /// be the last parameter; use the primary constructor or another overload. + /// Note: Default, IsSecret, and AllowBack cannot be specified alongside /// ; use the -overload for those. /// /// Preamble strings in top-to-bottom order (may be empty). diff --git a/src/Dcli/Internal/FixedRegion/InputDialog.cs b/src/Dcli/Internal/FixedRegion/InputDialog.cs index d5ed019..0a1d522 100644 --- a/src/Dcli/Internal/FixedRegion/InputDialog.cs +++ b/src/Dcli/Internal/FixedRegion/InputDialog.cs @@ -23,6 +23,7 @@ internal sealed class InputDialog : IModalOverlay { private readonly TextBuffer _buffer; private readonly bool _isSecret; + private readonly bool _allowBack; // True once the user makes any buffer-mutating keystroke (insert, Backspace, Delete). // Sticky: never reset to false after being set. Used so that masking semantics are // consistent: _userEdited=false means the buffer still holds the seeded Default exactly @@ -42,10 +43,15 @@ internal sealed class InputDialog : IModalOverlay /// Optional preamble lines rendered above the text field. /// Optional pre-filled text; the caret starts at its end. /// When , rendered characters are masked. - internal InputDialog(IReadOnlyList? prompt, string? @default, bool isSecret) + /// + /// When , Backspace on an empty buffer closes the dialog with + /// . + /// + internal InputDialog(IReadOnlyList? prompt, string? @default, bool isSecret, bool allowBack = false) { Prompt = prompt; _isSecret = isSecret; + _allowBack = allowBack; _buffer = new TextBuffer(); if (!string.IsNullOrEmpty(@default)) _buffer.SetText(@default); @@ -132,6 +138,11 @@ public bool HandleKey(KeyEvent key) switch (key.Code.NamedValue) { case NamedKey.Backspace: + if (_allowBack && _buffer.Text.Length == 0) + { + CloseRequest = OverlayCloseKind.Back; + return true; + } _userEdited = true; _buffer.Backspace(); return true; diff --git a/src/Dcli/Terminal.cs b/src/Dcli/Terminal.cs index 5f79b9b..7a5581e 100644 --- a/src/Dcli/Terminal.cs +++ b/src/Dcli/Terminal.cs @@ -221,7 +221,7 @@ public Task> InputAsync( CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(req); - InputDialog dialog = new(req.Prompt, req.Default, req.IsSecret); + InputDialog dialog = new(req.Prompt, req.Default, req.IsSecret, req.AllowBack); return OpenModalAsync( dialog, () => new DialogResult(DialogOutcome.Submitted, dialog.Text), From 2e52e0242fa4fdd2f4d1b77ace6a2f94aab38f01 Mon Sep 17 00:00:00 2001 From: Emmz Rendle Date: Sun, 31 May 2026 13:37:15 +0100 Subject: [PATCH 3/5] test(back-nav-input): cover InputRequest.AllowBack & Backspace-on-empty (section 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 2.1 InputRequest.AllowBack default false + settable on each non-params overload - 2.2 AllowBack + empty + Backspace → Back, empty value - 2.3 type-then-delete-to-empty then Backspace → Back (current-emptiness, not _userEdited) - 2.4 Backspace with text deletes one char, dialog stays open - 2.5 AllowBack=false Backspace-on-empty is a no-op - 2.6 '[' inserts literally, no Back binding for input Co-Authored-By: Claude Opus 4.8 --- openspec/changes/back-nav-input/tasks.md | 12 ++-- tests/Dcli.Tests/DialogRequestsTests.cs | 37 ++++++++++++ tests/Dcli.Tests/InputDialogTests.cs | 77 ++++++++++++++++++++++++ 3 files changed, 120 insertions(+), 6 deletions(-) diff --git a/openspec/changes/back-nav-input/tasks.md b/openspec/changes/back-nav-input/tasks.md index 5682c9e..e33ac9c 100644 --- a/openspec/changes/back-nav-input/tasks.md +++ b/openspec/changes/back-nav-input/tasks.md @@ -24,17 +24,17 @@ ## 2. Tests -- [ ] 2.1 `InputRequest.AllowBack` defaults to `false` and is settable on the primary ctor and each +- [x] 2.1 `InputRequest.AllowBack` defaults to `false` and is settable on the primary ctor and each non-`params` convenience overload (compile-level + value assertions). -- [ ] 2.2 `AllowBack=true` + empty field + Backspace → `DialogResult` outcome is `Back`, `Value` is +- [x] 2.2 `AllowBack=true` + empty field + Backspace → `DialogResult` outcome is `Back`, `Value` is `default` (empty string). -- [ ] 2.3 `AllowBack=true`, type text then Backspace back to empty, then one more Backspace → `Back` +- [x] 2.3 `AllowBack=true`, type text then Backspace back to empty, then one more Backspace → `Back` (trigger is current emptiness, not pristine state). -- [ ] 2.4 `AllowBack=true` + non-empty text + Backspace → character deleted, dialog stays open, no +- [x] 2.4 `AllowBack=true` + non-empty text + Backspace → character deleted, dialog stays open, no `Back`. -- [ ] 2.5 `AllowBack=false` (default) + empty field + Backspace → no-op, dialog stays open +- [x] 2.5 `AllowBack=false` (default) + empty field + Backspace → no-op, dialog stays open (v1 behaviour preserved). -- [ ] 2.6 `AllowBack=true` + `[` keypress → `[` inserted as literal text, dialog stays open +- [x] 2.6 `AllowBack=true` + `[` keypress → `[` inserted as literal text, dialog stays open (no Back binding for input). ## 3. Validation & packaging diff --git a/tests/Dcli.Tests/DialogRequestsTests.cs b/tests/Dcli.Tests/DialogRequestsTests.cs index 34489e8..7d2bdb0 100644 --- a/tests/Dcli.Tests/DialogRequestsTests.cs +++ b/tests/Dcli.Tests/DialogRequestsTests.cs @@ -136,6 +136,43 @@ public void InputRequestNullPromptPropertyIsNull() Assert.Null(req.Prompt); } + // ── back-nav-input §2.1 — InputRequest.AllowBack default and settable ─────── + + [Fact] + public void InputRequestAllowBackDefaultsToFalse() + { + InputRequest req = new(); + Assert.False(req.AllowBack); + } + + [Fact] + public void InputRequestAllowBackSetOnPrimaryCtorIReadOnlyListLine() + { + InputRequest req = new(Prompt: (IReadOnlyList?)null, AllowBack: true); + Assert.True(req.AllowBack); + } + + [Fact] + public void InputRequestAllowBackSetOnLineCtor() + { + InputRequest req = new(Prompt: (Line?)null, AllowBack: true); + Assert.True(req.AllowBack); + } + + [Fact] + public void InputRequestAllowBackSetOnStringCtor() + { + InputRequest req = new(prompt: (string?)null, allowBack: true); + Assert.True(req.AllowBack); + } + + [Fact] + public void InputRequestAllowBackSetOnIReadOnlyListStringCtor() + { + InputRequest req = new(prompt: (IReadOnlyList?)null, allowBack: true); + Assert.True(req.AllowBack); + } + // ── §3.6 — Null string preamble convenience ctor produces null property ── [Fact] diff --git a/tests/Dcli.Tests/InputDialogTests.cs b/tests/Dcli.Tests/InputDialogTests.cs index 2a2ec9b..3cf52e2 100644 --- a/tests/Dcli.Tests/InputDialogTests.cs +++ b/tests/Dcli.Tests/InputDialogTests.cs @@ -862,6 +862,83 @@ public async Task PasteConsumedByModalDialogDoesNotLeakToBaseEditor() finally { engine.Dispose(); } } + // ── back-nav-input §2 — InputDialog AllowBack / Back behaviour ─────────── + + // §2.2 — AllowBack=true + empty field + Backspace → CloseRequest = Back. + [Fact] + public void AllowBackTrueBackspaceOnEmptyBufferSetsBack() + { + InputDialog dialog = new(prompt: null, @default: null, isSecret: false, allowBack: true); + + dialog.HandleKey(new KeyEvent(KeyCode.Named(NamedKey.Backspace), Modifiers.None)); + + Assert.Equal(OverlayCloseKind.Back, dialog.CloseRequest); + Assert.Equal(string.Empty, dialog.Text); + } + + // §2.3 — AllowBack=true, type text then delete back to empty, one more Backspace → Back. + // Trigger is current emptiness, not pristine never-edited state. + [Fact] + public void AllowBackTrueBackspaceOnEditedThenEmptyBufferSetsBack() + { + InputDialog dialog = new(prompt: null, @default: null, isSecret: false, allowBack: true); + + // Insert two characters. + dialog.HandleKey(new KeyEvent(KeyCode.FromRune(new Rune('a')), Modifiers.None)); + dialog.HandleKey(new KeyEvent(KeyCode.FromRune(new Rune('b')), Modifiers.None)); + + // Delete them both — buffer is now empty but _userEdited is true (sticky). + dialog.HandleKey(new KeyEvent(KeyCode.Named(NamedKey.Backspace), Modifiers.None)); + Assert.Null(dialog.CloseRequest); // still open after first delete: "ab"→"a" (non-empty) + dialog.HandleKey(new KeyEvent(KeyCode.Named(NamedKey.Backspace), Modifiers.None)); + Assert.Null(dialog.CloseRequest); // still open after second delete: "a"→"" (non-empty when pressed) + + // Buffer is now empty; one more Backspace must fire Back. + dialog.HandleKey(new KeyEvent(KeyCode.Named(NamedKey.Backspace), Modifiers.None)); + + Assert.Equal(OverlayCloseKind.Back, dialog.CloseRequest); + Assert.Equal(string.Empty, dialog.Text); + } + + // §2.4 — AllowBack=true + non-empty text + Backspace → char deleted, dialog stays open. + [Fact] + public void AllowBackTrueBackspaceOnNonEmptyBufferDeletesChar() + { + InputDialog dialog = new(prompt: null, @default: null, isSecret: false, allowBack: true); + + dialog.HandleKey(new KeyEvent(KeyCode.FromRune(new Rune('x')), Modifiers.None)); + dialog.HandleKey(new KeyEvent(KeyCode.FromRune(new Rune('y')), Modifiers.None)); + + dialog.HandleKey(new KeyEvent(KeyCode.Named(NamedKey.Backspace), Modifiers.None)); + + Assert.Null(dialog.CloseRequest); + Assert.Equal("x", dialog.Text); + } + + // §2.5 — AllowBack=false (default) + empty field + Backspace → no-op; dialog stays open. + [Fact] + public void AllowBackFalseBackspaceOnEmptyBufferIsNoOp() + { + InputDialog dialog = new(prompt: null, @default: null, isSecret: false); + + dialog.HandleKey(new KeyEvent(KeyCode.Named(NamedKey.Backspace), Modifiers.None)); + + Assert.Null(dialog.CloseRequest); + Assert.Equal(string.Empty, dialog.Text); + } + + // §2.6 — AllowBack=true + '[' keypress → '[' inserted as literal text; dialog stays open. + [Fact] + public void AllowBackTrueBracketKeyInsertsLiteralText() + { + InputDialog dialog = new(prompt: null, @default: null, isSecret: false, allowBack: true); + + dialog.HandleKey(new KeyEvent(KeyCode.FromRune(new Rune('[')), Modifiers.None)); + + Assert.Null(dialog.CloseRequest); + Assert.Equal("[", dialog.Text); + } + // ── Helper ──────────────────────────────────────────────────────────────── private static int FindRowIndexContaining(IReadOnlyList rows, string needle) From e1d049006a0c0cf7d86379d62d5eab26697da04e Mon Sep 17 00:00:00 2001 From: Emmz Rendle Date: Sun, 31 May 2026 13:39:50 +0100 Subject: [PATCH 4/5] chore(back-nav-input): validation & packaging, 0.2.0-rc.4 (section 3) - 3.1 Bump Dcli and Dcli.Testing to 0.2.0-rc.4 (lockstep, per prior packaging sections) - 3.2 Gates green: build (0/0), test (866), format clean, openspec validate --strict; dcli.0.2.0-rc.4.nupkg packs cleanly Co-Authored-By: Claude Opus 4.8 --- openspec/changes/back-nav-input/tasks.md | 4 ++-- src/Dcli.Testing/Dcli.Testing.csproj | 2 +- src/Dcli/Dcli.csproj | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openspec/changes/back-nav-input/tasks.md b/openspec/changes/back-nav-input/tasks.md index e33ac9c..b029bf5 100644 --- a/openspec/changes/back-nav-input/tasks.md +++ b/openspec/changes/back-nav-input/tasks.md @@ -39,6 +39,6 @@ ## 3. Validation & packaging -- [ ] 3.1 Bump `` in `src/Dcli/Dcli.csproj` from `0.2.0-rc.3` to `0.2.0-rc.4`. -- [ ] 3.2 Gates: `dotnet build` clean (warnings-as-errors), `dotnet test` green, +- [x] 3.1 Bump `` in `src/Dcli/Dcli.csproj` from `0.2.0-rc.3` to `0.2.0-rc.4`. +- [x] 3.2 Gates: `dotnet build` clean (warnings-as-errors), `dotnet test` green, `openspec validate back-nav-input --strict`, `dotnet format --verify-no-changes` clean. diff --git a/src/Dcli.Testing/Dcli.Testing.csproj b/src/Dcli.Testing/Dcli.Testing.csproj index 81e0452..819dd55 100644 --- a/src/Dcli.Testing/Dcli.Testing.csproj +++ b/src/Dcli.Testing/Dcli.Testing.csproj @@ -7,7 +7,7 @@ true dcli.testing - 0.2.0-rc.3 + 0.2.0-rc.4 daemonicai daemonicai Headless test harness for the dcli inline terminal-rendering library. diff --git a/src/Dcli/Dcli.csproj b/src/Dcli/Dcli.csproj index 3d0677f..d92831b 100644 --- a/src/Dcli/Dcli.csproj +++ b/src/Dcli/Dcli.csproj @@ -9,7 +9,7 @@ true dcli - 0.2.0-rc.3 + 0.2.0-rc.4 daemonicai daemonicai Inline terminal-rendering library. Claude-Code-style styled output flows into the terminal's real scrollback, with a small interactive region pinned at the bottom. From 61c2339d70e3c1af8a7257a1cd610b83e624ff41 Mon Sep 17 00:00:00 2001 From: Emmz Rendle Date: Sun, 31 May 2026 13:42:55 +0100 Subject: [PATCH 5/5] chore(back-nav-input): archive change & sync fixed-region spec Sync the "Awaitable modal dialogs" MODIFIED requirement (InputRequest.AllowBack + Backspace-on-empty Back trigger and scenarios) into the main fixed-region spec, and move the change to openspec/changes/archive/2026-05-31-back-nav-input/. Co-Authored-By: Claude Opus 4.8 --- .../2026-05-31-back-nav-input}/.openspec.yaml | 0 .../2026-05-31-back-nav-input}/design.md | 0 .../2026-05-31-back-nav-input}/proposal.md | 0 .../specs/fixed-region/spec.md | 0 .../2026-05-31-back-nav-input}/tasks.md | 0 openspec/specs/fixed-region/spec.md | 29 +++++++++++++++++-- 6 files changed, 26 insertions(+), 3 deletions(-) rename openspec/changes/{back-nav-input => archive/2026-05-31-back-nav-input}/.openspec.yaml (100%) rename openspec/changes/{back-nav-input => archive/2026-05-31-back-nav-input}/design.md (100%) rename openspec/changes/{back-nav-input => archive/2026-05-31-back-nav-input}/proposal.md (100%) rename openspec/changes/{back-nav-input => archive/2026-05-31-back-nav-input}/specs/fixed-region/spec.md (100%) rename openspec/changes/{back-nav-input => archive/2026-05-31-back-nav-input}/tasks.md (100%) diff --git a/openspec/changes/back-nav-input/.openspec.yaml b/openspec/changes/archive/2026-05-31-back-nav-input/.openspec.yaml similarity index 100% rename from openspec/changes/back-nav-input/.openspec.yaml rename to openspec/changes/archive/2026-05-31-back-nav-input/.openspec.yaml diff --git a/openspec/changes/back-nav-input/design.md b/openspec/changes/archive/2026-05-31-back-nav-input/design.md similarity index 100% rename from openspec/changes/back-nav-input/design.md rename to openspec/changes/archive/2026-05-31-back-nav-input/design.md diff --git a/openspec/changes/back-nav-input/proposal.md b/openspec/changes/archive/2026-05-31-back-nav-input/proposal.md similarity index 100% rename from openspec/changes/back-nav-input/proposal.md rename to openspec/changes/archive/2026-05-31-back-nav-input/proposal.md diff --git a/openspec/changes/back-nav-input/specs/fixed-region/spec.md b/openspec/changes/archive/2026-05-31-back-nav-input/specs/fixed-region/spec.md similarity index 100% rename from openspec/changes/back-nav-input/specs/fixed-region/spec.md rename to openspec/changes/archive/2026-05-31-back-nav-input/specs/fixed-region/spec.md diff --git a/openspec/changes/back-nav-input/tasks.md b/openspec/changes/archive/2026-05-31-back-nav-input/tasks.md similarity index 100% rename from openspec/changes/back-nav-input/tasks.md rename to openspec/changes/archive/2026-05-31-back-nav-input/tasks.md diff --git a/openspec/specs/fixed-region/spec.md b/openspec/specs/fixed-region/spec.md index 67e794a..642f48b 100644 --- a/openspec/specs/fixed-region/spec.md +++ b/openspec/specs/fixed-region/spec.md @@ -124,10 +124,13 @@ Each dialog request type SHALL carry an optional **multi-line preamble** rendere Each request type SHALL expose backwards-compatible convenience constructors that accept a single `Line`, a single `string` (converted via `Line.FromText`), an `IReadOnlyList`, a `params Line[]`, an `IReadOnlyList`, or a `params string[]` for the preamble. Single-`Line` and single-`string` forms SHALL be internally equivalent to passing a one-element list. When the preamble is `null` or empty, no preamble row SHALL be painted and the full overlay budget SHALL be available to the interactive widget. -`SelectRequest`, `ChoiceRequest`, and `MultiSelectRequest` SHALL each expose an opt-in `AllowBack` flag (default `false`, backward-compatible). When `AllowBack=false`, no key produces `Back` and existing v1 behaviour is preserved. When `AllowBack=true`: +`SelectRequest`, `ChoiceRequest`, `MultiSelectRequest`, and `InputRequest` SHALL each expose an opt-in `AllowBack` flag (default `false`, backward-compatible). When `AllowBack=false`, no key produces `Back` and existing v1 behaviour is preserved. When `AllowBack=true`: - `SelectRequest` and `ChoiceRequest` SHALL produce `DialogOutcome.Back` when **Backspace** is pressed before the selection is moved (the binding introduced in `api-ergonomics-pass-1`), and SHALL additionally accept **`[`** as a secondary Back key with no movement-suppression. - `MultiSelectRequest` SHALL produce `DialogOutcome.Back` when **`[`** is pressed at any time, regardless of whether items have been toggled. Multi-select SHALL NOT bind Backspace to `Back` — Space-toggle and Backspace interplay makes a Backspace-position heuristic unreliable, so a distinct key (`[`) is used instead. +- `InputRequest` SHALL produce `DialogOutcome.Back` when **Backspace** is pressed while the input field is currently empty (text length zero), regardless of edit history (typing then deleting back to empty SHALL still arm Back). Backspace with any text present SHALL delete the character before the caret as normal and SHALL NOT produce `Back`. Input SHALL NOT bind `[` to `Back` — `[` is a literal character users type into free-text fields (URLs, JSON, keys), so rebinding it would corrupt legitimate input. + +For every request type, `DialogResult.Value` SHALL be `default` when the outcome is `Back`. #### Scenario: Select submitted @@ -166,8 +169,8 @@ Each request type SHALL expose backwards-compatible convenience constructors tha #### Scenario: AllowBack=false is the default -- **WHEN** a `SelectRequest`, `ChoiceRequest`, or `MultiSelectRequest` is constructed without setting `AllowBack` -- **THEN** Backspace and `[` have no effect on the dialog and existing v1 behaviour is preserved +- **WHEN** a `SelectRequest`, `ChoiceRequest`, `MultiSelectRequest`, or `InputRequest` is constructed without setting `AllowBack` +- **THEN** Backspace and `[` have no effect on Back navigation and existing v1 behaviour is preserved (for `InputRequest`, Backspace on an empty field remains a no-op) #### Scenario: AllowBack=true on MultiSelect produces Back via '[' @@ -184,6 +187,26 @@ Each request type SHALL expose backwards-compatible convenience constructors tha - **WHEN** a `SelectRequest` or `ChoiceRequest` with `AllowBack=true` is shown and the user presses `[` before moving the selection - **THEN** the awaited result is `DialogOutcome.Back` +#### Scenario: AllowBack=true on Input produces Back via Backspace-on-empty + +- **WHEN** an `InputRequest` with `AllowBack=true` is shown with an empty field and the user presses Backspace +- **THEN** the awaited result is `DialogOutcome.Back` and `DialogResult.Value` is `default` + +#### Scenario: Input Back arms after typing then deleting back to empty + +- **WHEN** an `InputRequest` with `AllowBack=true` is shown, the user types text, deletes it all back to empty with Backspace, and then presses Backspace once more +- **THEN** the awaited result is `DialogOutcome.Back` (the trigger is current emptiness, not a pristine never-edited field) + +#### Scenario: Input Backspace with text present deletes normally + +- **WHEN** an `InputRequest` with `AllowBack=true` is shown with non-empty text and the user presses Backspace +- **THEN** the character before the caret is deleted and the dialog remains open — no `Back` is produced + +#### Scenario: Input '[' is a literal character, not Back + +- **WHEN** an `InputRequest` with `AllowBack=true` is shown and the user presses `[` +- **THEN** `[` is inserted into the buffer as ordinary text and the dialog remains open + #### Scenario: Multi-line preamble renders all lines above the widget - **WHEN** a dialog request is constructed with a preamble containing multiple `Line`s