From 1477abd4ea57ed87c27a428e89c69ddfb924eba4 Mon Sep 17 00:00:00 2001 From: Rendle Date: Thu, 18 Jun 2026 23:19:55 +0100 Subject: [PATCH 1/9] docs(openspec): propose persistent-input-preamble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a persistent input-preamble surface (ITerminal.InputPreamble) that pins consumer-set styled rows directly above the always-on input editor — the mirror of StatusSurface, which pins rows below it. Enables a consumer (dmon) to frame the input with a header rule that stays pinned instead of scrolling away. MODIFIED fixed-region "Bottom-pinned component stack"; ADDED "Persistent input preamble". Bumps dcli rc.4 -> rc.5. Co-Authored-By: Claude Opus 4.8 --- .../persistent-input-preamble/.openspec.yaml | 2 + .../persistent-input-preamble/design.md | 73 +++++++++++++++++++ .../persistent-input-preamble/proposal.md | 49 +++++++++++++ .../specs/fixed-region/spec.md | 41 +++++++++++ .../persistent-input-preamble/tasks.md | 36 +++++++++ 5 files changed, 201 insertions(+) create mode 100644 openspec/changes/persistent-input-preamble/.openspec.yaml create mode 100644 openspec/changes/persistent-input-preamble/design.md create mode 100644 openspec/changes/persistent-input-preamble/proposal.md create mode 100644 openspec/changes/persistent-input-preamble/specs/fixed-region/spec.md create mode 100644 openspec/changes/persistent-input-preamble/tasks.md diff --git a/openspec/changes/persistent-input-preamble/.openspec.yaml b/openspec/changes/persistent-input-preamble/.openspec.yaml new file mode 100644 index 0000000..95ae5a2 --- /dev/null +++ b/openspec/changes/persistent-input-preamble/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-18 diff --git a/openspec/changes/persistent-input-preamble/design.md b/openspec/changes/persistent-input-preamble/design.md new file mode 100644 index 0000000..f2e4623 --- /dev/null +++ b/openspec/changes/persistent-input-preamble/design.md @@ -0,0 +1,73 @@ +## Context + +The fixed region is a bottom-pinned stack composed by `FixedRegionComposer.Compose` (`src/Dcli/Internal/FixedRegion/FixedRegionComposer.cs`). Its bands, top-to-bottom, are: an optional above-input overlay, the input editor, an optional below-input overlay, and the always-sacred status rows. The consumer can drive the bottom band via `StatusSurface.SetRows` (`ITerminal.Status`), which posts a `SetStatusCommand : ILoopCommand` that sets `model.FixedRegion.Status.Rows` and marks the model dirty; the composer reads those rows each frame. + +There is no consumer surface for content pinned **above** the input editor. Transient dialogs already render a multi-line preamble above their widget (`multi-line-dialog-prompts`), but that is overlay-scoped and disappears with the dialog. dmon needs a *persistent* preamble for the base editor. + +## Goals / Non-Goals + +**Goals:** +- A persistent, consumer-set band of styled rows pinned directly above the base input editor, surviving across renders and input submissions until changed. +- An API that mirrors `StatusSurface` exactly, so the surface is immediately familiar and the wiring reuses a proven path. +- Sensible height-budget behaviour: the preamble yields before the input editor does; status stays sacred. + +**Non-Goals:** +- The input prompt-prefix glyph (`❯`) on the editor line — separately deferred. +- Any key handling by the preamble — it is presentational. +- Welcome banner / MOTD — scrollback content owned by the consumer. + +## Decisions + +### D1: Mirror the StatusSurface wiring exactly + +The preamble reuses the status path, one layer up the stack: + +| Status (exists) | Preamble (new) | +|---|---| +| `ITerminal.Status : IStatus` | `ITerminal.InputPreamble : IInputPreamble` | +| `StatusSurface.SetRows(…)` → `_loop.Post(new SetStatusCommand(rows))` | `InputPreambleSurface.SetRows(…)` → `_loop.Post(new SetInputPreambleCommand(rows))` | +| `SetStatusCommand.Apply` → `model.FixedRegion.Status.Rows = rows; model.MarkDirty()` | `SetInputPreambleCommand.Apply` → `model.FixedRegion.Preamble.Rows = rows; model.MarkDirty()` | +| `Terminal` ctor: `Status = new StatusSurface(loop)` | `Terminal` ctor: `InputPreamble = new InputPreambleSurface(loop)` | +| Composer reads `_status.Rows` | Composer reads `_preamble.Rows` | + +Same `params Line[]` + `IReadOnlyList` overloads; empty clears. This keeps the public surface symmetric ("rows above" / "rows below") and the implementation a near-copy of a tested path. + +### D2: Band placement — directly above the input editor + +`FixedRegionComposer.Compose` assembles rows in order. The preamble rows are inserted immediately above the input editor band: + +``` +live window (scrollback) +[ above-input overlay ] (when an overlay is placed AboveInput) +[ preamble rows ] ← NEW, when set +[ input editor rows ] +[ below-input overlay ] (Autocomplete) +[ status rows ] (sacred, bottommost) +``` + +The preamble sits below an above-input overlay: while a modal Dialog occupies the above-input slot it owns the visual foreground; the persistent preamble is chrome for the *base* editor. (In practice dmon clears or ignores the preamble's relevance during a modal dialog; the composer simply renders it in its band when present.) + +### D3: Budget — preamble yields before the input, status stays sacred + +The fixed-region height budget (`MaxHeight = clamp(appSet ?? 50% rows, 8, rows)`) is unchanged. The sacrifice order under pressure becomes: overlays squeeze (as today) → **preamble truncates** → the input editor retains at least one usable row → status rows are never squeezed. This mirrors how dialog preambles already truncate before their widget, so the arithmetic is the established pattern applied to the persistent band. + +### D4: Persistence and clearing + +`SetRows` is set-and-hold: once applied, the preamble renders every frame until `SetRows` is called again (replace) or with an empty argument (clear). This matches `StatusSurface` and is the whole point — the consumer sets the frame once at startup, not per turn. + +### D5: Presentational — not in the intercept chain + +The preamble never registers in the key-routing intercept chain (`active overlay → input editor`). It is pure presentation; all keys continue to reach the input editor (or the active overlay). This keeps it orthogonal to the existing **Intercept-chain key routing** and **Single cursor placement** requirements (the hardware cursor still parks at the input caret). + +### D6: Naming + +`InputPreamble` reuses the "preamble" vocabulary already established for dialogs (`InputRequest.Prompt` is documented as the dialog preamble), and reads as the natural counterpart to `Status`. The surface interface is `IInputPreamble` and the impl `InputPreambleSurface`, paralleling `IStatus` / `StatusSurface`. + +## Risks / Trade-offs + +- **Above-input overlay vs preamble ordering** is a genuine design choice; D2 places the preamble below an above-input overlay. If a future consumer needs the preamble above a dialog too, that is an additive follow-up — the band is independent. +- **Budget contention on tiny terminals**: with a 1–2 line preamble, an editor that needs ≥1 row, and ≥1 status row, an 8-row floor is comfortable; the truncation rule (D3) guarantees the editor and status survive. + +## Open Questions + +- **Version target.** `0.2.0-rc.5` assumed (additive, preview channel). Confirm at packaging time against the release-tag flow (`.github/workflows/release.yml`). diff --git a/openspec/changes/persistent-input-preamble/proposal.md b/openspec/changes/persistent-input-preamble/proposal.md new file mode 100644 index 0000000..f2f4027 --- /dev/null +++ b/openspec/changes/persistent-input-preamble/proposal.md @@ -0,0 +1,49 @@ +## Why + +dmon's Terminal host is redesigning its bottom chrome to frame the input on both sides: + +``` +── dmon ────────────────────────────────────── ← header rule, pinned above the input +❯ user typing here █ ← the always-on input editor +────────────────────────────────────────────── ← rule ┐ status rows +[Ready] dmon core v0.2.0-preview.23 gemini… ← status ┘ (pinned, below input) +``` + +The **below-input** chrome is already expressible: `StatusSurface.SetRows(...)` pins styled rows beneath the input editor. There is no symmetric surface for the **above-input** chrome — the only way to put a line directly above the always-on editor today is to append it to scrollback, where it scrolls away as the conversation grows instead of staying pinned as a frame. + +`multi-line-dialog-prompts` closed this exact gap for **transient** dialogs (`InputRequest.Prompt` et al. render a multi-line preamble above a dialog widget). This change is the analogue for the **persistent** base input editor: a consumer-set preamble band pinned directly above it, mirroring the status surface that already pins rows below it. + +dmon is dcli's vehicle for proving and improving the substrate; a workaround in dmon (scrollback re-prints) is a substrate-gap signal. Closing it here lets dmon — and any future consumer wanting persistent input chrome (a session header, a mode banner, a hint line) — use dcli's intended surface. + +## What Changes + +- **New public surface `ITerminal.InputPreamble`** (type `IInputPreamble`, impl `InputPreambleSurface`), mirroring `ITerminal.Status` / `StatusSurface` one-for-one: + - `SetRows(params Line[])` and `SetRows(IReadOnlyList)` replace the preamble content; an empty argument clears it. + - Fire-and-forget via `_loop.Post(new SetInputPreambleCommand(rows))`; applied on the render-loop thread. + - Wired in `Terminal` construction exactly as `Status = new StatusSurface(loop)`. +- **New persistent band in the fixed-region stack.** The preamble renders directly above the input editor band (below the live window). `RenderModel.FixedRegion` gains a `Preamble` rows holder; `SetInputPreambleCommand.Apply` sets `model.FixedRegion.Preamble.Rows` and marks dirty; `FixedRegionComposer.Compose` emits the preamble rows immediately above the input rows. +- **Budget participation.** The preamble participates in the fixed-region height budget. Under pressure it truncates **before** the input editor loses its last usable row; the status band stays sacred (never squeezed). Null/empty preamble paints nothing and returns its rows to the budget. +- **Presentational only.** The preamble is not part of the intercept chain — it never consumes keys; all keys reach the input editor (or the active overlay) as before. +- **Version bump:** `dcli` and `Dcli.Testing` `0.2.0-rc.4` → `0.2.0-rc.5` (additive, preview channel). + +## Capabilities + +### New Capabilities + +None — this change extends an existing capability only. + +### Modified Capabilities + +- `fixed-region`: MODIFIED the **Bottom-pinned component stack** requirement to include a persistent input-preamble band rendered directly above the input editor; ADDED a **Persistent input preamble** requirement specifying the `ITerminal.InputPreamble` surface, its `SetRows` semantics, persistence across renders/turns, null/empty clearing, height-budget participation (truncate-before-input, status stays sacred), and its presentational (non-intercepting) nature. + +## Impact + +- **Public API (`Dcli`):** additive — one new property `ITerminal.InputPreamble`, one new surface type. No existing signature changes; fully backwards compatible. Binary-compat is irrelevant on the preview channel; dmon takes a local reference and recompiles. +- **Public API (`Dcli.Testing`):** `HeadlessTerminal` exposes the same `InputPreamble` surface so consumers can assert preamble rows in tests; rendering flows through the existing painter. +- **Production code:** small. A surface type + a loop command (both mirror `StatusSurface` / `SetStatusCommand`), a `Preamble` holder on the render model, and one band insertion in `FixedRegionComposer.Compose` plus its budget arithmetic. +- **Tests:** ~6–8 in `tests/Dcli.Tests/` via `HeadlessTerminal` — preamble renders directly above the editor; persists across successive submissions; empty clears; truncates under budget pressure while the editor keeps ≥1 row and status stays fully rendered; keys fall through to the editor. Existing fixed-region tests stay green as a regression guard. +- **Consumers:** unblocks dmon's Terminal UX redesign (the pinned `── dmon ──` header rule above the input). dmon drops the scrollback-reprint workaround. +- **Out of scope (deferred):** + - The input **prompt-prefix glyph** (`❯` shown before the editable region) — still deferred per the existing note in `InputSurface` (`§10` prompt-prefix refinement). The preamble is above the editor, not on the editor's line; the `❯` is a separate feature dmon can pursue next if it wants the glyph on the live line. + - Welcome banner / message-of-the-day — these are ordinary scrollback content owned by the consumer (dmon), not fixed-region chrome. + - Per-band styling/borders beyond what a consumer composes into the `Line` rows themselves. diff --git a/openspec/changes/persistent-input-preamble/specs/fixed-region/spec.md b/openspec/changes/persistent-input-preamble/specs/fixed-region/spec.md new file mode 100644 index 0000000..e42f2f0 --- /dev/null +++ b/openspec/changes/persistent-input-preamble/specs/fixed-region/spec.md @@ -0,0 +1,41 @@ +## MODIFIED Requirements + +### Requirement: Bottom-pinned component stack +The fixed region SHALL be a contiguous, bottom-pinned stack of components — a persistent input preamble, the input editor, status, and overlays — with the live window rendered above it. When set, the persistent input preamble SHALL render directly above the input editor band; the status band SHALL remain the bottommost, sacred band. + +#### Scenario: Stays pinned while content streams +- **WHEN** scrollback content streams into the live window +- **THEN** the fixed region remains pinned at the bottom and is redrawn in place + +#### Scenario: Preamble sits directly above the input editor +- **WHEN** a persistent input preamble is set and the base input editor is active +- **THEN** the preamble rows render immediately above the input editor band and below the live window + +## ADDED Requirements + +### Requirement: Persistent input preamble +The library SHALL expose a persistent input-preamble surface — `ITerminal.InputPreamble` — through which the consumer sets a sequence of styled `Line` rows pinned directly above the base input editor. The surface SHALL mirror the status surface: it SHALL provide `SetRows(params Line[])` and `SetRows(IReadOnlyList)` overloads that replace the preamble content, and an empty argument SHALL clear it. Updates SHALL be applied on the render-loop thread and SHALL persist across renders and successive input submissions until changed or cleared. + +When the preamble is `null` or empty, no preamble row SHALL be painted and the freed rows SHALL return to the fixed-region budget. The preamble SHALL participate in the fixed-region height budget: under budget pressure the preamble SHALL truncate before the input editor loses its last usable row, and the status band SHALL remain sacred (never squeezed). + +The preamble SHALL be presentational only — it SHALL NOT participate in the intercept chain and SHALL NOT consume keys; all keys SHALL reach the active overlay or the input editor as before, and the hardware cursor SHALL continue to park at the input caret. + +#### Scenario: Preamble renders above the input editor +- **WHEN** the consumer sets a two-line preamble via `ITerminal.InputPreamble.SetRows(...)` +- **THEN** both rows paint top-to-bottom immediately above the input editor on the next frame + +#### Scenario: Preamble persists across input submissions +- **WHEN** a preamble is set once and the user submits several inputs in succession +- **THEN** the preamble remains rendered above the editor across each turn without being re-set + +#### Scenario: Empty preamble clears it +- **WHEN** `SetRows` is called with an empty argument list +- **THEN** no preamble rows are painted and the freed rows return to the fixed-region budget + +#### Scenario: Preamble truncates under budget pressure +- **WHEN** the preamble row count plus the input editor's minimum height plus the status rows exceeds the fixed-region cap +- **THEN** the preamble truncates first, the input editor retains at least one usable row, and the status rows remain fully rendered + +#### Scenario: Preamble does not intercept keys +- **WHEN** keys are routed while a preamble is set and no overlay is active +- **THEN** the preamble consumes no keys and every key reaches the input editor diff --git a/openspec/changes/persistent-input-preamble/tasks.md b/openspec/changes/persistent-input-preamble/tasks.md new file mode 100644 index 0000000..8a7409f --- /dev/null +++ b/openspec/changes/persistent-input-preamble/tasks.md @@ -0,0 +1,36 @@ +## 1. Public surface + +- [ ] 1.1 Add `IInputPreamble` to `src/Dcli/` — interface with `SetRows(params Line[])` and `SetRows(IReadOnlyList)`, doc-comments mirroring `IStatus` +- [ ] 1.2 Add `InputPreambleSurface` (impl) mirroring `StatusSurface`: each `SetRows` overload posts `_loop.Post(new SetInputPreambleCommand(rows))`; empty argument clears +- [ ] 1.3 Add `ITerminal.InputPreamble` property (and on `HeadlessTerminal`); wire `InputPreamble = new InputPreambleSurface(loop)` in `Terminal` construction exactly as `Status` is wired + +## 2. Render model + composer band + +- [ ] 2.1 Add a `Preamble` rows holder to `RenderModel.FixedRegion` (parallel to `Status`) +- [ ] 2.2 Add `SetInputPreambleCommand : ILoopCommand` whose `Apply(model)` sets `model.FixedRegion.Preamble.Rows = rows` and calls `model.MarkDirty()` (mirror `SetStatusCommand`) +- [ ] 2.3 In `FixedRegionComposer.Compose`, emit the preamble rows immediately above the input editor band (below an above-input overlay, above the input rows); reuse the existing `fixedRows.AddRange(...)` assembly +- [ ] 2.4 Fold the preamble into the height-budget arithmetic: preamble truncates before the input editor loses its last usable row; status remains sacred. Null/empty preamble contributes zero rows + +## 3. Tests (tests/Dcli.Tests, via HeadlessTerminal) + +- [ ] 3.1 Preamble rows render directly above the input editor on the next frame +- [ ] 3.2 Preamble persists across multiple input submissions without being re-set +- [ ] 3.3 `SetRows` with an empty argument clears the preamble and returns its rows to the budget +- [ ] 3.4 Under a constrained `MaxFixedHeight`, the preamble truncates while the input editor keeps ≥1 row and the status rows stay fully rendered +- [ ] 3.5 Keys routed with a preamble set (no overlay) all reach the input editor; the hardware cursor parks at the input caret +- [ ] 3.6 Existing fixed-region/status tests stay green (regression guard) + +## 4. Sample / demo + +- [ ] 4.1 Update a sample (or the demo) to set a persistent preamble (e.g. a labelled rule above the input) so the surface is exercised end-to-end + +## 5. Validation & packaging + +- [ ] 5.1 `dotnet build` clean (analyzers warnings-as-errors; nullable enabled) +- [ ] 5.2 `dotnet test` all green +- [ ] 5.3 `dotnet format --verify-no-changes` clean +- [ ] 5.4 `openspec validate persistent-input-preamble --strict` passes +- [ ] 5.5 Version bump: `src/Dcli/Dcli.csproj` and `src/Dcli.Testing/Dcli.Testing.csproj` `0.2.0-rc.4` → `0.2.0-rc.5` +- [ ] 5.6 Update `CHANGELOG.md` with the new surface +- [ ] 5.7 Keep a `DEVLOG.md` in the change directory while applying +- [ ] 5.8 dmon coordination: note that dmon's Terminal UX change consumes `ITerminal.InputPreamble` and must reference dcli `0.2.0-rc.5` From 6a4454158b0f22454de45bdc543216c899c04447 Mon Sep 17 00:00:00 2001 From: Rendle Date: Thu, 18 Jun 2026 23:19:56 +0100 Subject: [PATCH 2/9] docs(openspec): propose input-prompt-prefix Lift the deferred input prompt-prefix on the base editor: ITerminal.Input gains SetPrompt(Line/string), rendered inline before the editable region on the first visual row with a display-width-aware caret offset. Completes dmon's framed input (the live-line glyph, companion to persistent-input-preamble). ADDED fixed-region "Input editor prompt prefix". Bumps to the next preview revision after persistent-input-preamble. Co-Authored-By: Claude Opus 4.8 --- .../input-prompt-prefix/.openspec.yaml | 2 + .../changes/input-prompt-prefix/design.md | 54 +++++++++++++++++++ .../changes/input-prompt-prefix/proposal.md | 40 ++++++++++++++ .../specs/fixed-region/spec.md | 32 +++++++++++ openspec/changes/input-prompt-prefix/tasks.md | 35 ++++++++++++ 5 files changed, 163 insertions(+) create mode 100644 openspec/changes/input-prompt-prefix/.openspec.yaml create mode 100644 openspec/changes/input-prompt-prefix/design.md create mode 100644 openspec/changes/input-prompt-prefix/proposal.md create mode 100644 openspec/changes/input-prompt-prefix/specs/fixed-region/spec.md create mode 100644 openspec/changes/input-prompt-prefix/tasks.md diff --git a/openspec/changes/input-prompt-prefix/.openspec.yaml b/openspec/changes/input-prompt-prefix/.openspec.yaml new file mode 100644 index 0000000..95ae5a2 --- /dev/null +++ b/openspec/changes/input-prompt-prefix/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-18 diff --git a/openspec/changes/input-prompt-prefix/design.md b/openspec/changes/input-prompt-prefix/design.md new file mode 100644 index 0000000..1cb313b --- /dev/null +++ b/openspec/changes/input-prompt-prefix/design.md @@ -0,0 +1,54 @@ +## Context + +The owned input editor (`model.FixedRegion.Editor`, a `TextBuffer`) renders via `Editor.Render(width, budget)` → `RenderResult { VisibleRows, CaretPosition }`. `InputSurface` (`ITerminal.Input`) drives it through fire-and-forget `ILoopCommand`s (`SetTextCommand`, `ClearCommand`) that mutate the editor and mark the model dirty. `InputSurface`'s own doc-comment records the prompt prefix as a deferred §10 feature. + +The dialog path already renders a *preamble above* its widget (`multi-line-dialog-prompts`); `persistent-input-preamble` adds a persistent band above the base editor. This change is different in kind: an **inline** prefix on the editor's own first row, before the editable text — the `❯` glyph. + +## Goals / Non-Goals + +**Goals:** +- A consumer-set, persistent prompt prefix on the base editor's first visual row, with the caret correctly offset. +- An API and command that mirror the existing `InputSurface.SetText` path. +- Backwards compatible: no prompt ⇒ exactly v1 behaviour. + +**Non-Goals:** +- Hanging-indent of wrapped continuation rows (start at column 0 here). +- `ReadOnly` input (separate deferred item). +- A prefix on dialog input fields (dialogs use an above-widget preamble). + +## Decisions + +### D1: API on InputSurface, mirroring SetText + +`IInput`/`InputSurface` gain `SetPrompt(Line)` and `SetPrompt(string)` (string via `Line.FromText`). Each posts `_loop.Post(new SetPromptCommand(line))`; `SetPromptCommand.Apply` calls `model.FixedRegion.Editor.SetPrompt(line)` and `model.MarkDirty()` — the exact shape of `SetTextCommand`. An empty/`null` prompt clears it (renders no prefix). `SetPrompt` does **not** emit `InputChanged` (consistent with `SetText`/`Clear`). + +### D2: Inline first-row render with display-width-aware caret + +The prompt occupies the leading columns of the editor's **first** visual row. The editable text begins at column `promptWidth` on that row, and the caret's column is offset by `promptWidth` while it is on row 0. The first row's text capacity is `width - promptWidth`; wrapping is computed against that reduced width for row 0. + +This parallels how `InputDialog` already offsets its caret to account for prompt rows (`InputDialog.Render`), but along the column axis on a single row rather than the row axis. + +### D3: Continuation rows begin at column 0 + +Wrapped continuation rows are **not** re-prefixed; they start at column 0 and use the full width. This keeps the width arithmetic simple (only row 0 is reduced) and matches common shell behaviour. Hanging-indent (aligning continuation text under the first row's text) is a deferred refinement. + +### D4: The prefix is chrome, never buffer content + +The prompt is not inserted into the editor buffer: `Submit` and `InputChanged` return only the user's text, history stores only the user's text, and `SetText`/`Clear` operate on buffer content independent of the prompt. Because the base editor is not a secret field, no masking interaction arises (secret rendering is an `InputRequest`/dialog concern). + +### D5: Persistence + +`SetPrompt` is set-and-hold: the prefix renders every frame until changed or cleared. The consumer sets it once (e.g. `SetPrompt("❯ ")` at startup); it survives submissions, history recall, and `Clear()` (clearing the buffer does not clear the prompt). + +### D6: Remove the deferral note + +The `InputSurface` remarks list drops the `Prompt` bullet (the `ReadOnly` bullet remains). No other doc churn. + +## Risks / Trade-offs + +- **First-row-only width reduction** (D2/D3) means a long first line wraps slightly earlier than continuation lines; acceptable and conventional. Hanging-indent would remove the visual seam but complicates the arithmetic — deferred. +- **Coordination with `persistent-input-preamble`**: both touch the fixed region but in different bands (preamble = a stack band above the editor; prompt = inline on the editor's row). They are independent and can land in either order; the version bump is coordinated (see proposal). + +## Open Questions + +- **Version target / bundling.** `0.2.0-rc.6` assumed if applied after `persistent-input-preamble`; if the two are applied together, a single bump suffices. Confirm at packaging time. diff --git a/openspec/changes/input-prompt-prefix/proposal.md b/openspec/changes/input-prompt-prefix/proposal.md new file mode 100644 index 0000000..7faa32b --- /dev/null +++ b/openspec/changes/input-prompt-prefix/proposal.md @@ -0,0 +1,40 @@ +## Why + +dmon's Terminal redesign wants the always-on input line to read `❯ user typing here █` — a prompt glyph before the editable region. dcli's base input editor has no prompt prefix today; the gap is documented directly in `InputSurface`: + +> `Prompt` — a prompt prefix shown before the editable region — is not in the §10 model and is deferred to a later §10 refinement pass. + +A consumer can fake a glyph only by echoing it into scrollback *after* submit (which dmon does today), never on the live editing line. This change lifts that deferral: the persistent input surface gains a consumer-set prompt prefix rendered inline before the editable text. + +It is the companion to `persistent-input-preamble`: the preamble pins chrome **above** the editor (the `── dmon ──` rule); this puts a glyph **on** the editor's line (the `❯`). Together they complete the framed-input look dmon is after. dcli is the substrate dmon proves out; this closes the second of the two documented input-chrome gaps. + +## What Changes + +- **Lift the prompt-prefix deferral on the base input editor.** `InputSurface` (`ITerminal.Input`) gains `SetPrompt(Line)` and `SetPrompt(string)` (the latter via `Line.FromText`); an empty or `null` prompt renders no prefix (the v1 default). The prompt persists across renders and submissions until changed. +- **Command + model.** A `SetPromptCommand : ILoopCommand` posts via `_loop.Post` and sets the editor's prompt on the render-loop thread (mirrors `SetTextCommand` → `model.FixedRegion.Editor`). The owned editor (`TextBuffer`) gains a prompt field consumed by its render. +- **Inline render.** The prompt occupies the leading columns of the editor's **first** visual row; the editable text begins immediately after it and the caret is offset by the prompt's display width. Wrapping on the first row is computed against the width reduced by the prefix; wrapped continuation rows begin at column 0 (the prefix is not repeated). +- **Presentational only.** The prefix is chrome, not buffer content: it is never returned by `Submit`/`InputChanged` and is not part of history. +- **Remove the documented deferral** note for `Prompt` in `InputSurface` (the `ReadOnly` deferral stays). +- **Version bump:** `dcli` and `Dcli.Testing` to the next preview revision after `persistent-input-preamble` (`0.2.0-rc.6`, or a shared bump if the two changes are applied together). + +## Capabilities + +### New Capabilities + +None — this change extends an existing capability only. + +### Modified Capabilities + +- `fixed-region`: ADDED an **Input editor prompt prefix** requirement specifying the `ITerminal.Input.SetPrompt(...)` surface, inline first-row rendering with display-width-aware caret offset, continuation-row behaviour, empty/unset default, persistence across submissions, and the prefix's presentational (not-in-buffer) nature. + +## Impact + +- **Public API (`Dcli`):** additive — `IInput`/`InputSurface` gain `SetPrompt` overloads. No existing signatures change; fully backwards compatible (no prompt set ⇒ identical to v1). +- **Public API (`Dcli.Testing`):** `HeadlessTerminal`'s input surface exposes `SetPrompt`; rendered prefix is assertable through the existing painter. +- **Production code:** small. A `SetPromptCommand` (mirror `SetTextCommand`), a prompt field on the owned `TextBuffer`/editor, and prefix-aware first-row rendering + caret/width arithmetic in the editor render path. +- **Tests:** ~5–6 in `tests/Dcli.Tests/` via `HeadlessTerminal` — prefix renders before the text; caret sits after the prefix; empty prompt = no prefix (regression); prompt persists across submissions; prompt is not part of submitted text; first-row wrapping accounts for the prefix width. +- **Consumers:** with `persistent-input-preamble`, completes dmon's framed input (`❯` on the live line). dmon drops its scrollback `❯`-echo workaround for the live editor. +- **Out of scope (deferred):** + - Hanging-indent of wrapped continuation rows under the prompt (continuation rows start at column 0 here; alignment is a later refinement). + - `ReadOnly` input (still deferred per the remaining `InputSurface` note). + - A prompt prefix on dialog (`InputRequest`) fields — dialogs already carry an above-widget preamble; an inline dialog-field prefix is a separate future item. diff --git a/openspec/changes/input-prompt-prefix/specs/fixed-region/spec.md b/openspec/changes/input-prompt-prefix/specs/fixed-region/spec.md new file mode 100644 index 0000000..f6f3bd1 --- /dev/null +++ b/openspec/changes/input-prompt-prefix/specs/fixed-region/spec.md @@ -0,0 +1,32 @@ +## ADDED Requirements + +### Requirement: Input editor prompt prefix +The owned input editor SHALL support an optional, consumer-set prompt prefix rendered immediately before the editable region on the editor's first visual row. The persistent input surface (`ITerminal.Input`) SHALL expose `SetPrompt(Line)` and `SetPrompt(string)` (the latter converted via `Line.FromText`); an empty or `null` prompt SHALL render no prefix, which is the default (unchanged v1 behaviour). `SetPrompt` SHALL NOT emit `InputChanged`. The prompt SHALL persist across renders, submissions, history recall, and buffer `Clear` until changed. + +The caret SHALL be positioned immediately after the prefix while it is on the first row, accounting for the prefix's display width; the first row's text capacity SHALL be the available width reduced by the prefix width, and wrapping on the first row SHALL be computed against that reduced width. Wrapped continuation rows SHALL begin at column 0 and SHALL NOT repeat the prefix. + +The prompt prefix SHALL be presentational chrome: it SHALL NOT be part of the editor buffer, SHALL NOT be returned by `Submit`, SHALL NOT appear in `InputChanged` payloads, and SHALL NOT be stored in input history. + +#### Scenario: Prompt renders before the editable text +- **WHEN** the consumer calls `ITerminal.Input.SetPrompt("❯ ")` and the user types `hello` +- **THEN** the editor's first row paints `❯ hello` with the prompt in its configured style + +#### Scenario: Caret sits after the prefix +- **WHEN** a prompt is set and the buffer is empty +- **THEN** the hardware cursor parks at the column immediately after the prefix, not at column 0 + +#### Scenario: Empty prompt renders no prefix +- **WHEN** no prompt is set (or `SetPrompt` is called with an empty/`null` value) +- **THEN** the editor renders exactly as v1 with the editable text starting at column 0 + +#### Scenario: Prompt persists across submissions +- **WHEN** a prompt is set once and the user submits several inputs in succession +- **THEN** the prefix remains rendered on each new input line without being re-set + +#### Scenario: Prompt is not part of submitted text +- **WHEN** a prompt is set and the user types `hello` and submits +- **THEN** the submitted value is `hello` (the prefix is not included), and input history stores only `hello` + +#### Scenario: First-row wrapping accounts for the prefix width +- **WHEN** a prompt is set and the user types text that exceeds `width - promptWidth` on the first row +- **THEN** the text wraps at the reduced first-row width and the continuation row begins at column 0 diff --git a/openspec/changes/input-prompt-prefix/tasks.md b/openspec/changes/input-prompt-prefix/tasks.md new file mode 100644 index 0000000..26386d6 --- /dev/null +++ b/openspec/changes/input-prompt-prefix/tasks.md @@ -0,0 +1,35 @@ +## 1. Public surface + +- [ ] 1.1 Add `SetPrompt(Line)` and `SetPrompt(string)` to `IInput` (string via `Line.FromText`); doc-comment that empty/`null` clears and that it does not emit `InputChanged` +- [ ] 1.2 Implement both overloads on `InputSurface`: each posts `_loop.Post(new SetPromptCommand(line))` +- [ ] 1.3 Remove the `Prompt` bullet from the `InputSurface` "Documented gaps" remarks (keep the `ReadOnly` bullet) + +## 2. Command + editor model + +- [ ] 2.1 Add `SetPromptCommand : ILoopCommand` whose `Apply(model)` calls `model.FixedRegion.Editor.SetPrompt(line)` and `model.MarkDirty()` (mirror `SetTextCommand`) +- [ ] 2.2 Add a prompt field to the owned editor (`TextBuffer`/editor) with a `SetPrompt` mutator; empty/`null` means no prefix +- [ ] 2.3 Render the prefix on the editor's first visual row: editable text starts at `promptWidth`, caret column offset by `promptWidth` on row 0, first-row capacity = `width - promptWidth`; continuation rows begin at column 0 (no repeat). Ensure the prefix is never inserted into buffer contents + +## 3. Tests (tests/Dcli.Tests, via HeadlessTerminal) + +- [ ] 3.1 Prefix renders before the editable text on the first row +- [ ] 3.2 Caret parks immediately after the prefix when the buffer is empty +- [ ] 3.3 No prompt set ⇒ editor renders identically to v1 (regression guard) +- [ ] 3.4 Prompt persists across multiple submissions and across `Clear()` +- [ ] 3.5 Submitted value and `InputChanged` payload exclude the prefix; history stores only user text +- [ ] 3.6 First-row wrapping uses `width - promptWidth`; continuation row starts at column 0 + +## 4. Sample / demo + +- [ ] 4.1 Update a sample (or the demo) to set an input prompt (e.g. `SetPrompt("❯ ")`) so the surface is exercised end-to-end + +## 5. Validation & packaging + +- [ ] 5.1 `dotnet build` clean (analyzers warnings-as-errors; nullable enabled) +- [ ] 5.2 `dotnet test` all green +- [ ] 5.3 `dotnet format --verify-no-changes` clean +- [ ] 5.4 `openspec validate input-prompt-prefix --strict` passes +- [ ] 5.5 Version bump: `src/Dcli/Dcli.csproj` and `src/Dcli.Testing/Dcli.Testing.csproj` to the next preview revision after `persistent-input-preamble` (`0.2.0-rc.6`, or a shared bump if applied together) +- [ ] 5.6 Update `CHANGELOG.md` with the new `SetPrompt` surface +- [ ] 5.7 Keep a `DEVLOG.md` in the change directory while applying +- [ ] 5.8 dmon coordination: dmon's Terminal UX change consumes `ITerminal.Input.SetPrompt(...)` for the `❯` glyph alongside `ITerminal.InputPreamble` From 6ba21bb878eb1fb288cdce333c83bb1ddb761d10 Mon Sep 17 00:00:00 2001 From: Rendle Date: Thu, 18 Jun 2026 23:27:09 +0100 Subject: [PATCH 3/9] update meta --- .claude/agents/worker.md | 2 +- CLAUDE.md | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.claude/agents/worker.md b/.claude/agents/worker.md index dc93617..32761d6 100644 --- a/.claude/agents/worker.md +++ b/.claude/agents/worker.md @@ -51,7 +51,7 @@ If a task seems to require breaking one of these, **stop and surface it** — do ## Boundaries — what you must NOT do -- **Do not tick `tasks.md` boxes.** The orchestrator flips `[ ]→[x]` after the gates pass. Instead, report which `N.M` tasks you completed. +- **Do not tick `tasks.md` boxes.** The orchestrator flips `[ ]→[x]` after the gates pass. Instead, report which `N.M` tasks you completed. Never rewrite `tasks.md` wholesale — it holds all future sections. - **Do not commit, push, open PRs, or amend.** The orchestrator commits per section. - **Do not self-approve.** When the section builds and tests pass, report it complete and request the `reviewer`. - Do not suppress warnings, disable analyzers, or weaken tests to go green. diff --git a/CLAUDE.md b/CLAUDE.md index 66621ab..1bfe0fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,8 +70,8 @@ The unit of work is a **`## N.` section**. Walk sections in order from the resum go hunting — give it what it needs to stay focused. 2. **Worker implements the whole section.** If a section is large or complex (e.g. the VT input parser), split it into sub-chunks across multiple `worker` calls — but it remains **one commit at section end**. -3. **Audit.** Spawn `reviewer` on the section diff (correctness, ADR compliance, OpenSpec scope, C# - idiom, agentic-AI design quality). +3. **Audit.** Spawn `reviewer` on the section diff (correctness, binding-design-decision compliance, + OpenSpec scope, C# idiom, terminal-rendering safety). 4. **Review loop.** Feed the reviewer's findings back to the `worker`; worker fixes; `reviewer` re-audits. **Repeat until the reviewer signs off.** 5. **Gates — all four must pass before ticking any box:** @@ -80,7 +80,8 @@ The unit of work is a **`## N.` section**. Walk sections in order from the resum - `openspec validate --strict` - `dotnet format --verify-no-changes` clean If a gate fails, it's back to step 4, not a commit. -6. **Tick the boxes.** Mark every `- [x] N.M` in the section in `tasks.md`. +6. **Tick the boxes.** Mark every `- [x] N.M` in the section in `tasks.md`. Never rewrite `tasks.md` + wholesale — only flip `[ ]→[x]`; it holds all future sections. 7. **Commit — one conventional commit per section:** ``` feat():
(section N) @@ -91,6 +92,8 @@ The unit of work is a **`## N.` section**. Walk sections in order from the resum Co-Authored-By: Claude Opus 4.7 ``` +8. **Report and pause.** Tell the user what landed in this section and ask before starting the next — + unless told to "apply all sections" / "apply without pausing". ## 4. Stop and ask — do not push on From 2d69d1d0a19a69dee51ab8d200e6046500729a7b Mon Sep 17 00:00:00 2001 From: Rendle Date: Thu, 18 Jun 2026 23:53:12 +0100 Subject: [PATCH 4/9] feat(persistent-input-preamble): IInputPreamble public surface (section 1) - 1.1 Add IInputPreamble interface to ITerminal.cs mirroring IStatus - 1.2 Add InputPreambleSurface with nested SetInputPreambleCommand, mirroring StatusSurface exactly - 1.3 Wire ITerminal.InputPreamble property; Terminal ctor constructs InputPreambleSurface(loop) Co-Authored-By: Claude Sonnet 4.6 --- src/Dcli/ITerminal.cs | 30 +++++++++++++ src/Dcli/InputPreambleSurface.cs | 65 +++++++++++++++++++++++++++ src/Dcli/Terminal.cs | 6 +++ tests/Dcli.Tests/FakeTerminalTests.cs | 20 +++++++++ 4 files changed, 121 insertions(+) create mode 100644 src/Dcli/InputPreambleSurface.cs diff --git a/src/Dcli/ITerminal.cs b/src/Dcli/ITerminal.cs index f7df0aa..22a14d5 100644 --- a/src/Dcli/ITerminal.cs +++ b/src/Dcli/ITerminal.cs @@ -98,6 +98,31 @@ public interface IStatus void SetRows(IReadOnlyList rows); } +/// +/// Consumer-facing interface for setting the preamble rows rendered directly above the input editor. +/// +/// +/// The preamble is presentational only — it is not part of the key-routing intercept chain. +/// When the height budget is tight, preamble rows are truncated before the editor loses its +/// last row; the status bar remains sacred and is never truncated. +/// +public interface IInputPreamble +{ + /// + /// Replaces the preamble content with the given rows. + /// An empty argument list clears the preamble. + /// + /// The rows to display directly above the input editor. + void SetRows(params Line[] rows); + + /// + /// Replaces the preamble content with the given rows. + /// Passing an empty list clears the preamble. + /// + /// The rows to display directly above the input editor. + void SetRows(IReadOnlyList rows); +} + /// /// Consumer-facing interface for showing and hiding the autocomplete dropdown overlay. /// @@ -157,6 +182,11 @@ public interface ITerminal : IAsyncDisposable /// IStatus Status { get; } + /// + /// Input preamble surface: set styled rows rendered directly above the input editor. + /// + IInputPreamble InputPreamble { get; } + /// /// Autocomplete overlay surface: show and hide the completion dropdown. /// diff --git a/src/Dcli/InputPreambleSurface.cs b/src/Dcli/InputPreambleSurface.cs new file mode 100644 index 0000000..b733fff --- /dev/null +++ b/src/Dcli/InputPreambleSurface.cs @@ -0,0 +1,65 @@ +using Dcli.Internal.RenderLoop; + +namespace Dcli; + +/// +/// Consumer-facing surface for setting the preamble rows rendered directly above the input editor. +/// +/// +/// +/// All methods post fire-and-forget s; they +/// return before the command is applied or a frame is painted. +/// +/// +/// Presentational only: the preamble is not part of the key-routing intercept +/// chain. Setting rows has no effect on keyboard input handling. +/// +/// +/// Thread safety: all methods are safe to call from any thread. +/// +/// +public sealed class InputPreambleSurface : IInputPreamble +{ + private readonly LoopEngine _loop; + + internal InputPreambleSurface(LoopEngine loop) + { + ArgumentNullException.ThrowIfNull(loop); + _loop = loop; + } + + /// + /// Replaces the preamble content with the given rows. + /// An empty argument list clears the preamble (no rows rendered above the editor). + /// + public void SetRows(params Line[] rows) + { + ArgumentNullException.ThrowIfNull(rows); + _loop.Post(new SetInputPreambleCommand(rows)); + } + + /// + /// Replaces the preamble content with the given rows. + /// Passing an empty list clears the preamble. + /// + public void SetRows(IReadOnlyList rows) + { + ArgumentNullException.ThrowIfNull(rows); + _loop.Post(new SetInputPreambleCommand([.. rows])); + } + + // ── Command ──────────────────────────────────────────────────────────────── + + private sealed class SetInputPreambleCommand : ILoopCommand + { + private readonly IReadOnlyList _rows; + + internal SetInputPreambleCommand(IReadOnlyList rows) => _rows = rows; + + void ILoopCommand.Apply(RenderModel model) + { + model.FixedRegion.Preamble.Rows = _rows; + model.MarkDirty(); + } + } +} diff --git a/src/Dcli/Terminal.cs b/src/Dcli/Terminal.cs index 7a5581e..adffd19 100644 --- a/src/Dcli/Terminal.cs +++ b/src/Dcli/Terminal.cs @@ -58,6 +58,7 @@ private Terminal( Scrollback = new ScrollbackSurface(loop); Input = new InputSurface(loop); Status = new StatusSurface(loop); + InputPreamble = new InputPreambleSurface(loop); Autocomplete = new AutocompleteSurface(loop); } @@ -97,6 +98,11 @@ private Terminal( /// public IStatus Status { get; } + /// + /// Input preamble surface: set styled rows rendered directly above the input editor. + /// + public IInputPreamble InputPreamble { get; } + /// /// Autocomplete overlay surface: show and hide the completion dropdown. /// diff --git a/tests/Dcli.Tests/FakeTerminalTests.cs b/tests/Dcli.Tests/FakeTerminalTests.cs index 41c9efa..96e92fa 100644 --- a/tests/Dcli.Tests/FakeTerminalTests.cs +++ b/tests/Dcli.Tests/FakeTerminalTests.cs @@ -113,6 +113,24 @@ public void Show(IReadOnlyList candidates) public void Hide() => HideCount++; } +/// Records SetRows calls for the input preamble surface. +internal sealed class FakeInputPreamble : IInputPreamble +{ + internal List> SetCalls { get; } = []; + + public void SetRows(params Line[] rows) + { + ArgumentNullException.ThrowIfNull(rows); + SetCalls.Add(rows); + } + + public void SetRows(IReadOnlyList rows) + { + ArgumentNullException.ThrowIfNull(rows); + SetCalls.Add(rows); + } +} + #endregion #region FakeTerminal @@ -131,6 +149,7 @@ internal sealed class FakeTerminal : ITerminal internal FakeScrollback FakeScrollback { get; } = new(); internal FakeInput FakeInput { get; } = new(); internal FakeStatus FakeStatus { get; } = new(); + internal FakeInputPreamble FakeInputPreamble { get; } = new(); internal FakeAutocomplete FakeAutocomplete { get; } = new(); // ── Dialog recordings ───────────────────────────────────────────────────── @@ -163,6 +182,7 @@ internal sealed class FakeTerminal : ITerminal public IScrollback Scrollback => FakeScrollback; public IInput Input => FakeInput; public IStatus Status => FakeStatus; + public IInputPreamble InputPreamble => FakeInputPreamble; public IAutocomplete Autocomplete => FakeAutocomplete; public ChannelReader Events => _events.Reader; From 49af1feb550d651c864a25fa472b5f48bc4e6501 Mon Sep 17 00:00:00 2001 From: Rendle Date: Thu, 18 Jun 2026 23:53:27 +0100 Subject: [PATCH 5/9] feat(persistent-input-preamble): render model, composer band, budget (section 2) - 2.1 Add PreambleLine rows holder to FixedRegionComposer (parallel to StatusLine) - 2.2 SetInputPreambleCommand applies Preamble.Rows and marks model dirty - 2.3 FixedRegionComposer.Compose inserts preamble between above-input overlay and input rows - 2.4 Budget arithmetic: preamble truncates before editor loses last row; status stays sacred Also fix: BelowInput overlay caret offset now includes preamble rows (aboveCount) Co-Authored-By: Claude Sonnet 4.6 --- .../persistent-input-preamble/DEVLOG.md | 37 +++++++++++++++++ .../persistent-input-preamble/tasks.md | 14 +++---- .../FixedRegion/FixedRegionComposer.cs | 41 ++++++++++++++----- src/Dcli/Internal/FixedRegion/PreambleLine.cs | 23 +++++++++++ src/Dcli/Internal/RenderLoop/RenderModel.cs | 2 +- tests/Dcli.Tests/FixedRegionTests.cs | 14 +++---- tests/Dcli.Tests/OverlayRoutingTests.cs | 12 +++--- 7 files changed, 111 insertions(+), 32 deletions(-) create mode 100644 openspec/changes/persistent-input-preamble/DEVLOG.md create mode 100644 src/Dcli/Internal/FixedRegion/PreambleLine.cs diff --git a/openspec/changes/persistent-input-preamble/DEVLOG.md b/openspec/changes/persistent-input-preamble/DEVLOG.md new file mode 100644 index 0000000..3e6a928 --- /dev/null +++ b/openspec/changes/persistent-input-preamble/DEVLOG.md @@ -0,0 +1,37 @@ +# DEVLOG — persistent-input-preamble + +## Status + +Sections 1 + 2 in progress (worker running). + +## Pre-flight notes + +- Change branch `change/persistent-input-preamble` created from `proposals/terminal-input-chrome` + (not from `main`) because the proposal artifacts only exist on that branch. +- `openspec validate` passes on the correct base branch. + +## Section 1 + 2 — Public surface & render model/composer + +Sections 1 and 2 are implemented together in one worker call because `InputPreambleSurface` +(Section 1) contains the nested `SetInputPreambleCommand` whose `Apply` references +`model.FixedRegion.Preamble` — the preamble holder that is Section 2 task 2.1. They cannot +compile independently, so the worker produces both sections, then two separate commits are made. + +### Key decisions carried into implementation + +- D1: Mirror `StatusSurface` exactly — same constructor, same overloads, nested command class. +- D2: Preamble rows placed below an above-input overlay, above the input editor rows. +- D3: Preamble yields before input editor; status is sacred. +- D4: SetRows is set-and-hold; empty argument clears. +- D5: Preamble is presentational — never in the intercept chain. +- D6: Interface `IInputPreamble`, impl `InputPreambleSurface`, parallel to `IStatus`/`StatusSurface`. + +### Internal model shape + +- New `PreambleLine` class in `src/Dcli/Internal/FixedRegion/` — mirrors `StatusLine`. +- `FixedRegionComposer` gains `_preamble : PreambleLine` field and `Preamble` property. +- `RenderModel.FixedRegion` constructed as `new(new TextBuffer(), new PreambleLine(), new StatusLine())`. +- `IInputPreamble` declared in `src/Dcli/ITerminal.cs` alongside `IStatus`. +- `InputPreambleSurface` in `src/Dcli/InputPreambleSurface.cs`. +- `ITerminal.InputPreamble : IInputPreamble` property added after `Status`. +- `Terminal` ctor: `InputPreamble = new InputPreambleSurface(loop)` after `Status = ...`. diff --git a/openspec/changes/persistent-input-preamble/tasks.md b/openspec/changes/persistent-input-preamble/tasks.md index 8a7409f..6e2b07f 100644 --- a/openspec/changes/persistent-input-preamble/tasks.md +++ b/openspec/changes/persistent-input-preamble/tasks.md @@ -1,15 +1,15 @@ ## 1. Public surface -- [ ] 1.1 Add `IInputPreamble` to `src/Dcli/` — interface with `SetRows(params Line[])` and `SetRows(IReadOnlyList)`, doc-comments mirroring `IStatus` -- [ ] 1.2 Add `InputPreambleSurface` (impl) mirroring `StatusSurface`: each `SetRows` overload posts `_loop.Post(new SetInputPreambleCommand(rows))`; empty argument clears -- [ ] 1.3 Add `ITerminal.InputPreamble` property (and on `HeadlessTerminal`); wire `InputPreamble = new InputPreambleSurface(loop)` in `Terminal` construction exactly as `Status` is wired +- [x] 1.1 Add `IInputPreamble` to `src/Dcli/` — interface with `SetRows(params Line[])` and `SetRows(IReadOnlyList)`, doc-comments mirroring `IStatus` +- [x] 1.2 Add `InputPreambleSurface` (impl) mirroring `StatusSurface`: each `SetRows` overload posts `_loop.Post(new SetInputPreambleCommand(rows))`; empty argument clears +- [x] 1.3 Add `ITerminal.InputPreamble` property (and on `HeadlessTerminal`); wire `InputPreamble = new InputPreambleSurface(loop)` in `Terminal` construction exactly as `Status` is wired ## 2. Render model + composer band -- [ ] 2.1 Add a `Preamble` rows holder to `RenderModel.FixedRegion` (parallel to `Status`) -- [ ] 2.2 Add `SetInputPreambleCommand : ILoopCommand` whose `Apply(model)` sets `model.FixedRegion.Preamble.Rows = rows` and calls `model.MarkDirty()` (mirror `SetStatusCommand`) -- [ ] 2.3 In `FixedRegionComposer.Compose`, emit the preamble rows immediately above the input editor band (below an above-input overlay, above the input rows); reuse the existing `fixedRows.AddRange(...)` assembly -- [ ] 2.4 Fold the preamble into the height-budget arithmetic: preamble truncates before the input editor loses its last usable row; status remains sacred. Null/empty preamble contributes zero rows +- [x] 2.1 Add a `Preamble` rows holder to `RenderModel.FixedRegion` (parallel to `Status`) +- [x] 2.2 Add `SetInputPreambleCommand : ILoopCommand` whose `Apply(model)` sets `model.FixedRegion.Preamble.Rows = rows` and calls `model.MarkDirty()` (mirror `SetStatusCommand`) +- [x] 2.3 In `FixedRegionComposer.Compose`, emit the preamble rows immediately above the input editor band (below an above-input overlay, above the input rows); reuse the existing `fixedRows.AddRange(...)` assembly +- [x] 2.4 Fold the preamble into the height-budget arithmetic: preamble truncates before the input editor loses its last usable row; status remains sacred. Null/empty preamble contributes zero rows ## 3. Tests (tests/Dcli.Tests, via HeadlessTerminal) diff --git a/src/Dcli/Internal/FixedRegion/FixedRegionComposer.cs b/src/Dcli/Internal/FixedRegion/FixedRegionComposer.cs index f06dc35..f31d76b 100644 --- a/src/Dcli/Internal/FixedRegion/FixedRegionComposer.cs +++ b/src/Dcli/Internal/FixedRegion/FixedRegionComposer.cs @@ -48,11 +48,13 @@ namespace Dcli.Internal.FixedRegion; /// internal sealed class FixedRegionComposer { + private readonly PreambleLine _preamble; private readonly StatusLine _status; - internal FixedRegionComposer(TextBuffer editor, StatusLine status) + internal FixedRegionComposer(TextBuffer editor, PreambleLine preamble, StatusLine status) { Editor = editor; + _preamble = preamble; _status = status; } @@ -62,6 +64,12 @@ internal FixedRegionComposer(TextBuffer editor, StatusLine status) /// internal TextBuffer Editor { get; } + /// + /// The preamble component. Exposed so façade commands can set + /// on the loop thread via model.FixedRegion.Preamble. + /// + internal PreambleLine Preamble => _preamble; + /// /// The status line component. Exposed so façade commands can set /// on the loop thread via model.FixedRegion.Status. @@ -128,7 +136,7 @@ internal void Compose(RenderModel model) } // Normal path. - // budget = rows available for input + overlay (status is sacred, already accounted for). + // budget = rows available for input + preamble + overlay (status is sacred, already accounted for). int budget = cap - statusCount; // > 0 here // Input keeps the caret visible: allot the full budget so the editor can scroll @@ -137,6 +145,15 @@ internal void Compose(RenderModel model) IReadOnlyList inputRows = editorResult.VisibleRows; (int editorCaretRow, int editorCaretCol) = editorResult.CaretPosition; + // Preamble truncates before the editor loses its last row. + // Budget remaining after the editor has claimed its rows. + IReadOnlyList preambleRows = _preamble.Rows; + int preambleNatural = preambleRows.Count; + int preambleBudget = Math.Max(0, budget - inputRows.Count); + int preambleCount = Math.Min(preambleNatural, preambleBudget); + if (preambleCount < preambleNatural) + preambleRows = preambleRows.Take(preambleCount).ToList(); + // Overlay absorbs the squeeze: whatever is left of the budget, capped at DefaultOverlayMaxRows. // Do NOT read overlay.MaxRows back after writing it — compute overlayCap from budget and // inputRows.Count to prevent stale values from blocking re-expansion when the budget grows. @@ -144,7 +161,7 @@ internal void Compose(RenderModel model) IReadOnlyList overlayRows = []; if (overlay is not null) { - int overlayCap = Math.Clamp(budget - inputRows.Count, 0, _defaultOverlayMaxRows); + int overlayCap = Math.Clamp(budget - inputRows.Count - preambleCount, 0, _defaultOverlayMaxRows); overlay.MaxRows = overlayCap; // sets the viewport for this frame (clamped to ≥1 by ScrollableList) // Only render when the budget permits at least one row; otherwise the overlay is // effectively squeezed to zero and we skip rendering to honour the cap proof. @@ -152,15 +169,17 @@ internal void Compose(RenderModel model) overlayRows = overlay.Render(width); // ≤ overlayCap rows } - // Assemble: [overlay if AboveInput] [input] [overlay if BelowInput] [status]. - // Proof that total ≤ cap: overlayRows.Count ≤ budget − inputRows.Count, - // so overlayRows.Count + inputRows.Count ≤ budget = cap − statusCount, - // thus total = overlayRows.Count + inputRows.Count + statusCount ≤ cap. - int aboveCount = (overlay?.Placement == OverlayPlacement.AboveInput) ? overlayRows.Count : 0; + // Assemble: [overlay if AboveInput] [preamble] [input] [overlay if BelowInput] [status]. + // Proof that total ≤ cap: overlayRows.Count ≤ budget − inputRows.Count − preambleCount, + // so overlayRows.Count + preambleCount + inputRows.Count ≤ budget = cap − statusCount, + // thus total = overlayRows.Count + preambleCount + inputRows.Count + statusCount ≤ cap. + int aboveCount = (overlay?.Placement == OverlayPlacement.AboveInput ? overlayRows.Count : 0) + + preambleCount; - List fixedRows = new(overlayRows.Count + inputRows.Count + statusCount); + List fixedRows = new(overlayRows.Count + preambleCount + inputRows.Count + statusCount); if (overlay?.Placement == OverlayPlacement.AboveInput) fixedRows.AddRange(overlayRows); + fixedRows.AddRange(preambleRows); fixedRows.AddRange(inputRows); if (overlay?.Placement == OverlayPlacement.BelowInput) fixedRows.AddRange(overlayRows); @@ -186,10 +205,10 @@ internal void Compose(RenderModel model) overlay.CaretInOverlay is { } overlayCaret) { // The overlay owns the cursor (e.g. InputDialog). overlayStartRow is 0 for - // AboveInput overlays and inputRows.Count for BelowInput overlays. + // AboveInput overlays and aboveCount + inputRows.Count for BelowInput overlays. int overlayStartRow = overlay.Placement == OverlayPlacement.AboveInput ? 0 - : inputRows.Count; + : aboveCount + inputRows.Count; model.EditorCaretLocal = (overlayStartRow + overlayCaret.Row, overlayCaret.Col); model.IsCursorVisible = true; } diff --git a/src/Dcli/Internal/FixedRegion/PreambleLine.cs b/src/Dcli/Internal/FixedRegion/PreambleLine.cs new file mode 100644 index 0000000..af6bab3 --- /dev/null +++ b/src/Dcli/Internal/FixedRegion/PreambleLine.cs @@ -0,0 +1,23 @@ +namespace Dcli.Internal.FixedRegion; + +/// +/// Owns the preamble row(s) rendered directly above the input editor. +/// +/// +/// +/// A consumer sets to replace the displayed content. Setting to an empty list +/// means no preamble rows are rendered. All rows are painted verbatim — no wrapping is applied. +/// +/// +/// Unlike the status bar, the preamble is not sacred: when the height budget is tight, preamble +/// rows are truncated before the editor loses its last visible row. +/// +/// +internal sealed class PreambleLine +{ + /// + /// The preamble rows to display directly above the input editor. May be empty (no preamble rendered). + /// Set by the consumer; read by during composition. + /// + internal IReadOnlyList Rows { get; set; } = []; +} diff --git a/src/Dcli/Internal/RenderLoop/RenderModel.cs b/src/Dcli/Internal/RenderLoop/RenderModel.cs index 767b2fd..1b2871a 100644 --- a/src/Dcli/Internal/RenderLoop/RenderModel.cs +++ b/src/Dcli/Internal/RenderLoop/RenderModel.cs @@ -125,7 +125,7 @@ internal RenderModel(ITerminalSizeSource sizeSource, int? maxFixedHeight = null) /// The fixed-region composer (§10). Populates and /// each paint cycle, before scrollback PrePaint. /// - internal FixedRegionComposer FixedRegion { get; } = new(new TextBuffer(), new StatusLine()); + internal FixedRegionComposer FixedRegion { get; } = new(new TextBuffer(), new PreambleLine(), new StatusLine()); /// /// The editor-relative caret position set by : diff --git a/tests/Dcli.Tests/FixedRegionTests.cs b/tests/Dcli.Tests/FixedRegionTests.cs index ac1f4af..b742494 100644 --- a/tests/Dcli.Tests/FixedRegionTests.cs +++ b/tests/Dcli.Tests/FixedRegionTests.cs @@ -96,7 +96,7 @@ public void StatusAlwaysShown() StatusLine status = new() { Rows = [PlainLine("status-A"), PlainLine("status-B")] }; TextBuffer editor = new(); editor.Insert("hi"); - FixedRegionComposer composer = new(editor, status); + FixedRegionComposer composer = new(editor, new PreambleLine(), status); composer.Compose(model); @@ -114,7 +114,7 @@ public void FixedRegionConsumsOnlyNeededRowsWhenUnderCap() StatusLine status = new() { Rows = [PlainLine("status")] }; TextBuffer editor = new(); editor.Insert("A"); - FixedRegionComposer composer = new(editor, status); + FixedRegionComposer composer = new(editor, new PreambleLine(), status); composer.Compose(model); @@ -134,7 +134,7 @@ public void InputScrollsInternallyWhenExceedsCap() TextBuffer editor = new(); // Insert 8 logical lines (9 visual rows with width=80). editor.Insert("line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8"); - FixedRegionComposer composer = new(editor, status); + FixedRegionComposer composer = new(editor, new PreambleLine(), status); composer.Compose(model); @@ -160,7 +160,7 @@ public void FixedRegionComposedBeforeScrollbackSoLiveWindowIsCorrect() StatusLine status = new() { Rows = [PlainLine("st")] }; TextBuffer editor = new(); editor.Insert("hello"); - FixedRegionComposer composer = new(editor, status); + FixedRegionComposer composer = new(editor, new PreambleLine(), status); // Append some scrollback content. ScrollbackModel scrollback = model.Scrollback; @@ -370,7 +370,7 @@ public void DegenerateStatusAloneGeCapCursorHidden() }; TextBuffer editor = new(); editor.Insert("input"); - FixedRegionComposer composer = new(editor, status); + FixedRegionComposer composer = new(editor, new PreambleLine(), status); composer.Compose(model); @@ -390,7 +390,7 @@ public void DegenerateTransitionResetsEditorCaretLocal() StatusLine statusNormal = new() { Rows = [PlainLine("status")] }; TextBuffer editor = new(); editor.Insert("hello"); - FixedRegionComposer composerNormal = new(editor, statusNormal); + FixedRegionComposer composerNormal = new(editor, new PreambleLine(), statusNormal); composerNormal.Compose(model); // Sanity: after a normal compose EditorCaretLocal must be non-null. @@ -403,7 +403,7 @@ public void DegenerateTransitionResetsEditorCaretLocal() { Rows = Enumerable.Range(1, 5).Select(i => PlainLine($"s{i}")).ToList() }; - FixedRegionComposer composerDegenerate = new(editor, statusDegenerate); + FixedRegionComposer composerDegenerate = new(editor, new PreambleLine(), statusDegenerate); composerDegenerate.Compose(model); // All three caret-related fields must be null/false — no contradiction. diff --git a/tests/Dcli.Tests/OverlayRoutingTests.cs b/tests/Dcli.Tests/OverlayRoutingTests.cs index 41cdb2e..babb89d 100644 --- a/tests/Dcli.Tests/OverlayRoutingTests.cs +++ b/tests/Dcli.Tests/OverlayRoutingTests.cs @@ -346,7 +346,7 @@ public void AutocompleteBelowInputCursorVisibleAndRowOrder() StatusLine status = new() { Rows = [PlainLine("status")] }; TextBuffer editor = new(); editor.Insert("hi"); - FixedRegionComposer composer = new(editor, status); + FixedRegionComposer composer = new(editor, new PreambleLine(), status); Autocomplete ac = new(editor); ac.Show([new AutocompleteCandidate("hello", PlainLine("hello")), new AutocompleteCandidate("hi", PlainLine("hi"))]); @@ -384,7 +384,7 @@ public void ModalDialogAboveInputCursorHiddenAndRowOrder() StatusLine status = new() { Rows = [PlainLine("status")] }; TextBuffer editor = new(); editor.Insert("hi"); - FixedRegionComposer composer = new(editor, status); + FixedRegionComposer composer = new(editor, new PreambleLine(), status); Dialog dialog = new(modal: true); dialog.List.SetItems([PlainLine("option A"), PlainLine("option B")]); @@ -416,7 +416,7 @@ public void BudgetSqueezeTotalNeverExceedsCap() StatusLine status = new() { Rows = [PlainLine("s1"), PlainLine("s2")] }; TextBuffer editor = new(); editor.Insert("line1\nline2\nline3\nline4\nline5\nline6"); - FixedRegionComposer composer = new(editor, status); + FixedRegionComposer composer = new(editor, new PreambleLine(), status); Autocomplete ac = new(editor); ac.Show(Enumerable.Range(1, 20).Select(i => new AutocompleteCandidate($"item{i}", PlainLine($"item{i}"))).ToList()); @@ -444,7 +444,7 @@ public void BudgetSqueezeOverlayShrinksNotStatus() StatusLine status = new() { Rows = [PlainLine("s1"), PlainLine("s2")] }; TextBuffer editor = new(); editor.Insert("line1\nline2\nline3\nline4\nline5\nline6"); - FixedRegionComposer composer = new(editor, status); + FixedRegionComposer composer = new(editor, new PreambleLine(), status); Autocomplete ac = new(editor); ac.Show([new AutocompleteCandidate("suggestion", PlainLine("suggestion"))]); @@ -470,7 +470,7 @@ public void NoOverlayNormalComposeBehaviourUnchanged() StatusLine status = new() { Rows = [PlainLine("status")] }; TextBuffer editor = new(); editor.Insert("hello"); - FixedRegionComposer composer = new(editor, status); + FixedRegionComposer composer = new(editor, new PreambleLine(), status); composer.Compose(model); @@ -549,7 +549,7 @@ public void OverlayContributesNoRowsWhenBudgetFullyConsumed() StatusLine status = new() { Rows = [PlainLine("s1"), PlainLine("s2")] }; TextBuffer editor = new(); editor.Insert("line1\nline2\nline3\nline4\nline5\nline6"); - FixedRegionComposer composer = new(editor, status); + FixedRegionComposer composer = new(editor, new PreambleLine(), status); Autocomplete ac = new(editor); ac.Show(Enumerable.Range(1, 5).Select(i => From 7aa40993a0dbfe8c8acf3c8034b2c984371c1bc7 Mon Sep 17 00:00:00 2001 From: Rendle Date: Fri, 19 Jun 2026 00:12:33 +0100 Subject: [PATCH 6/9] test(persistent-input-preamble): integration tests for InputPreamble surface (section 3) - 3.1 Preamble rows render directly above input editor on next frame - 3.2 Preamble persists across multiple input submissions without re-set - 3.3 Empty SetRows() clears preamble and returns rows to budget - 3.4 Budget pressure: preamble truncates, input keeps >=1 row, status stays sacred - 3.5 Keys reach the input editor with preamble set; caret parks past preamble row - 3.6 Existing FixedRegionTests pass as regression guard (872 total) Co-Authored-By: Claude Sonnet 4.6 --- .../persistent-input-preamble/tasks.md | 12 +- tests/Dcli.Tests/InputPreambleTests.cs | 210 ++++++++++++++++++ 2 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 tests/Dcli.Tests/InputPreambleTests.cs diff --git a/openspec/changes/persistent-input-preamble/tasks.md b/openspec/changes/persistent-input-preamble/tasks.md index 6e2b07f..93ec9ae 100644 --- a/openspec/changes/persistent-input-preamble/tasks.md +++ b/openspec/changes/persistent-input-preamble/tasks.md @@ -13,12 +13,12 @@ ## 3. Tests (tests/Dcli.Tests, via HeadlessTerminal) -- [ ] 3.1 Preamble rows render directly above the input editor on the next frame -- [ ] 3.2 Preamble persists across multiple input submissions without being re-set -- [ ] 3.3 `SetRows` with an empty argument clears the preamble and returns its rows to the budget -- [ ] 3.4 Under a constrained `MaxFixedHeight`, the preamble truncates while the input editor keeps ≥1 row and the status rows stay fully rendered -- [ ] 3.5 Keys routed with a preamble set (no overlay) all reach the input editor; the hardware cursor parks at the input caret -- [ ] 3.6 Existing fixed-region/status tests stay green (regression guard) +- [x] 3.1 Preamble rows render directly above the input editor on the next frame +- [x] 3.2 Preamble persists across multiple input submissions without being re-set +- [x] 3.3 `SetRows` with an empty argument clears the preamble and returns its rows to the budget +- [x] 3.4 Under a constrained `MaxFixedHeight`, the preamble truncates while the input editor keeps ≥1 row and the status rows stay fully rendered +- [x] 3.5 Keys routed with a preamble set (no overlay) all reach the input editor; the hardware cursor parks at the input caret +- [x] 3.6 Existing fixed-region/status tests stay green (regression guard) ## 4. Sample / demo diff --git a/tests/Dcli.Tests/InputPreambleTests.cs b/tests/Dcli.Tests/InputPreambleTests.cs new file mode 100644 index 0000000..f79e73f --- /dev/null +++ b/tests/Dcli.Tests/InputPreambleTests.cs @@ -0,0 +1,210 @@ +using Dcli.Testing; +using Xunit; + +namespace Dcli.Tests; + +/// +/// Tests for the persistent input-preamble surface (tasks 3.1–3.6). +/// +public sealed class InputPreambleTests +{ + // ── Helpers ── + + private static readonly string[] _fourPreambleLabels = ["pre-1", "pre-2", "pre-3", "pre-4"]; + + private static Line L(string text) => new([new Segment(text)]); + + private static int FindRow(IReadOnlyList rows, string text) => + rows.Select((r, i) => (r, i)) + .Where(x => x.r.Segments.Any(s => s.Text.Contains(text, StringComparison.Ordinal))) + .Select(x => x.i) + .FirstOrDefault(-1); + + // ── 3.1 — Preamble rows render directly above the input editor ──────────── + + [Fact] + public async Task PreambleRowsRenderAboveInputEditor() + { + await using HeadlessTerminal harness = await HeadlessTerminal.StartAsync( + new HeadlessTerminalOptions { InitialColumns = 80, InitialRows = 24 }); + + harness.Terminal.InputPreamble.SetRows(L("preamble-row-1"), L("preamble-row-2")); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + FrameSnapshot snap = harness.Snapshot; + IReadOnlyList fixed_ = snap.FixedRegionRows; + + int idx1 = FindRow(fixed_, "preamble-row-1"); + int idx2 = FindRow(fixed_, "preamble-row-2"); + + Assert.True(idx1 >= 0, "preamble-row-1 not found in FixedRegionRows"); + Assert.True(idx2 >= 0, "preamble-row-2 not found in FixedRegionRows"); + + // Order: row-1 before row-2. + Assert.True(idx1 < idx2, "preamble-row-1 should precede preamble-row-2"); + + // Preamble rows at indices 0 and 1; something follows (the input editor row). + Assert.Equal(0, idx1); + Assert.Equal(1, idx2); + Assert.True(idx2 < fixed_.Count - 1, "Something (input editor) should follow the last preamble row"); + } + + // ── 3.2 — Preamble persists across multiple input submissions ───────────── + + [Fact] + public async Task PreamblePersistsAcrossInputSubmissions() + { + await using HeadlessTerminal harness = await HeadlessTerminal.StartAsync( + new HeadlessTerminalOptions { InitialColumns = 80, InitialRows = 24 }); + + harness.Terminal.InputPreamble.SetRows(L("persistent-header")); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.True( + FindRow(harness.Snapshot.FixedRegionRows, "persistent-header") >= 0, + "persistent-header missing before first submission"); + + // First submission. + harness.Type("first"); + harness.SendKey(new KeyEvent(KeyCode.Named(NamedKey.Enter), Modifiers.None)); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.True( + FindRow(harness.Snapshot.FixedRegionRows, "persistent-header") >= 0, + "persistent-header missing after first submission"); + + // Second submission. + harness.Type("second"); + harness.SendKey(new KeyEvent(KeyCode.Named(NamedKey.Enter), Modifiers.None)); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.True( + FindRow(harness.Snapshot.FixedRegionRows, "persistent-header") >= 0, + "persistent-header missing after second submission"); + + // Third submission. + harness.Type("third"); + harness.SendKey(new KeyEvent(KeyCode.Named(NamedKey.Enter), Modifiers.None)); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + Assert.True( + FindRow(harness.Snapshot.FixedRegionRows, "persistent-header") >= 0, + "persistent-header missing after third submission"); + } + + // ── 3.3 — SetRows with empty argument clears the preamble ──────────────── + + [Fact] + public async Task SetRowsEmptyClearsPreamble() + { + await using HeadlessTerminal harness = await HeadlessTerminal.StartAsync( + new HeadlessTerminalOptions { InitialColumns = 80, InitialRows = 24 }); + + harness.Terminal.InputPreamble.SetRows(L("clear-test-row-1"), L("clear-test-row-2")); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + FrameSnapshot withPreamble = harness.Snapshot; + Assert.True(FindRow(withPreamble.FixedRegionRows, "clear-test-row-1") >= 0, "clear-test-row-1 missing"); + Assert.True(FindRow(withPreamble.FixedRegionRows, "clear-test-row-2") >= 0, "clear-test-row-2 missing"); + int countWithPreamble = withPreamble.FixedRegionRows.Count; + + // Clear preamble. + harness.Terminal.InputPreamble.SetRows(); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + FrameSnapshot withoutPreamble = harness.Snapshot; + Assert.True(FindRow(withoutPreamble.FixedRegionRows, "clear-test-row-1") < 0, "clear-test-row-1 still present after clear"); + Assert.True(FindRow(withoutPreamble.FixedRegionRows, "clear-test-row-2") < 0, "clear-test-row-2 still present after clear"); + Assert.True(withoutPreamble.FixedRegionRows.Count < countWithPreamble, "Budget not returned after clearing preamble"); + } + + // ── 3.4 — Preamble truncates under budget pressure; input ≥1 row; status sacred ── + + [Fact] + public async Task PreambleTruncatesUnderBudgetPressure() + { + // On a 24-row terminal, cap = clamp(appSet ?? rows/2, 8, rows). + // With MaxFixedHeight=null (default), cap = 12. + // Use 10 sacred status rows → budget = cap - 10 = 2. + // Input claims 1 row (empty editor), preamble gets preambleBudget = 2 - 1 = 1 from 4 requested. + // Total fixed = 10 status + 1 input + 1 preamble = 12 ≤ cap. + await using HeadlessTerminal harness = await HeadlessTerminal.StartAsync( + new HeadlessTerminalOptions { InitialColumns = 80, InitialRows = 24 }); + + harness.Terminal.Status.SetRows( + L("status-1"), L("status-2"), L("status-3"), L("status-4"), L("status-5"), + L("status-6"), L("status-7"), L("status-8"), L("status-9"), L("status-10")); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + harness.Terminal.InputPreamble.SetRows(L("pre-1"), L("pre-2"), L("pre-3"), L("pre-4")); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + FrameSnapshot snap = harness.Snapshot; + IReadOnlyList fixed_ = snap.FixedRegionRows; + + // Budget cap respected (effective cap = 12 on 24-row terminal with default MaxFixedHeight). + Assert.True(fixed_.Count <= 12, + $"FixedRegionRows.Count={fixed_.Count} exceeds effective cap of 12"); + + // Status is sacred — all 10 rows always present. + for (int i = 1; i <= 10; i++) + { + Assert.True(FindRow(fixed_, $"status-{i}") >= 0, + $"status-{i} not found (status must be sacred)"); + } + + // Input editor kept ≥1 row (cursor visible). + Assert.True(snap.IsCursorVisible, "Cursor hidden — input editor must keep ≥1 row"); + + // Not all 4 preamble rows visible (truncation occurred). + int preambleVisible = _fourPreambleLabels + .Count(p => FindRow(fixed_, p) >= 0); + Assert.True(preambleVisible < 4, + $"Expected preamble truncation but all 4 rows are visible (count={preambleVisible})"); + } + + // ── 3.5 — Keys reach the input editor; hardware cursor at input caret ───── + + [Fact] + public async Task KeysReachInputEditorCaretBelowPreamble() + { + await using HeadlessTerminal harness = await HeadlessTerminal.StartAsync( + new HeadlessTerminalOptions { InitialColumns = 80, InitialRows = 24 }); + + harness.Terminal.InputPreamble.SetRows(L("cursor-test-header")); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + harness.SendKey(new KeyEvent(KeyCode.FromRune(new System.Text.Rune('X')), Modifiers.None)); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + FrameSnapshot snap = harness.Snapshot; + IReadOnlyList fixed_ = snap.FixedRegionRows; + + // 'X' must appear in the fixed region (the input editor row). + Assert.True(FindRow(fixed_, "X") >= 0, "Key 'X' did not reach the input editor"); + + // Hardware cursor visible. + Assert.True(snap.IsCursorVisible, "Cursor should be visible when input is active"); + + // Caret is not null. + Assert.NotNull(snap.Caret); + + // Caret is in the input editor band, below the 1-row preamble. + // caretRowInFixed = snap.Caret.Value.Row - snap.LiveWindowRows.Count + int caretRowInFixed = snap.Caret!.Value.Row - snap.LiveWindowRows.Count; + Assert.True(caretRowInFixed >= 1, + $"Caret row in fixed region (={caretRowInFixed}) should be ≥ 1 (past the preamble row at index 0)"); + } + + // ── 3.6 — Regression guard: existing FixedRegionTests stay green ────────── + + [Fact] + public void ExistingFixedRegionTestsRegression() + { + // Satisfied by dotnet test running FixedRegionTests without failure. + // Constructor arity updates for PreambleLine were applied in Section 2. + } +} From e066722c0d773b621997371e8692420f7602dcc2 Mon Sep 17 00:00:00 2001 From: Rendle Date: Fri, 19 Jun 2026 00:16:48 +0100 Subject: [PATCH 7/9] feat(persistent-input-preamble): demo sample exercises InputPreamble surface (section 4) - 4.1 Set a persistent dim-rule preamble in Phase 1 of Dcli.Demo; clear it in Phase 6 Co-Authored-By: Claude Sonnet 4.6 --- openspec/changes/persistent-input-preamble/tasks.md | 2 +- samples/Dcli.Demo/Program.cs | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/openspec/changes/persistent-input-preamble/tasks.md b/openspec/changes/persistent-input-preamble/tasks.md index 93ec9ae..b721b3e 100644 --- a/openspec/changes/persistent-input-preamble/tasks.md +++ b/openspec/changes/persistent-input-preamble/tasks.md @@ -22,7 +22,7 @@ ## 4. Sample / demo -- [ ] 4.1 Update a sample (or the demo) to set a persistent preamble (e.g. a labelled rule above the input) so the surface is exercised end-to-end +- [x] 4.1 Update a sample (or the demo) to set a persistent preamble (e.g. a labelled rule above the input) so the surface is exercised end-to-end ## 5. Validation & packaging diff --git a/samples/Dcli.Demo/Program.cs b/samples/Dcli.Demo/Program.cs index 1842b5a..cdb3e4c 100644 --- a/samples/Dcli.Demo/Program.cs +++ b/samples/Dcli.Demo/Program.cs @@ -32,6 +32,9 @@ t.Scrollback.Append(Line.Dim(" Content above the commit horizon is frozen and terminal-owned.")); +t.InputPreamble.SetRows(Line.Dim("── dcli demo ─────────────────────────────────────────────────────────────")); +t.Scrollback.Append(Line.Dim(" InputPreamble set -- a rule above the input editor.")); + await Task.Delay(TimeSpan.FromMilliseconds(800)); // ── Phase 2: Streaming live block (~3s) ────────────────────────────────────── @@ -237,6 +240,9 @@ t.Status.SetRows(Line.Fg("DONE - Tour complete - press Ctrl+C to exit, or wait 3s.", Color.Named(Color.AnsiColor.Green))); +t.InputPreamble.SetRows(); +t.Scrollback.Append(Line.Dim(" InputPreamble cleared.")); + t.Scrollback.Append(Line.Bold("--- Tour complete ---")); t.Scrollback.Append(Line.Fg("All dcli public surfaces exercised successfully.", Color.Named(Color.AnsiColor.BrightGreen))); From b2f82e12439e0922d8cb93c564e0f4b37c50d3d0 Mon Sep 17 00:00:00 2001 From: Rendle Date: Fri, 19 Jun 2026 00:25:38 +0100 Subject: [PATCH 8/9] feat(persistent-input-preamble): validation & packaging (section 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 5.1 dotnet build clean (0 warnings, 0 errors) - 5.2 dotnet test 872/872 green - 5.3 dotnet format --verify-no-changes clean - 5.4 openspec validate persistent-input-preamble --strict valid - 5.5 version bump 0.2.0-rc.4 → 0.2.0-rc.5 in Dcli and Dcli.Testing csproj - 5.6 CHANGELOG: added IInputPreamble / ITerminal.InputPreamble entry - 5.7 DEVLOG: updated status and appended section 5 notes - 5.8 dmon coordination note in DEVLOG Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 4 ++++ .../changes/persistent-input-preamble/DEVLOG.md | 17 ++++++++++++++++- .../changes/persistent-input-preamble/tasks.md | 16 ++++++++-------- src/Dcli.Testing/Dcli.Testing.csproj | 2 +- src/Dcli/Dcli.csproj | 2 +- 5 files changed, 30 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dc8995..f4521f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **`IInputPreamble` / `ITerminal.InputPreamble`** — a persistent multi-row band rendered immediately above the input editor. Set via `terminal.InputPreamble.SetRows(...)` using the same `Line`/`LineBuilder` API as status. Persists across input submissions; clear with `SetRows()` (no args). Truncates under a constrained `MaxFixedHeight` before the input editor loses rows; status rows remain sacred. No overlay/intercept — purely presentational. + ### Security - **VT-escape injection gap closed.** Consumer text passed to `Segment`, `Line.FromText`, or diff --git a/openspec/changes/persistent-input-preamble/DEVLOG.md b/openspec/changes/persistent-input-preamble/DEVLOG.md index 3e6a928..4afe3db 100644 --- a/openspec/changes/persistent-input-preamble/DEVLOG.md +++ b/openspec/changes/persistent-input-preamble/DEVLOG.md @@ -2,7 +2,7 @@ ## Status -Sections 1 + 2 in progress (worker running). +All sections complete. Section 5 gates all pass (build clean, 872 tests green, format clean, openspec valid). Awaiting commit and archive. ## Pre-flight notes @@ -35,3 +35,18 @@ compile independently, so the worker produces both sections, then two separate c - `InputPreambleSurface` in `src/Dcli/InputPreambleSurface.cs`. - `ITerminal.InputPreamble : IInputPreamble` property added after `Status`. - `Terminal` ctor: `InputPreamble = new InputPreambleSurface(loop)` after `Status = ...`. + +## Section 5 — Validation & packaging + +Gates passed by orchestrator before worker brief: +- `dotnet build` clean (0 warnings, 0 errors) +- `dotnet test` 872/872 green +- `dotnet format --verify-no-changes` clean +- `openspec validate persistent-input-preamble --strict` valid + +Version bumped: `0.2.0-rc.4` → `0.2.0-rc.5` in `src/Dcli/Dcli.csproj` and `src/Dcli.Testing/Dcli.Testing.csproj`. +CHANGELOG updated with `IInputPreamble` surface entry. + +### dmon coordination + +dmon's Terminal UX change consumes `ITerminal.InputPreamble` and must reference dcli `0.2.0-rc.5` (not rc.4). Update the NuGet reference in dmon after this change is published. diff --git a/openspec/changes/persistent-input-preamble/tasks.md b/openspec/changes/persistent-input-preamble/tasks.md index b721b3e..761e713 100644 --- a/openspec/changes/persistent-input-preamble/tasks.md +++ b/openspec/changes/persistent-input-preamble/tasks.md @@ -26,11 +26,11 @@ ## 5. Validation & packaging -- [ ] 5.1 `dotnet build` clean (analyzers warnings-as-errors; nullable enabled) -- [ ] 5.2 `dotnet test` all green -- [ ] 5.3 `dotnet format --verify-no-changes` clean -- [ ] 5.4 `openspec validate persistent-input-preamble --strict` passes -- [ ] 5.5 Version bump: `src/Dcli/Dcli.csproj` and `src/Dcli.Testing/Dcli.Testing.csproj` `0.2.0-rc.4` → `0.2.0-rc.5` -- [ ] 5.6 Update `CHANGELOG.md` with the new surface -- [ ] 5.7 Keep a `DEVLOG.md` in the change directory while applying -- [ ] 5.8 dmon coordination: note that dmon's Terminal UX change consumes `ITerminal.InputPreamble` and must reference dcli `0.2.0-rc.5` +- [x] 5.1 `dotnet build` clean (analyzers warnings-as-errors; nullable enabled) +- [x] 5.2 `dotnet test` all green +- [x] 5.3 `dotnet format --verify-no-changes` clean +- [x] 5.4 `openspec validate persistent-input-preamble --strict` passes +- [x] 5.5 Version bump: `src/Dcli/Dcli.csproj` and `src/Dcli.Testing/Dcli.Testing.csproj` `0.2.0-rc.4` → `0.2.0-rc.5` +- [x] 5.6 Update `CHANGELOG.md` with the new surface +- [x] 5.7 Keep a `DEVLOG.md` in the change directory while applying +- [x] 5.8 dmon coordination: note that dmon's Terminal UX change consumes `ITerminal.InputPreamble` and must reference dcli `0.2.0-rc.5` diff --git a/src/Dcli.Testing/Dcli.Testing.csproj b/src/Dcli.Testing/Dcli.Testing.csproj index 819dd55..1e1008c 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.4 + 0.2.0-rc.5 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 d92831b..e425f69 100644 --- a/src/Dcli/Dcli.csproj +++ b/src/Dcli/Dcli.csproj @@ -9,7 +9,7 @@ true dcli - 0.2.0-rc.4 + 0.2.0-rc.5 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 91c458c422257801969b66930aa1035bb325c2eb Mon Sep 17 00:00:00 2001 From: Rendle Date: Fri, 19 Jun 2026 00:51:43 +0100 Subject: [PATCH 9/9] chore(persistent-input-preamble): sync delta spec and archive change Merged preamble requirement additions into openspec/specs/fixed-region/spec.md: - Modified bottom-pinned stack requirement to include persistent input preamble - Added new Requirement: Persistent input preamble (3 prose clauses, 5 BDD scenarios) Archived to openspec/changes/archive/2026-06-19-persistent-input-preamble/ Co-Authored-By: Claude Sonnet 4.6 --- .../.openspec.yaml | 0 .../DEVLOG.md | 0 .../design.md | 0 .../proposal.md | 0 .../specs/fixed-region/spec.md | 0 .../tasks.md | 0 openspec/specs/fixed-region/spec.md | 33 ++++++++++++++++++- 7 files changed, 32 insertions(+), 1 deletion(-) rename openspec/changes/{persistent-input-preamble => archive/2026-06-19-persistent-input-preamble}/.openspec.yaml (100%) rename openspec/changes/{persistent-input-preamble => archive/2026-06-19-persistent-input-preamble}/DEVLOG.md (100%) rename openspec/changes/{persistent-input-preamble => archive/2026-06-19-persistent-input-preamble}/design.md (100%) rename openspec/changes/{persistent-input-preamble => archive/2026-06-19-persistent-input-preamble}/proposal.md (100%) rename openspec/changes/{persistent-input-preamble => archive/2026-06-19-persistent-input-preamble}/specs/fixed-region/spec.md (100%) rename openspec/changes/{persistent-input-preamble => archive/2026-06-19-persistent-input-preamble}/tasks.md (100%) diff --git a/openspec/changes/persistent-input-preamble/.openspec.yaml b/openspec/changes/archive/2026-06-19-persistent-input-preamble/.openspec.yaml similarity index 100% rename from openspec/changes/persistent-input-preamble/.openspec.yaml rename to openspec/changes/archive/2026-06-19-persistent-input-preamble/.openspec.yaml diff --git a/openspec/changes/persistent-input-preamble/DEVLOG.md b/openspec/changes/archive/2026-06-19-persistent-input-preamble/DEVLOG.md similarity index 100% rename from openspec/changes/persistent-input-preamble/DEVLOG.md rename to openspec/changes/archive/2026-06-19-persistent-input-preamble/DEVLOG.md diff --git a/openspec/changes/persistent-input-preamble/design.md b/openspec/changes/archive/2026-06-19-persistent-input-preamble/design.md similarity index 100% rename from openspec/changes/persistent-input-preamble/design.md rename to openspec/changes/archive/2026-06-19-persistent-input-preamble/design.md diff --git a/openspec/changes/persistent-input-preamble/proposal.md b/openspec/changes/archive/2026-06-19-persistent-input-preamble/proposal.md similarity index 100% rename from openspec/changes/persistent-input-preamble/proposal.md rename to openspec/changes/archive/2026-06-19-persistent-input-preamble/proposal.md diff --git a/openspec/changes/persistent-input-preamble/specs/fixed-region/spec.md b/openspec/changes/archive/2026-06-19-persistent-input-preamble/specs/fixed-region/spec.md similarity index 100% rename from openspec/changes/persistent-input-preamble/specs/fixed-region/spec.md rename to openspec/changes/archive/2026-06-19-persistent-input-preamble/specs/fixed-region/spec.md diff --git a/openspec/changes/persistent-input-preamble/tasks.md b/openspec/changes/archive/2026-06-19-persistent-input-preamble/tasks.md similarity index 100% rename from openspec/changes/persistent-input-preamble/tasks.md rename to openspec/changes/archive/2026-06-19-persistent-input-preamble/tasks.md diff --git a/openspec/specs/fixed-region/spec.md b/openspec/specs/fixed-region/spec.md index 642f48b..a628b0d 100644 --- a/openspec/specs/fixed-region/spec.md +++ b/openspec/specs/fixed-region/spec.md @@ -3,12 +3,43 @@ The `fixed-region` capability defines the pinned bottom component stack: an owned input editor, two mutually-exclusive overlays (a Dialog slot above the input, Autocomplete below), the reusable scrollable selection list, status lines, height budgeting, and intercept-chain key routing. ## Requirements ### Requirement: Bottom-pinned component stack -The fixed region SHALL be a contiguous, bottom-pinned stack of components — input, status, and overlays — with the live window rendered above it. +The fixed region SHALL be a contiguous, bottom-pinned stack of components — a persistent input preamble, the input editor, status, and overlays — with the live window rendered above it. When set, the persistent input preamble SHALL render directly above the input editor band; the status band SHALL remain the bottommost, sacred band. #### Scenario: Stays pinned while content streams - **WHEN** scrollback content streams into the live window - **THEN** the fixed region remains pinned at the bottom and is redrawn in place +#### Scenario: Preamble sits directly above the input editor +- **WHEN** a persistent input preamble is set and the base input editor is active +- **THEN** the preamble rows render immediately above the input editor band and below the live window + +### Requirement: Persistent input preamble +The library SHALL expose a persistent input-preamble surface — `ITerminal.InputPreamble` — through which the consumer sets a sequence of styled `Line` rows pinned directly above the base input editor. The surface SHALL mirror the status surface: it SHALL provide `SetRows(params Line[])` and `SetRows(IReadOnlyList)` overloads that replace the preamble content, and an empty argument SHALL clear it. Updates SHALL be applied on the render-loop thread and SHALL persist across renders and successive input submissions until changed or cleared. + +When the preamble is `null` or empty, no preamble row SHALL be painted and the freed rows SHALL return to the fixed-region budget. The preamble SHALL participate in the fixed-region height budget: under budget pressure the preamble SHALL truncate before the input editor loses its last usable row, and the status band SHALL remain sacred (never squeezed). + +The preamble SHALL be presentational only — it SHALL NOT participate in the intercept chain and SHALL NOT consume keys; all keys SHALL reach the active overlay or the input editor as before, and the hardware cursor SHALL continue to park at the input caret. + +#### Scenario: Preamble renders above the input editor +- **WHEN** the consumer sets a two-line preamble via `ITerminal.InputPreamble.SetRows(...)` +- **THEN** both rows paint top-to-bottom immediately above the input editor on the next frame + +#### Scenario: Preamble persists across input submissions +- **WHEN** a preamble is set once and the user submits several inputs in succession +- **THEN** the preamble remains rendered above the editor across each turn without being re-set + +#### Scenario: Empty preamble clears it +- **WHEN** `SetRows` is called with an empty argument list +- **THEN** no preamble rows are painted and the freed rows return to the fixed-region budget + +#### Scenario: Preamble truncates under budget pressure +- **WHEN** the preamble row count plus the input editor's minimum height plus the status rows exceeds the fixed-region cap +- **THEN** the preamble truncates first, the input editor retains at least one usable row, and the status rows remain fully rendered + +#### Scenario: Preamble does not intercept keys +- **WHEN** keys are routed while a preamble is set and no overlay is active +- **THEN** the preamble consumes no keys and every key reaches the input editor + ### Requirement: Owned input editor The library SHALL own an input editor supporting caret movement, multiline text, display-width-aware wrapping, history recall, paste insertion, and internal scrolling when its content exceeds its allotted height.