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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-31
101 changes: 101 additions & 0 deletions openspec/changes/archive/2026-05-31-back-nav-input/design.md
Original file line number Diff line number Diff line change
@@ -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<string>?` 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 `<remarks>` 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.
49 changes: 49 additions & 0 deletions openspec/changes/archive/2026-05-31-back-nav-input/proposal.md
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
@@ -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<Line>?` and SHALL render as a sequence of styled rows above the list items.
- `MultiSelectRequest.Title` SHALL be typed `IReadOnlyList<Line>?` and SHALL render as a sequence of styled rows above the list items.
- `ChoiceRequest.Prompt` SHALL be typed `IReadOnlyList<Line>?` and SHALL render as a sequence of styled rows above the options.
- `InputRequest.Prompt` SHALL be typed `IReadOnlyList<Line>?` 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<Line>`, a `params Line[]`, an `IReadOnlyList<string>`, 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
44 changes: 44 additions & 0 deletions openspec/changes/archive/2026-05-31-back-nav-input/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Tasks — back-nav-input

## 1. InputRequest.AllowBack + dialog wiring

- [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<string>?` 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 `<param>`/`<summary>`
XML doc for `AllowBack` describing the Backspace-on-empty trigger.
- [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).
- [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`.
- [x] 1.4 Update the `DialogOutcome.Back` enum-member summary and `<remarks>` 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

- [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).
- [x] 2.2 `AllowBack=true` + empty field + Backspace → `DialogResult` outcome is `Back`, `Value` is
`default` (empty string).
- [x] 2.3 `AllowBack=true`, type text then Backspace back to empty, then one more Backspace → `Back`
(trigger is current emptiness, not pristine state).
- [x] 2.4 `AllowBack=true` + non-empty text + Backspace → character deleted, dialog stays open, no
`Back`.
- [x] 2.5 `AllowBack=false` (default) + empty field + Backspace → no-op, dialog stays open
(v1 behaviour preserved).
- [x] 2.6 `AllowBack=true` + `[` keypress → `[` inserted as literal text, dialog stays open
(no Back binding for input).

## 3. Validation & packaging

- [x] 3.1 Bump `<Version>` 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.
Loading
Loading