diff --git a/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/.openspec.yaml b/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/.openspec.yaml new file mode 100644 index 0000000..1194417 --- /dev/null +++ b/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-29 diff --git a/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/DEVLOG.md b/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/DEVLOG.md new file mode 100644 index 0000000..fa01ba2 --- /dev/null +++ b/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/DEVLOG.md @@ -0,0 +1,72 @@ +# DEVLOG — `api-ergonomics-pass-2` + +> **Status: shipped.** All 7 sections landed on `change/api-ergonomics-pass-2` (created from `main`). +> Final section commit `9f1cedb` (§7 validation & packaging); DEVLOG hash-backfill `719c587`. +> Released as `dcli` + `dcli.testing` `0.2.0-rc.3`. Pending `/opsx:archive` (awaiting user +> confirmation) — on archive this file moves with the change to +> `openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/DEVLOG.md`. PR link backfilled on merge. + +## How to resume + +> _Historical — frozen at archive-readiness._ + +- Final state at archive: branch **`change/api-ergonomics-pass-2`** (created from `main`), 9 commits ahead (proposal scaffold + DEVLOG scaffold + 7 section commits + 1 hash-backfill). Working tree CLEAN. +- Final sanity check (passed before freeze): `dotnet build -c Release && dotnet test -c Release && dotnet format --verify-no-changes && openspec validate api-ergonomics-pass-2 --strict` → 0 warnings, **856 tests green** (827 baseline + 29 new), format clean, validate clean. +- Memory files at the bottom encode the hard-won constraints that survive past this change (render-loop thread discipline; oversized-reprint ordering). + +## Section status + +One row per `## N.` section in `tasks.md`. Add a row when the section commits. + +| § | Section | Commit | Tests after | Notes | +|---|---------|--------|-------------|-------| +| 1 | Line single-style shorthand factories | `9292b4d` | 836 (827 + 9) | Bold/Dim/Fg/Bg as thin sanitizing wrappers over `FromText`. Reviewer clean first pass (all 5 dimensions); null-guard inherited from `FromText` (accepted — message names `text` correctly); the repeated "no Raw" doc paragraph is mandated by task 1.5, not noise. Tests added to `tests/Dcli.Tests/StyledTextTests.cs`. | +| 2 | Scrollback.AppendRule | `512374a` | 838 (836 + 2) | New `RuleBlock : ILineObject` resolves width at paint time (re-expands on resize for free); `AppendRule()` posts a private nested `AppendRuleToScrollbackCommand` mirroring `AppendToScrollbackCommand`; the `// AppendRule … deferred` gap comment removed. Surfaced: adding a member to `IScrollback` required a `FakeScrollback.AppendRule()` stub in `FakeTerminalTests.cs`. Reviewer round 1 should-fix: the test hand-rolled doubles instead of `HeadlessTerminal`/`FrameSnapshot` — rewritten onto the real harness (also fixed a latent size-source/resize-watcher desync by using `harness.Resize`). Round 2 clean. | +| 3 | Incremental Collapsible.AppendLine | `ad8c8a1` | 844 (838 + 6) | `Collapsible._hiddenLines` → owned `List` (`.ToList()` copy in ctor — also closes a latent off-thread-mutation leak); `AppendHidden` dumb-add; new `ScrollbackModel.AppendToCollapsible` with the SAME guard precedence as `ExpandCollapsible` (horizon-freeze → already-expanded → act); façade `AppendLine(Line)`/`(string)` via nested `AppendLineToCollapsibleFacadeCommand`. Tests are **model-level** (`ScrollbackModelTests` house style — HeadlessTerminal can't observe `IsExpanded`/`NewlyCommittedRows`), incl. a 3.7 oversized-reprint ordering regression. Reviewer round 1 should-fix: worker left an orphan `Commands/AppendToCollapsibleCommand.cs` (never instantiated) — deleted. Round 2 clean. Decision-4 holds: append touches only the pre-expansion snapshot and never initiates a reprint, so it can't worsen [[scrollback-oversized-reprint-ordering]]. | +| 4 | PasteEvent editor routing | `7476fb0` | 848 (844 + 4) | New `IOverlay.HandlePaste(string)→bool`; `InputDialog` inserts+flips `_userEdited`+consumes; `Dialog`→`Modal` (modal consumes/ignores, non-modal passes); `Autocomplete`→`false` (pass-through, cursor in base editor). `LoopEngine.ApplyInputEvent` gains a `PasteEvent` case mirroring the KeyEvent intercept chain; base-editor paste emits `InputChanged`; stale "paste not routed" comment removed. **Terminal-safety verified by reviewer:** pasted escape bytes are neutralized on the render path (`TextBuffer.Render`/`MaskLine` build via the sanitizing `Segment` ctor — `Segment.Raw` is NOT on the paste path). Reviewer note (pre-existing, not §4): `_userEdited` has no runtime reader — secret masking is unconditional on `_isSecret`, so "default→buffer masking" coincides (buffer==default before edit); security outcome holds. **No HITL needed** (no raw-mode/real-terminal behaviour changed; headless tests cover it). Round 1 clean. | +| 5 | Multi-select Back via '[' | `7928485` | 856 (848 + 8) | **The one contended edit** — REVERSES the shipped "MultiSelectRequest SHALL continue to omit AllowBack". `MultiSelectRequest` gains `AllowBack` (mirrors SelectRequest ctor pattern; `params` ctors omit it). One `[`-Back branch in `Dialog.HandleKey` (before type-to-filter, gated `_allowBack && _filterText.Length==0 && (List.MultiSelect \|\| !_hasMoved)`): multi-select fires at any time (Space-toggle doesn't set `_hasMoved`), select/choice only before movement (like Backspace). `Terminal.MultiSelectAsync` passes `allowBack: req.AllowBack`; `OpenModalAsync` already maps Back→`DialogOutcome.Back` (Back returns `default!`/null Value — consumer must null-check). Removed the obsolete `MultiSelectRequestDoesNotHaveAllowBackProperty` test (pinned the reversed contract). Reviewer: approve, 6 dims clean; folded in 2 of 3 nits (arrow-then-`[` regression guard + doc clause). Architectural note: the multi-vs-single suppression divergence is now load-bearing and lives in the `List.MultiSelect` predicate — the spot to revisit if a 4th dialog type appears. | +| 6 | Sample migration onto the new Line factories | `5d8a0aa` | 856 (unchanged — samples only) | `WizardRenderer.cs` 13 single-style `LineBuilder` sites → `Line.Bold/Dim/Fg` (0 `LineBuilder` left); `Program.cs` 22 migrated, 11 multi-segment chains correctly left (`.Bold().Text()`, `.Text().Bold()`, an `.Italic()` site with no factory). 6.3 optional `AppendRule`/`AppendLine` demo skipped (no natural fit — not forced). Equivalence is the §1-proven `Line.X(s) ≡ LineBuilder().X(s).Build()`. Reviewer: approve, all dims clean. | +| 7 | Validation & packaging | `9f1cedb` | 856 (unchanged) | Orchestrator-direct (no feature code → no worker/reviewer cycle). All four gates green in **Release**: build 0 warnings, 856 tests, format clean, validate `--strict` clean. `Version` bumped `0.2.0-rc.2 → 0.2.0-rc.3` in `Dcli.csproj` + `Dcli.Testing.csproj`. `dotnet pack -c Release --no-build` produced all four artifacts: `dcli.0.2.0-rc.3.{nupkg,snupkg}` (in `src/Dcli/bin/Release/`) and `dcli.testing.0.2.0-rc.3.{nupkg,snupkg}` (in `src/Dcli.Testing/bin/Release/`). | + +## Decisions & deviations + +Narrative log of anything that wasn't a straight read-off-the-spec-and-implement. One entry per decision, dated/section-scoped. + +- **Scope (2026-05-29, pre-flight).** All five candidate items triaged IN by the user: Line single-style factories (Bold/Dim/Fg/**Bg** — not full LineBuilder parity), Scrollback.AppendRule, incremental Collapsible.AppendLine, PasteEvent editor routing, and multi-select Back via `[`. Explicitly OUT: Italic/Underline/Reverse/Strikethrough factories, and `Line.Raw` (rejected — preserves the single-verbatim-seam invariant; see design Decision 2 and [[vt-escape-sanitization-gap]]). +- **§5 reverses a shipped spec sentence.** The live `fixed-region` spec said "MultiSelectRequest SHALL continue to omit AllowBack"; this change reverses it, binding `[` (not Backspace) to Back for multi-select to sidestep the Backspace-at-toggle ambiguity that caused two prior deferrals. This is the one genuinely contended edit — flagged for the reviewer. + +## Human-in-the-loop verifications + +Anything that can't be settled by automated gates. For each: section reference, exact copy-pasteable command, what the user should see, and current status. + +- **§4 — PasteEvent into the input editor — NOT NEEDED (resolved 2026-05-29).** Reviewer confirmed §4 changes no raw-mode/real-terminal behaviour; the intercept-chain routing, caret/wrap, secret-default flip, and the pasted-escape sanitization are all fully covered by headless tests. No real-terminal eyeball required. +- **§5 — `[`-as-Back keybinding — NOT NEEDED (resolved 2026-05-29).** Multi-select Back via `[` is fully covered headlessly (8 tests incl. survives-toggle and survives-arrow-move); reviewer approved with no real-terminal flag. The `[` binding is gated behind opt-in `AllowBack=true` and multi-select dialogs don't type-to-filter, so no key-collision risk. No eyeball required. + +*(Concrete commands/expected-output added when the relevant section lands and the need is confirmed.)* + +## Open follow-ups / known gaps (after this change lands — NOT in scope here) + +Surface gaps for future changes. Link to memory files where the constraint is encoded. + +- **`Italic`/`Underline`/`Reverse`/`Strikethrough` `Line` factories** — deferred (no call sites). A future pass can add them if consumers appear. +- **`Line.Raw`** — deliberately rejected (single-verbatim-seam invariant). Memory: [[vt-escape-sanitization-gap]]. +- **`Input.Prompt` / `Input.ReadOnly`** — still deferred from §12 of the architecture change. Memory: [[section14-api-ergonomics-findings]] entry 3-related. +- **`InputDialog` over-budget caret reporting** — flagged by `multi-line-dialog-prompts`; not addressed here. + +## Memory files (indexed by `~/.claude/projects/-Users-emmz-github-emmz-dcli/memory/MEMORY.md`) + +- [[ca2007-render-loop-thread-discipline]] — CA2007 suppressed repo-wide; loop-thread correctness for the new AppendRule / Collapsible.AppendLine / paste-routing commands must be checked by hand (§2, §3, §4). +- [[scrollback-oversized-reprint-ordering]] — known minor commit-order edge in the oversized-collapsible reprint path; §3 `AppendLine` must not worsen it (design Decision 4 constrains append to the pre-expansion snapshot). +- [[section14-api-ergonomics-findings]] — the original 5-finding candidate list; this change is the second pass against it. +- [[restore-on-signal-rendering-state]] — restore-on-signal protocol is load-bearing; §4 paste routing must add no new restore-path state. + +## Resume point + +> **Shipped 2026-05-29.** All 7 sections landed on `change/api-ergonomics-pass-2`. Section commits: +> §1 `9292b4d`, §2 `512374a`, §3 `ad8c8a1`, §4 `7476fb0`, §5 `7928485`, §6 `5d8a0aa`, §7 `9f1cedb` +> (+ DEVLOG hash-backfill `719c587`). Released as `dcli` + `dcli.testing` `0.2.0-rc.3` (all four +> nupkg/snupkg artifacts produced). 856 tests green (+29). No HITL was required (§4 and §5 are fully +> covered headlessly — reviewer-confirmed). Follow-ups (Italic/Underline/Reverse/Strikethrough `Line` +> factories; `Line.Raw`; `Input.Prompt`/`ReadOnly`; `InputDialog` over-budget caret reporting) live as +> separate future OpenSpec changes — see "Open follow-ups" above. Pending `/opsx:archive` (awaiting +> user confirmation); on archive this file moves to `openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/`. diff --git a/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/design.md b/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/design.md new file mode 100644 index 0000000..912f94a --- /dev/null +++ b/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/design.md @@ -0,0 +1,151 @@ +## Context + +`dcli` ships an inline terminal-rendering library. Two prior ergonomics changes — +`api-ergonomics-pass-1` (`0.2.0-rc.1`) and `multi-line-dialog-prompts` (`0.2.0-rc.2`) — closed the +first wave of §14.4 dmon-wizard-port friction. This change (`0.2.0-rc.3`) closes a second wave that +mixes two natures: + +1. **Pure conveniences** with concrete call-site evidence (the `Line` single-style factories). +2. **Spec/impl gaps the specs already name** but the code never satisfied: + - `inline-scrollback` "Scrollback command surface" already says the library SHALL expose an + "append a rule/separator" command, yet there is no `AppendRule` method, scenario, or + implementation (`ScrollbackSurface` carries an explicit `// AppendRule … deferred` comment). + - `ScrollbackSurface.BeginCollapsible` documents incremental hidden-line append as "a documented + gap for a future refinement". + - `PasteEvent` already exists in the `terminal-input` event model and parser, and the + `fixed-region` "Owned input editor" requirement already lists "paste" as a first-edit trigger — + but the editor never inserts paste text. + - `MultiSelectRequest` Back was deferred in both prior changes over the Backspace-at-toggle + ambiguity; the live `fixed-region` spec currently says "MultiSelectRequest SHALL continue to omit + AllowBack". + +Binding constraints carried from memory: +- **`ca2007-render-loop-thread-discipline`** — CA2007 is suppressed repo-wide; the render-loop thread + is the sole mutator of scrollback/editor state. New loop commands and the paste path must be + checked by hand for thread correctness, not just by the analyzer. +- **`scrollback-oversized-reprint-ordering`** — a known minor commit-order edge exists in the + oversized-collapsible reprint path; `AppendLine` must not worsen it. +- **`vt-escape-sanitization-gap` (resolved)** — `Segment.Raw` is the single verbatim escape hatch; + the `Line` shorthands must not open a second one. + +## Goals / Non-Goals + +**Goals:** +- Add `Line.Bold/Dim/Fg/Bg` single-style factories that are thin, sanitizing wrappers over + `Line.FromText`, and migrate the sample call sites onto them. +- Implement the `AppendRule` command the spec already mandates, with a width-aware rule line-object. +- Add incremental `Collapsible.AppendLine(Line)` / `(string)` with explicit one-way semantics. +- Route `PasteEvent` into the owned input editor, and make paste count as a first edit (flipping + secret-default masking). +- Give `MultiSelectRequest` an opt-in `AllowBack` bound to `[`, and accept `[` as a secondary Back + key on `Select`/`Choice`. +- Ship `0.2.0-rc.3` with all four gates green and no breaking changes. + +**Non-Goals:** +- `Italic`/`Underline`/`Reverse`/`Strikethrough` `Line` factories (no call sites) and `Line.Raw` + (rejected — preserves the single-escape-hatch invariant). +- Rich rule styling (custom glyph palettes); a minimal optional glyph/style at most. +- `Input.Prompt` / `Input.ReadOnly` on the fixed-region input surface (still deferred). +- Tightening the `InputDialog` over-budget caret reporting flagged by `multi-line-dialog-prompts`. +- Any `dmon`-side migration; this change only ships the library surface that unblocks it. + +## Decisions + +### Decision 1 — `Line` shorthands are sanitizing wrappers over `FromText`, set Bold/Dim/Fg/Bg only +`Line.Bold(s)` ≡ `Line.FromText(s, new Style(Format: Format.Bold))`; `Dim` likewise; `Fg(s, c)` / +`Bg(s, c)` set the color. They route through the ordinary sanitizing `Segment` constructor, so +control/escape neutralization is identical to `FromText`. **Why this set:** the samples only exercise +Bold/Dim/Fg; Bg is added for fg/bg symmetry. *Alternatives:* full `LineBuilder` parity (8 methods) — +rejected, 5 factories would have zero consumers; an instance-fluent `Line.Styled(s).Bold()` — rejected, +heavier than the one-liner it replaces and `LineBuilder` already covers fluent composition. + +### Decision 2 — No `Line.Raw` +`Segment.Raw` / `LineBuilder.Raw` stay the only verbatim seams. *Why:* the vt-escape-sanitization +design deliberately narrowed verbatim output to a single, conspicuous path; a top-level `Line.Raw` +factory would widen the unsanitized surface and invite accidental escape smuggling. Consumers needing a +verbatim single-segment line use `new LineBuilder().Raw(s).Build()`. *Alternative:* add `Line.Raw` for +symmetry — rejected on the security argument. + +### Decision 3 — `AppendRule` renders a width-aware rule line-object resolved at paint time +`IScrollback.AppendRule()` posts a fire-and-forget loop command (like `Append`) carrying a new +rule line-object. The object holds no fixed width; the renderer expands it to the live-window content +width at paint time, so it stays correct across resize. Keep the public signature minimal — +parameterless in the first cut, with room for an optional glyph/`Style` later without breaking callers. +*Why a distinct line-object* (not a pre-built `Line` of `─`): width is a render-time property; baking a +fixed-width `Line` would be wrong after a resize and would duplicate the wrapping logic. Remove the +`// AppendRule … deferred` gap comment when landed. *Alternative:* `Append(Line.FromText(new string('─', +width)))` at the call site — rejected, pushes width math onto consumers and breaks on resize. + +### Decision 4 — `Collapsible.AppendLine` is honored only while collapsed-and-live; otherwise a no-op +The hidden-line list is mutated only by the render-loop thread via a new `AppendLineToCollapsible` +loop command. The command checks the collapsible's state: if it has already expanded, or frozen past +the commit horizon, the append is dropped (no-op), exactly mirroring how `Expand` no-ops past the +horizon. This keeps the one-way invariant intact (you can never *grow* visible content after the +reveal decision is made) and sidesteps the `scrollback-oversized-reprint-ordering` edge — because +`AppendLine` only ever touches the *pre-expansion* hidden snapshot, it cannot interleave with the +oversized-reprint commit ordering, which runs at/after expansion. *Alternative:* allow post-expansion +append (live-growing revealed content) — rejected, it breaks "expand at most once / one-way" and +collides directly with the reprint-ordering edge. + +### Decision 5 — `PasteEvent` routes through the existing intercept chain; paste is a first edit +Paste is delivered to the same intercept chain as keys: overlay-first (an active `Dialog`/`InputDialog` +consumes it), else the base input editor. The editor's insert path is reused — the paste string is +inserted at the caret as one edit with display-width/multiline wrapping, identical to typed insertion. +The single sticky `_userEdited` flag introduced in pass-1 §4 is flipped by the paste path, so a seeded +secret `Default` switches from default-masking to buffer-masking on the next paint. *Why reuse +`_userEdited`:* pass-1's DEVLOG explicitly pre-registered paste/history-recall as future edit triggers +that "must flip `_userEdited` too"; this change makes that real. *Alternative:* a separate paste flag — +rejected, pass-1 deliberately introduced exactly one edit flag. + +### Decision 6 — Multi-select Back uses `[`; Select/Choice gain `[` as a secondary key +`MultiSelectRequest` gets `AllowBack` (default `false`). When `true`, `[` at any time → `Back`, with no +movement-suppression (toggling does not disarm it). Select/Choice keep their pass-1 +Backspace-before-first-move binding and additionally accept `[` (also movement-suppressed for them, to +match Backspace). *Why `[` for multi-select:* Space toggles and Backspace would be needed for nothing +here, but a Backspace-position heuristic is unreliable amid toggling — a distinct, never-printable-in-a- +list key removes the ambiguity that caused two prior deferrals. *Why also on Select/Choice:* one Back +key across all three dialogs is less surprising for consumers than a per-dialog split. The internal +`OverlayCloseKind` enum already has `Back` (pass-1); only the key-handler arms and the request flag are +new. *Alternative:* keep multi-select Back-less — rejected, the user asked to settle it; *Alternative:* +Backspace-before-first-toggle for multi-select — rejected as the ambiguous path both prior changes +declined. + +### Decision 7 — Section = work item = one commit; samples migrate in their own section +tasks.md is organized so each work item is a `## N.` section committed independently per the repo +apply workflow, a sample-migration section (mirroring pass-1 §5) moves WizardRenderer.cs / +Program.cs single-style sites onto the new factories, and a final Validation & packaging section runs +the gates and bumps `0.2.0-rc.3` in both `.csproj` files plus `dotnet pack`. + +## Risks / Trade-offs + +- **[Paste routing touches the load-bearing intercept chain and secret-masking path]** → Reuse the + existing key-routing chain and the single `_userEdited` flag rather than adding parallel paths; pin + the secret-default-flip behaviour with a `HeadlessTerminal` regression test driving a `PasteEvent`. +- **[New loop commands could violate single-thread mutation]** (`ca2007-render-loop-thread-discipline`) + → `AppendRule` and `AppendLineToCollapsible` mutate state only inside the loop-thread command apply, + matching `Append`/`Expand`; reviewer checks thread correctness by hand (CA2007 won't catch it). +- **[`AppendLine` interacting with the oversized-reprint ordering edge]** + (`scrollback-oversized-reprint-ordering`) → constrain `AppendLine` to the pre-expansion hidden + snapshot only (Decision 4), so it never participates in the reprint-commit ordering; add a test that + appends, then triggers an oversized expansion, and asserts ordering is unchanged from the baseline. +- **[`[` is a printable character a consumer might expect in a filter]** → `[`-as-Back is gated behind + opt-in `AllowBack=true`; with the default `false` it has no effect, and the affected dialogs + (Select/MultiSelect/Choice) do not type-to-filter on bracket characters today. Documented on the + flag. +- **[Width-aware rule rendering must stay correct across resize]** → the rule line-object resolves its + width at paint time (Decision 3); a resize test asserts the rule re-expands. + +## Migration Plan + +No data or protocol migration. Rollout is the standard section-by-section apply on +`change/api-ergonomics-pass-2`, one commit per section, ending with the `0.2.0-rc.3` version bump and +`dotnet pack`. Rollback is reverting the branch — every addition is opt-in or additive, so reverting +cannot break a consumer that was already compiling against `rc.2`. + +## Open Questions + +None blocking. Minor, resolvable during implementation without a spec change: +- Whether `AppendRule` takes an optional rule glyph / `Style` in this cut or stays parameterless — lean + parameterless (Decision 3 leaves room to add an overload later without breaking callers). +- Whether the `[` secondary-Back binding on Select/Choice warrants its own scenario beyond the one in + the spec delta — the reviewer can request additional coverage if the single scenario is thin. diff --git a/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/proposal.md b/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/proposal.md new file mode 100644 index 0000000..5fdf3d0 --- /dev/null +++ b/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/proposal.md @@ -0,0 +1,36 @@ +## Why + +`api-ergonomics-pass-1` (released `0.2.0-rc.1`) closed three of the five §14.4 dmon-wizard-port ergonomics gaps; `multi-line-dialog-prompts` (`0.2.0-rc.2`) widened dialog preambles. Two classes of friction remain, plus three contracts the specs already *name* but the code never satisfied: the headline `LineBuilder` ceremony for single-style label strings (~24 concrete call sites across the two samples), a `Scrollback.AppendRule` command the `inline-scrollback` spec already mandates but ships unimplemented, an incremental `Collapsible.AppendLine` left as a documented gap, `PasteEvent` text that the input editor never inserts (the spec already lists "paste" as a first-edit trigger), and a multi-select "go back" affordance deferred twice for want of an unambiguous keybinding. This change closes all five before the rest of the dmon migration leans on them, so consumer call sites don't churn a third time. + +## What Changes + +- **`Line` single-style shorthand factories** — `Line.Bold(string)`, `Line.Dim(string)`, `Line.Fg(string, Color)`, `Line.Bg(string, Color)`, analogous to the existing `Line.FromText`. Each builds a single **sanitized** `Segment` and is semantically equal to `Line.FromText(text, new Style(...))`. No `Italic`/`Underline`/`Reverse`/`Strikethrough` factories (no current call sites). **No `Line.Raw` shorthand** — `Segment.Raw` / `LineBuilder.Raw` remain the only verbatim (unsanitized) seams, preserving the single-escape-hatch invariant from the vt-escape-sanitization design. +- **`IScrollback.AppendRule()`** — closes a spec/impl gap: the `inline-scrollback` "Scrollback command surface" requirement already says the library SHALL expose a command to "append a rule/separator", but no method, scenario, or implementation exists (`ScrollbackSurface` carries an explicit `// AppendRule … deferred` comment). Adds a width-aware horizontal-rule line-object spanning the live-window content width, posted as a fire-and-forget loop command like `Append`. +- **`ICollapsible.AppendLine(Line)` + `AppendLine(string)`** — closes the "incremental append to the hidden-line list … is a documented gap" noted on `BeginCollapsible`. Appends to a collapsible's hidden-line set via a loop command, with explicit one-way semantics: append is honored while the block is collapsed and live; it is a **no-op** once the block has expanded or frozen past the commit horizon (mirroring `Expand()`'s past-horizon no-op). +- **`PasteEvent` routing into the owned input editor** — `PasteEvent` already exists in the `terminal-input` event model and parser, but its text is never inserted. Route it through the intercept chain to the active input surface (overlay-first), inserting at the caret with display-width- and multiline-aware semantics identical to typed insertion. Paste **counts as a first edit**: it flips the `InputDialog` secret-default state so a seeded `IsSecret` `Default` switches from default-masking to buffer-masking — making the spec's existing "insert, delete, paste, history-recall" wording true in code. +- **`MultiSelectRequest.AllowBack`** (default `false`) — settles the twice-deferred multi-select Back affordance. When `true`, pressing **`[`** at any time produces `OverlayCloseKind.Back` → `DialogOutcome.Back`. A distinct key (not Backspace) sidesteps the Backspace-at-toggle-position ambiguity that caused the prior deferrals. `Select`/`Choice` additionally accept `[` as a secondary Back key (when their existing `AllowBack=true`) for cross-dialog consistency; their Backspace-before-first-move binding from pass-1 is unchanged. + +No breaking changes. Every addition is a new factory, a new overload, a new method, or an opt-in flag defaulting to existing behaviour; all current call sites compile and behave identically. Package bump `0.2.0-rc.2 → 0.2.0-rc.3`. + +## Capabilities + +### New Capabilities + +None — this change extends existing capabilities only. + +### Modified Capabilities + +- `styled-text`: ADDED requirement for the `Line.Bold` / `Line.Dim` / `Line.Fg` / `Line.Bg` single-style shorthand factories (single sanitized segment; no implicit conversion; no `Raw` shorthand). No behavioural change to existing requirements. +- `inline-scrollback`: MODIFIED "Scrollback command surface" to add a satisfiable `AppendRule` scenario; MODIFIED "One-way collapsible" to define incremental hidden-line `AppendLine` with explicit post-expand / post-horizon no-op semantics. +- `fixed-region`: MODIFIED "Owned input editor" (and intercept-chain routing) so `PasteEvent` text is inserted at the caret and counts as a first edit (flips secret-default masking); MODIFIED "Awaitable modal dialogs" to give `MultiSelectRequest` an opt-in `AllowBack` bound to `[` — reversing the spec's current "MultiSelectRequest SHALL continue to omit AllowBack" sentence. + +## Impact + +- **Public API (`Dcli`):** net additions only — four `Line` factories; `IScrollback.AppendRule`; `ICollapsible.AppendLine(Line)` / `(string)`; `MultiSelectRequest.AllowBack`; `[`-as-Back accepted by `Select`/`Choice`/`MultiSelect` dialogs under `AllowBack`. Existing members unchanged. +- **Public API (`Dcli.Testing`):** none directly; new tests use the headless harness as substrate. +- **Production code:** `Line` factories are thin wrappers over `FromText`. `AppendRule` needs a new width-aware rule line-object + loop command. `Collapsible.AppendLine` needs a loop command appending to the hidden-line list, guarded by the collapsed/live state. `PasteEvent` routing is a new arm in the intercept chain + the editor's insert path + the secret-default first-edit flip. `MultiSelectRequest.AllowBack` is one flag + one `[` keybinding arm in the dialog key handler. All new loop commands and the paste path must respect render-loop single-thread mutation (see memory `ca2007-render-loop-thread-discipline`). +- **Samples:** `samples/Dcli.Demo.DmonWizard/Engine/WizardRenderer.cs` (~10 single-style sites) and `samples/Dcli.Demo/Program.cs` (~14 sites) migrate onto the new `Line` factories to validate ergonomics, mirroring pass-1 §5. +- **Tests:** ~20–30 new tests across `LineTests` (factories), `ScrollbackTests`/inline-scrollback harness tests (rule, incremental collapsible), input-editor tests (paste insertion + secret-default flip), and `DialogSelectionTests` (multi-select `AllowBack`/`[`). All ride the `Dcli.Testing` `HeadlessTerminal`/`FrameSnapshot` harness. +- **Cross-references:** memory `scrollback-oversized-reprint-ordering` (ensure `AppendLine` doesn't worsen the known commit-order edge); memory `restore-on-signal-rendering-state` (paste routing adds no new restore-path state). +- **Out of scope (deferred to later changes):** `Italic`/`Underline`/`Reverse`/`Strikethrough` `Line` factories; `Line.Raw`; `Input.Prompt` / `Input.ReadOnly` on the fixed-region input surface; deeper rule styling (custom glyph palettes beyond a minimal optional glyph/style); the `InputDialog` over-budget caret-reporting tightening flagged by multi-line-dialog-prompts. +- **Consumers:** unblocks the remaining `dmon-migration` work that leans on rule separators, incremental collapsibles, paste-into-input, and multi-select back-navigation. diff --git a/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/specs/fixed-region/spec.md b/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/specs/fixed-region/spec.md new file mode 100644 index 0000000..85f7693 --- /dev/null +++ b/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/specs/fixed-region/spec.md @@ -0,0 +1,149 @@ +## MODIFIED Requirements + +### 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. + +A `PasteEvent` delivered to the active input surface SHALL have its entire text inserted at the caret as a single edit, with the same display-width-aware and multiline-aware semantics as typed character insertion (the caret advances past the inserted text and wrapping is recomputed). Paste SHALL be routed through the intercept chain like other input: while a modal Dialog or `InputDialog` is active it is consumed by that overlay's editor; otherwise it is applied to the base input editor. + +When an `InputRequest` is constructed with `IsSecret = true` and a non-empty `Default`, the editor SHALL render the seeded default as `'•'` repeated by the default's display width on every paint that occurs before the user's first edit. Any edit — insert, delete, **paste**, or history-recall — SHALL count as the user's first edit, after which the editor SHALL fall back to the existing secret-render path (which masks the current buffer contents as bullets on each paint). The `Submit` outcome SHALL return the real string contents — either the unedited `Default` or the edited text — regardless of how it was rendered. + +When `IsSecret = false`, the editor SHALL render the seeded default as plain text (unchanged from v1 behaviour). + +#### Scenario: Wrapping tracks the caret + +- **WHEN** input text exceeds the available width +- **THEN** the text wraps and the caret is positioned at the correct visual row and column + +#### Scenario: History recall + +- **WHEN** the user navigates input history +- **THEN** the buffer is replaced with the recalled entry + +#### Scenario: Paste inserts text at the caret + +- **WHEN** a `PasteEvent` is delivered while the input editor is focused +- **THEN** the event's entire text is inserted at the caret position and the caret advances to the end of the inserted text + +#### Scenario: Paste into a multiline buffer wraps correctly + +- **WHEN** a `PasteEvent` whose text exceeds the available width is delivered +- **THEN** the inserted text wraps display-width-aware across rows and the caret is positioned at the correct visual row and column + +#### Scenario: Paste counts as a first edit for a secret default + +- **WHEN** an `InputRequest` with `IsSecret=true` and a non-empty `Default` is shown and the user's first interaction is a `PasteEvent` +- **THEN** the editor switches from default-masking to buffer-masking on the next paint (the seeded default is no longer the rendered content) and `Submit` returns the real edited buffer text + +#### Scenario: Secret default is masked before first edit + +- **WHEN** an `InputRequest` is shown with `IsSecret=true` and a non-empty `Default` and the user has not yet edited the buffer +- **THEN** the paint shows bullets (`•`) at every column the default occupies, never the default's clear-text content + +#### Scenario: Secret default reveals real text on submit + +- **WHEN** the user submits an `InputRequest` with `IsSecret=true` and a non-empty `Default` without editing the buffer +- **THEN** the awaited `DialogResult.Value` is the real `Default` string (not bullets) + +#### Scenario: Non-secret default renders as plain text + +- **WHEN** an `InputRequest` is shown with `IsSecret=false` and a non-empty `Default` +- **THEN** the paint shows the default's clear-text content (no masking) + +### Requirement: Awaitable modal dialogs + +The library SHALL expose select, multi-select, input, and choice dialogs as awaitable operations that return a `DialogResult` whose outcome is `Submitted`, `Back`, or `Cancelled`. + +Each dialog request type SHALL carry an optional **multi-line preamble** rendered top-to-bottom above the interactive widget within the overlay: + +- `SelectRequest.Title` SHALL be typed `IReadOnlyList?` and SHALL render as a sequence of styled rows above the list items. +- `MultiSelectRequest.Title` SHALL be typed `IReadOnlyList?` and SHALL render as a sequence of styled rows above the list items. +- `ChoiceRequest.Prompt` SHALL be typed `IReadOnlyList?` and SHALL render as a sequence of styled rows above the options. +- `InputRequest.Prompt` SHALL be typed `IReadOnlyList?` and SHALL render as a sequence of styled rows above the input field. + +Each request type SHALL expose backwards-compatible convenience constructors that accept a single `Line`, a single `string` (converted via `Line.FromText`), an `IReadOnlyList`, a `params Line[]`, an `IReadOnlyList`, or a `params string[]` for the preamble. Single-`Line` and single-`string` forms SHALL be internally equivalent to passing a one-element list. When the preamble is `null` or empty, no preamble row SHALL be painted and the full overlay budget SHALL be available to the interactive widget. + +`SelectRequest`, `ChoiceRequest`, and `MultiSelectRequest` SHALL each expose an opt-in `AllowBack` flag (default `false`, backward-compatible). When `AllowBack=false`, no key produces `Back` and existing v1 behaviour is preserved. When `AllowBack=true`: + +- `SelectRequest` 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. + +#### 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`, or `MultiSelectRequest` is constructed without setting `AllowBack` +- **THEN** Backspace and `[` have no effect on the dialog and existing v1 behaviour is preserved + +#### 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: Multi-line preamble renders all lines above the widget + +- **WHEN** a dialog request is constructed with a preamble containing multiple `Line`s +- **THEN** the overlay paints each preamble line in order, top-to-bottom, immediately above the interactive widget (list / options / input field) + +#### Scenario: Single-Line preamble constructor still works + +- **WHEN** a dialog request is constructed via the single-`Line` convenience constructor (e.g. `new ChoiceRequest(options, prompt: someLine)`) +- **THEN** the overlay paints exactly one preamble row, semantically identical to passing a one-element list + +#### Scenario: Single-string preamble constructor still works + +- **WHEN** a dialog request is constructed via the single-`string` convenience constructor (e.g. `new ChoiceRequest(options, prompt: "Permission:")`) +- **THEN** the string is wrapped via `Line.FromText` and a single preamble row is painted with the default style + +#### Scenario: Null or empty preamble paints no preamble row + +- **WHEN** a dialog request is constructed with a `null` or empty-list preamble +- **THEN** no preamble rows are painted and the full overlay budget is available to the interactive widget + +#### Scenario: Multi-line preamble truncates when over budget + +- **WHEN** a preamble's line count plus the interactive widget's minimum height exceeds the overlay's available rows +- **THEN** the preamble truncates per the existing overlay budget arithmetic (the same behaviour live-blocks have shipped since v1); the interactive widget remains usable diff --git a/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/specs/inline-scrollback/spec.md b/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/specs/inline-scrollback/spec.md new file mode 100644 index 0000000..b506f5e --- /dev/null +++ b/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/specs/inline-scrollback/spec.md @@ -0,0 +1,43 @@ +## MODIFIED Requirements + +### Requirement: One-way collapsible +A collapsible line-object SHALL begin collapsed (showing a summary) and SHALL expand at most once; it SHALL NOT re-collapse. The library SHALL allow a caller holding the collapsible handle to **incrementally append** lines to the collapsible's hidden-line set via `AppendLine(Line)` and a `AppendLine(string)` shorthand (the string form wrapped through `Line.FromText` with default style). An incremental append SHALL be honored only while the block is still collapsed and live: once the block has been expanded, or has frozen collapsed past the commit horizon, a further `AppendLine` SHALL be a no-op (mirroring the past-horizon no-op of `Expand`). When honored before expansion, the appended line SHALL become part of the hidden set that a subsequent `Expand` reveals. + +#### Scenario: Expand reveals hidden lines +- **WHEN** a collapsed collapsible is expanded +- **THEN** its hidden lines become visible and remain visible + +#### Scenario: Cannot re-collapse +- **WHEN** a collapse is requested on a collapsible +- **THEN** the request has no effect and the collapsible stays expanded once expanded + +#### Scenario: Freezes collapsed at the horizon +- **WHEN** a still-collapsed collapsible commits past the horizon before being expanded +- **THEN** it freezes in the collapsed state and can no longer be expanded + +#### Scenario: Append grows the hidden set before expansion +- **WHEN** a caller calls `AppendLine` on a still-collapsed, still-live collapsible and then expands it +- **THEN** the expanded content includes the appended line in append order after the originally-supplied hidden lines + +#### Scenario: Append after expansion is a no-op +- **WHEN** a caller calls `AppendLine` on a collapsible that has already been expanded +- **THEN** the call has no effect and the revealed content is unchanged + +#### Scenario: Append after horizon-freeze is a no-op +- **WHEN** a caller calls `AppendLine` on a collapsible that has frozen collapsed past the commit horizon +- **THEN** the call has no effect + +### Requirement: Scrollback command surface +The library SHALL expose commands to append a line, append a rule/separator, append a line to a collapsible's hidden set, begin a live block, and begin a collapsible. The append-a-rule command SHALL be `IScrollback.AppendRule()`, producing a width-aware horizontal-rule line-object that spans the live-window content width at render time; it SHALL be posted as a fire-and-forget loop command like `Append`. + +#### Scenario: Append a line +- **WHEN** a caller appends a styled line +- **THEN** the line is enqueued for rendering into the live window + +#### Scenario: Append a rule +- **WHEN** a caller calls `AppendRule` +- **THEN** a width-aware rule line-object is enqueued and rendered as a horizontal separator spanning the live-window content width + +#### Scenario: Begin a collapsible +- **WHEN** a caller begins a collapsible with a summary line +- **THEN** a collapsible line-object is created in the collapsed state showing that summary diff --git a/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/specs/styled-text/spec.md b/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/specs/styled-text/spec.md new file mode 100644 index 0000000..1e3c558 --- /dev/null +++ b/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/specs/styled-text/spec.md @@ -0,0 +1,47 @@ +## ADDED Requirements + +### Requirement: Single-style line shorthand factories + +The library SHALL expose single-style `Line` static factories that build a one-`Segment` line from a plain `string`, so single-style label call sites do not have to lift through `LineBuilder`. The factories SHALL be: + +- `Line.Bold(string text)` — a single segment whose style has `Format.Bold`. +- `Line.Dim(string text)` — a single segment whose style has `Format.Dim`. +- `Line.Fg(string text, Color foreground)` — a single segment whose style sets the given foreground color. +- `Line.Bg(string text, Color background)` — a single segment whose style sets the given background color. + +Each factory SHALL be semantically equivalent to `Line.FromText(text, style)` with the corresponding single `Format`/`Color` set, and its segment SHALL be constructed through the ordinary sanitizing path (control and escape bytes neutralized exactly as for `Line.FromText`, per the "Terminal-safe segment text" requirement). The library SHALL NOT add `Italic`, `Underline`, `Reverse`, or `Strikethrough` single-style line factories in this change, and SHALL NOT add a `Line.Raw` factory — `Segment.Raw` and `LineBuilder.Raw` SHALL remain the only verbatim (unsanitized) construction seams. No implicit `string`→`Line` conversion SHALL be defined. + +#### Scenario: Bold produces a single bold segment + +- **WHEN** a caller invokes `Line.Bold("hi")` +- **THEN** the returned `Line` contains a single `Segment` whose text is `"hi"` and whose style has `Format.Bold` and no other attributes + +#### Scenario: Dim produces a single dim segment + +- **WHEN** a caller invokes `Line.Dim("note")` +- **THEN** the returned `Line` contains a single `Segment` whose text is `"note"` and whose style has `Format.Dim` + +#### Scenario: Fg produces a single foreground-colored segment + +- **WHEN** a caller invokes `Line.Fg("err", Color.Named(Color.AnsiColor.Red))` +- **THEN** the returned `Line` contains a single `Segment` whose text is `"err"` and whose style sets that foreground color and `Format.None` + +#### Scenario: Bg produces a single background-colored segment + +- **WHEN** a caller invokes `Line.Bg("sel", Color.Named(Color.AnsiColor.Blue))` +- **THEN** the returned `Line` contains a single `Segment` whose text is `"sel"` and whose style sets that background color + +#### Scenario: Shorthand factories are equivalent to FromText with a style + +- **WHEN** a caller invokes `Line.Bold("x")` +- **THEN** the result is equal to `Line.FromText("x", new Style(Format: Format.Bold))` + +#### Scenario: Shorthand factories sanitize control bytes + +- **WHEN** a caller invokes `Line.Bold("ab")` (text containing an `ESC` byte) under the default sanitization mode +- **THEN** the resulting segment's stored text contains no `ESC` byte, exactly as `Line.FromText` would have neutralized it + +#### Scenario: No Raw line shorthand is defined + +- **WHEN** a consumer needs a verbatim (unsanitized) single-segment line +- **THEN** there SHALL be no `Line.Raw` factory; the consumer SHALL use `new LineBuilder().Raw(text).Build()` or `Segment.Raw`, which remain the only verbatim seams diff --git a/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/tasks.md b/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/tasks.md new file mode 100644 index 0000000..60508a2 --- /dev/null +++ b/openspec/changes/archive/2026-05-29-api-ergonomics-pass-2/tasks.md @@ -0,0 +1,67 @@ +## 1. Line single-style shorthand factories + +- [x] 1.1 Add `public static Line Bold(string text)` to `src/Dcli/Line.cs`, equivalent to `FromText(text, new Style(Format: Format.Bold))`. +- [x] 1.2 Add `public static Line Dim(string text)` equivalent to `FromText(text, new Style(Format: Format.Dim))`. +- [x] 1.3 Add `public static Line Fg(string text, Color foreground)` equivalent to `FromText(text, new Style(Foreground: foreground))`. +- [x] 1.4 Add `public static Line Bg(string text, Color background)` equivalent to `FromText(text, new Style(Background: background))`. +- [x] 1.5 XML-doc each factory: single sanitized `Segment`, equivalent `FromText` form, and that there is deliberately no `Italic`/`Underline`/`Reverse`/`Strikethrough`/`Raw` shorthand (`Segment.Raw`/`LineBuilder.Raw` remain the only verbatim seams; no implicit `string`→`Line` conversion). +- [x] 1.6 Add `LineTests` covering: Bold→single bold segment; Dim→single dim segment; Fg→single fg-colored segment + `Format.None`; Bg→single bg-colored segment. +- [x] 1.7 Add `LineTests` asserting `Line.Bold("x")` equals `Line.FromText("x", new Style(Format: Format.Bold))` (equivalence), and that a control/escape byte is neutralized identically to `FromText` under the default sanitization mode. +- [x] 1.8 Build + test + format gates clean for this section. + +## 2. Scrollback.AppendRule + +- [x] 2.1 Add a width-aware rule line-object in the inline-scrollback widget layer whose rendered width resolves to the live-window content width at paint time (correct across resize); do not bake a fixed width. +- [x] 2.2 Add `void AppendRule()` to `IScrollback` (`src/Dcli/ITerminal.cs`) with XML doc. +- [x] 2.3 Implement `ScrollbackSurface.AppendRule()` posting a new fire-and-forget loop command (sibling of `AppendToScrollbackCommand`); the command appends the rule line-object on the render-loop thread only. +- [x] 2.4 Remove the `// AppendRule … needs a width-aware rule line-object … deferred to a future change` documented-gap comment from `ScrollbackSurface`. +- [x] 2.5 Add a test asserting `AppendRule` enqueues and renders a horizontal separator spanning the live-window content width (via `HeadlessTerminal`/`FrameSnapshot`). +- [x] 2.6 Add a resize test asserting the rule re-expands to the new content width after the terminal resizes. +- [x] 2.7 Build + test + format gates clean for this section. + +## 3. Incremental Collapsible.AppendLine + +- [x] 3.1 Add `void AppendLine(Line line)` and `void AppendLine(string text)` (string form via `Line.FromText`) to `ICollapsible` (`src/Dcli/ScrollbackSurface.cs`) with XML doc stating the one-way semantics. +- [x] 3.2 Add a new loop command that appends to the collapsible's hidden-line list on the render-loop thread only; it MUST no-op if the collapsible has already expanded or frozen past the commit horizon (mirror `Expand`'s past-horizon no-op). +- [x] 3.3 Wire `CollapsibleHandle.AppendLine` to post the command; update the `BeginCollapsible` "incremental append … is a documented gap" remark to reflect that the gap is now closed. +- [x] 3.4 Add a test: append before expansion, then expand → revealed content includes the appended line in append order after the original hidden lines. +- [x] 3.5 Add a test: append after expansion is a no-op (revealed content unchanged). +- [x] 3.6 Add a test: append after horizon-freeze is a no-op. +- [x] 3.7 Add a regression test guarding `scrollback-oversized-reprint-ordering`: append, then trigger an oversized expansion, and assert commit ordering matches the existing baseline (AppendLine does not worsen the edge). +- [x] 3.8 Build + test + format gates clean for this section. + +## 4. PasteEvent editor routing + +- [x] 4.1 Route `PasteEvent` through the existing intercept chain to the active input surface (overlay-first: an active `Dialog`/`InputDialog` consumes it; otherwise the base input editor) on the render-loop thread. +- [x] 4.2 Insert the paste text at the caret as a single edit using the editor's existing insert path (display-width-aware, multiline-aware; caret advances past the inserted text; wrapping recomputed). +- [x] 4.3 Flip the existing sticky `_userEdited` flag (from pass-1 §4) on paste so a seeded secret `Default` switches from default-masking to buffer-masking. +- [x] 4.4 Add a test: paste inserts text at the caret and the caret advances to the end of the inserted text. +- [x] 4.5 Add a test: pasting text wider than the available width wraps display-width-aware and the caret lands at the correct visual row/column. +- [x] 4.6 Add a test driving a `PasteEvent` as the first interaction on an `IsSecret=true` `InputRequest` with a non-empty `Default`: next paint shows buffer-masking (seeded default no longer the rendered content) and `Submit` returns the real edited buffer. +- [x] 4.7 Add a test confirming paste is consumed by an active modal dialog/InputDialog and does not leak to the base editor when an overlay is active. +- [x] 4.8 Build + test + format gates clean for this section. + +## 5. Multi-select Back via '[' + +- [x] 5.1 Add `AllowBack` (default `false`) to `MultiSelectRequest` (`src/Dcli/DialogRequests.cs`) with XML doc explaining the `[` binding and why Backspace is not used for multi-select. +- [x] 5.2 In the dialog key handler, when a multi-select overlay has `AllowBack=true`, map `[` (at any time, no movement-suppression) to `OverlayCloseKind.Back` → `DialogOutcome.Back`. +- [x] 5.3 For `Select`/`Choice` with `AllowBack=true`, additionally accept `[` as a secondary Back key alongside the existing pass-1 Backspace-before-first-move binding (apply the same movement-suppression as Backspace for these two). +- [x] 5.4 Add tests: MultiSelect `AllowBack=true` + `[` → `Back`; MultiSelect `[` still produces `Back` after toggling items with Space; MultiSelect `AllowBack=false` (default) → `[` has no effect (v1 behaviour preserved). +- [x] 5.5 Add tests: Select/Choice `AllowBack=true` + `[` before moving the selection → `Back`; the existing Backspace bindings remain unchanged. +- [x] 5.6 Build + test + format gates clean for this section. + +## 6. Sample migration onto the new Line factories + +- [x] 6.1 Replace the single-style `new LineBuilder().Bold(s)/.Dim(s)/.Fg(s, color).Build()` sites in `samples/Dcli.Demo.DmonWizard/Engine/WizardRenderer.cs` (~10 sites) with `Line.Bold(s)` / `Line.Dim(s)` / `Line.Fg(s, color)`. +- [x] 6.2 Replace the single-style `new LineBuilder()....Build()` label sites in `samples/Dcli.Demo/Program.cs` (~14 sites) with the corresponding `Line.Bold/Dim/Fg` factories. +- [x] 6.3 Optionally demonstrate `AppendRule` and/or incremental `Collapsible.AppendLine` in `Program.cs` where it tightens the demo (keep minimal; samples-only). +- [x] 6.4 Confirm samples build; multi-segment `LineBuilder` sites (not single-style) are left untouched. +- [x] 6.5 Build + test + format gates clean for this section. + +## 7. Validation & packaging + +- [x] 7.1 `openspec validate api-ergonomics-pass-2 --strict` passes. +- [x] 7.2 `dotnet build` clean (0 warnings, analyzers/warnings-as-errors), `dotnet test` green (all sections' new tests + full existing suite), `dotnet format --verify-no-changes` clean. +- [x] 7.3 Bump `Version` `0.2.0-rc.2 → 0.2.0-rc.3` in `src/Dcli/Dcli.csproj` and `src/Dcli.Testing/Dcli.Testing.csproj`. +- [x] 7.4 `dotnet pack -c Release` produces `dcli.0.2.0-rc.3.{nupkg,snupkg}` and `dcli.testing.0.2.0-rc.3.{nupkg,snupkg}`. +- [x] 7.5 Final gates re-run clean against the version bump; record per-section commit hashes in the DEVLOG. diff --git a/openspec/specs/fixed-region/spec.md b/openspec/specs/fixed-region/spec.md index e528618..67e794a 100644 --- a/openspec/specs/fixed-region/spec.md +++ b/openspec/specs/fixed-region/spec.md @@ -1,9 +1,7 @@ ## Purpose 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. @@ -13,9 +11,11 @@ The fixed region SHALL be a contiguous, bottom-pinned stack of components — in ### Requirement: Owned input editor -The library SHALL own an input editor supporting caret movement, multiline text, display-width-aware wrapping, history recall, and internal scrolling when its content exceeds its allotted height. +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. + +A `PasteEvent` delivered to the active input surface SHALL have its entire text inserted at the caret as a single edit, with the same display-width-aware and multiline-aware semantics as typed character insertion (the caret advances past the inserted text and wrapping is recomputed). Paste SHALL be routed through the intercept chain like other input: while a modal Dialog or `InputDialog` is active it is consumed by that overlay's editor; otherwise it is applied to the base input editor. -When an `InputRequest` is constructed with `IsSecret = true` and a non-empty `Default`, the editor SHALL render the seeded default as `'•'` repeated by the default's display width on every paint that occurs before the user's first edit. Once the user makes any edit (insert, delete, paste, history-recall), the editor SHALL fall back to the existing secret-render path (which masks the current buffer contents as bullets on each paint). The `Submit` outcome SHALL return the real string contents — either the unedited `Default` or the edited text — regardless of how it was rendered. +When an `InputRequest` is constructed with `IsSecret = true` and a non-empty `Default`, the editor SHALL render the seeded default as `'•'` repeated by the default's display width on every paint that occurs before the user's first edit. Any edit — insert, delete, **paste**, or history-recall — SHALL count as the user's first edit, after which the editor SHALL fall back to the existing secret-render path (which masks the current buffer contents as bullets on each paint). The `Submit` outcome SHALL return the real string contents — either the unedited `Default` or the edited text — regardless of how it was rendered. When `IsSecret = false`, the editor SHALL render the seeded default as plain text (unchanged from v1 behaviour). @@ -29,6 +29,21 @@ When `IsSecret = false`, the editor SHALL render the seeded default as plain tex - **WHEN** the user navigates input history - **THEN** the buffer is replaced with the recalled entry +#### Scenario: Paste inserts text at the caret + +- **WHEN** a `PasteEvent` is delivered while the input editor is focused +- **THEN** the event's entire text is inserted at the caret position and the caret advances to the end of the inserted text + +#### Scenario: Paste into a multiline buffer wraps correctly + +- **WHEN** a `PasteEvent` whose text exceeds the available width is delivered +- **THEN** the inserted text wraps display-width-aware across rows and the caret is positioned at the correct visual row and column + +#### Scenario: Paste counts as a first edit for a secret default + +- **WHEN** an `InputRequest` with `IsSecret=true` and a non-empty `Default` is shown and the user's first interaction is a `PasteEvent` +- **THEN** the editor switches from default-masking to buffer-masking on the next paint (the seeded default is no longer the rendered content) and `Submit` returns the real edited buffer text + #### Scenario: Secret default is masked before first edit - **WHEN** an `InputRequest` is shown with `IsSecret=true` and a non-empty `Default` and the user has not yet edited the buffer @@ -109,7 +124,10 @@ Each dialog request type SHALL carry an optional **multi-line preamble** rendere Each request type SHALL expose backwards-compatible convenience constructors that accept a single `Line`, a single `string` (converted via `Line.FromText`), an `IReadOnlyList`, a `params Line[]`, an `IReadOnlyList`, or a `params string[]` for the preamble. Single-`Line` and single-`string` forms SHALL be internally equivalent to passing a one-element list. When the preamble is `null` or empty, no preamble row SHALL be painted and the full overlay budget SHALL be available to the interactive widget. -`SelectRequest` and `ChoiceRequest` SHALL continue to expose the opt-in `AllowBack` flag (default `false`) introduced in `api-ergonomics-pass-1`. `MultiSelectRequest` SHALL continue to omit `AllowBack`. `InputRequest` SHALL continue to omit `AllowBack`. +`SelectRequest`, `ChoiceRequest`, and `MultiSelectRequest` SHALL each expose an opt-in `AllowBack` flag (default `false`, backward-compatible). When `AllowBack=false`, no key produces `Back` and existing v1 behaviour is preserved. When `AllowBack=true`: + +- `SelectRequest` 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. #### Scenario: Select submitted @@ -148,8 +166,23 @@ Each request type SHALL expose backwards-compatible convenience constructors tha #### Scenario: AllowBack=false is the default -- **WHEN** a `SelectRequest` or `ChoiceRequest` is constructed without setting `AllowBack` -- **THEN** Backspace has no effect on the dialog and existing v1 behaviour is preserved +- **WHEN** a `SelectRequest`, `ChoiceRequest`, or `MultiSelectRequest` is constructed without setting `AllowBack` +- **THEN** Backspace and `[` have no effect on the dialog and existing v1 behaviour is preserved + +#### 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: Multi-line preamble renders all lines above the widget @@ -186,3 +219,4 @@ Autocomplete candidates SHALL be supplied by the consumer in response to input-c #### Scenario: Accept a candidate - **WHEN** the user accepts a highlighted candidate - **THEN** the candidate's insert text is applied to the input buffer + diff --git a/openspec/specs/inline-scrollback/spec.md b/openspec/specs/inline-scrollback/spec.md index 4d64aef..b11e172 100644 --- a/openspec/specs/inline-scrollback/spec.md +++ b/openspec/specs/inline-scrollback/spec.md @@ -1,9 +1,7 @@ ## Purpose The `inline-scrollback` capability defines the append-mostly scrollback buffer — line-objects, the commit horizon, one-way collapsibles, and the content-model-to-visual-rows render contract — so styled output flows into the terminal's native scrollback while a bounded live window remains re-renderable. - ## Requirements - ### Requirement: Inline rendering preserves native scrollback The library SHALL render inline and SHALL NOT use the terminal's alternate screen buffer; committed output SHALL remain in the terminal's native scrollback. @@ -49,7 +47,7 @@ The library SHALL provide a live block that accepts appended text and whose cont - **THEN** the replaced content is what freezes into native scrollback ### Requirement: One-way collapsible -A collapsible line-object SHALL begin collapsed (showing a summary) and SHALL expand at most once; it SHALL NOT re-collapse. +A collapsible line-object SHALL begin collapsed (showing a summary) and SHALL expand at most once; it SHALL NOT re-collapse. The library SHALL allow a caller holding the collapsible handle to **incrementally append** lines to the collapsible's hidden-line set via `AppendLine(Line)` and a `AppendLine(string)` shorthand (the string form wrapped through `Line.FromText` with default style). An incremental append SHALL be honored only while the block is still collapsed and live: once the block has been expanded, or has frozen collapsed past the commit horizon, a further `AppendLine` SHALL be a no-op (mirroring the past-horizon no-op of `Expand`). When honored before expansion, the appended line SHALL become part of the hidden set that a subsequent `Expand` reveals. #### Scenario: Expand reveals hidden lines - **WHEN** a collapsed collapsible is expanded @@ -63,6 +61,18 @@ A collapsible line-object SHALL begin collapsed (showing a summary) and SHALL ex - **WHEN** a still-collapsed collapsible commits past the horizon before being expanded - **THEN** it freezes in the collapsed state and can no longer be expanded +#### Scenario: Append grows the hidden set before expansion +- **WHEN** a caller calls `AppendLine` on a still-collapsed, still-live collapsible and then expands it +- **THEN** the expanded content includes the appended line in append order after the originally-supplied hidden lines + +#### Scenario: Append after expansion is a no-op +- **WHEN** a caller calls `AppendLine` on a collapsible that has already been expanded +- **THEN** the call has no effect and the revealed content is unchanged + +#### Scenario: Append after horizon-freeze is a no-op +- **WHEN** a caller calls `AppendLine` on a collapsible that has frozen collapsed past the commit horizon +- **THEN** the call has no effect + ### Requirement: Oversized expansion reprints into flow WHEN expanding a collapsible would make it taller than the live window, the library SHALL reprint the expanded content as normal flowing output rather than keeping it re-renderable. @@ -71,12 +81,17 @@ WHEN expanding a collapsible would make it taller than the live window, the libr - **THEN** its content is emitted as flowing output and scrolls into native scrollback ### Requirement: Scrollback command surface -The library SHALL expose commands to append a line, append a rule/separator, begin a live block, and begin a collapsible. +The library SHALL expose commands to append a line, append a rule/separator, append a line to a collapsible's hidden set, begin a live block, and begin a collapsible. The append-a-rule command SHALL be `IScrollback.AppendRule()`, producing a width-aware horizontal-rule line-object that spans the live-window content width at render time; it SHALL be posted as a fire-and-forget loop command like `Append`. #### Scenario: Append a line - **WHEN** a caller appends a styled line - **THEN** the line is enqueued for rendering into the live window +#### Scenario: Append a rule +- **WHEN** a caller calls `AppendRule` +- **THEN** a width-aware rule line-object is enqueued and rendered as a horizontal separator spanning the live-window content width + #### Scenario: Begin a collapsible - **WHEN** a caller begins a collapsible with a summary line - **THEN** a collapsible line-object is created in the collapsed state showing that summary + diff --git a/openspec/specs/styled-text/spec.md b/openspec/specs/styled-text/spec.md index 27984db..6573425 100644 --- a/openspec/specs/styled-text/spec.md +++ b/openspec/specs/styled-text/spec.md @@ -1,9 +1,7 @@ ## Purpose The `styled-text` capability defines the programmatic `Segment`/`Line`/`Style` primitive — with a `[Flags] Format` enum and a `LineBuilder` — shared across both the scrollback and fixed-region zones. There is no markup parser: styled text is built programmatically. - ## Requirements - ### Requirement: Programmatic styled-text model The library SHALL represent styled text as `Segment` values (text plus a `Style`) composed into `Line` values, and SHALL NOT parse any markup string syntax. Printable text content — including markup-like characters such as `[bold]` — SHALL be preserved literally; only control and escape bytes are neutralized, as defined by the "Terminal-safe segment text" requirement. @@ -155,3 +153,50 @@ The transform applied to neutralized control/escape bytes SHALL be configurable #### Scenario: Unrecognized mode falls back to strip - **WHEN** `DCLI_SANITIZE_MODE` is set to an unrecognized value - **THEN** segments are sanitized as if the mode were `strip` + +### Requirement: Single-style line shorthand factories + +The library SHALL expose single-style `Line` static factories that build a one-`Segment` line from a plain `string`, so single-style label call sites do not have to lift through `LineBuilder`. The factories SHALL be: + +- `Line.Bold(string text)` — a single segment whose style has `Format.Bold`. +- `Line.Dim(string text)` — a single segment whose style has `Format.Dim`. +- `Line.Fg(string text, Color foreground)` — a single segment whose style sets the given foreground color. +- `Line.Bg(string text, Color background)` — a single segment whose style sets the given background color. + +Each factory SHALL be semantically equivalent to `Line.FromText(text, style)` with the corresponding single `Format`/`Color` set, and its segment SHALL be constructed through the ordinary sanitizing path (control and escape bytes neutralized exactly as for `Line.FromText`, per the "Terminal-safe segment text" requirement). The library SHALL NOT add `Italic`, `Underline`, `Reverse`, or `Strikethrough` single-style line factories in this change, and SHALL NOT add a `Line.Raw` factory — `Segment.Raw` and `LineBuilder.Raw` SHALL remain the only verbatim (unsanitized) construction seams. No implicit `string`→`Line` conversion SHALL be defined. + +#### Scenario: Bold produces a single bold segment + +- **WHEN** a caller invokes `Line.Bold("hi")` +- **THEN** the returned `Line` contains a single `Segment` whose text is `"hi"` and whose style has `Format.Bold` and no other attributes + +#### Scenario: Dim produces a single dim segment + +- **WHEN** a caller invokes `Line.Dim("note")` +- **THEN** the returned `Line` contains a single `Segment` whose text is `"note"` and whose style has `Format.Dim` + +#### Scenario: Fg produces a single foreground-colored segment + +- **WHEN** a caller invokes `Line.Fg("err", Color.Named(Color.AnsiColor.Red))` +- **THEN** the returned `Line` contains a single `Segment` whose text is `"err"` and whose style sets that foreground color and `Format.None` + +#### Scenario: Bg produces a single background-colored segment + +- **WHEN** a caller invokes `Line.Bg("sel", Color.Named(Color.AnsiColor.Blue))` +- **THEN** the returned `Line` contains a single `Segment` whose text is `"sel"` and whose style sets that background color + +#### Scenario: Shorthand factories are equivalent to FromText with a style + +- **WHEN** a caller invokes `Line.Bold("x")` +- **THEN** the result is equal to `Line.FromText("x", new Style(Format: Format.Bold))` + +#### Scenario: Shorthand factories sanitize control bytes + +- **WHEN** a caller invokes `Line.Bold("ab")` (text containing an `ESC` byte) under the default sanitization mode +- **THEN** the resulting segment's stored text contains no `ESC` byte, exactly as `Line.FromText` would have neutralized it + +#### Scenario: No Raw line shorthand is defined + +- **WHEN** a consumer needs a verbatim (unsanitized) single-segment line +- **THEN** there SHALL be no `Line.Raw` factory; the consumer SHALL use `new LineBuilder().Raw(text).Build()` or `Segment.Raw`, which remain the only verbatim seams + diff --git a/samples/Dcli.Demo.DmonWizard/Engine/WizardRenderer.cs b/samples/Dcli.Demo.DmonWizard/Engine/WizardRenderer.cs index fc629b8..d59670c 100644 --- a/samples/Dcli.Demo.DmonWizard/Engine/WizardRenderer.cs +++ b/samples/Dcli.Demo.DmonWizard/Engine/WizardRenderer.cs @@ -34,7 +34,7 @@ private static async Task RenderChooseOneAsync( DialogResult result = await terminal.SelectAsync( new SelectRequest( Items: items, - Title: new LineBuilder().Bold(step.Prompt).Build(), + Title: Line.Bold(step.Prompt), AllowBack: true), ct).ConfigureAwait(false); @@ -60,7 +60,7 @@ private static async Task RenderChooseManyAsync( DialogResult result = await terminal.MultiSelectAsync( new MultiSelectRequest( Items: items, - Title: new LineBuilder().Bold(step.Prompt).Build()), + Title: Line.Bold(step.Prompt)), ct).ConfigureAwait(false); if (result.Outcome == DialogOutcome.Cancelled) @@ -76,19 +76,17 @@ private static async Task RenderTextInputAsync( if (step.Default is not null) { string shown = step.Secret ? new string('*', 8) : step.Default; - terminal.Scrollback.Append(new LineBuilder() - .Dim($"Default: {shown}") - .Build()); + terminal.Scrollback.Append(Line.Dim($"Default: {shown}")); } IReadOnlyList prompt = step.Secret ? (IReadOnlyList) [ - new LineBuilder().Bold(step.Prompt).Build(), - new LineBuilder().Dim("Used only for this session. Not persisted to disk.").Build(), - new LineBuilder().Dim("Press Esc to cancel; Enter to confirm.").Build(), + Line.Bold(step.Prompt), + Line.Dim("Used only for this session. Not persisted to disk."), + Line.Dim("Press Esc to cancel; Enter to confirm."), ] - : [new LineBuilder().Bold(step.Prompt).Build()]; + : [Line.Bold(step.Prompt)]; DialogResult result = await terminal.InputAsync( new InputRequest( @@ -118,17 +116,17 @@ private static async Task RenderYesNoAsync( // Use ChoiceAsync for a clean Yes/No prompt. // Honour step.Default by placing the default option first. List options = step.Default - ? [new LineBuilder().Fg("Yes", Color.Named(Color.AnsiColor.Green)).Build(), - new LineBuilder().Fg("No", Color.Named(Color.AnsiColor.Red)).Build()] - : [new LineBuilder().Fg("No", Color.Named(Color.AnsiColor.Red)).Build(), - new LineBuilder().Fg("Yes", Color.Named(Color.AnsiColor.Green)).Build()]; + ? [Line.Fg("Yes", Color.Named(Color.AnsiColor.Green)), + Line.Fg("No", Color.Named(Color.AnsiColor.Red))] + : [Line.Fg("No", Color.Named(Color.AnsiColor.Red)), + Line.Fg("Yes", Color.Named(Color.AnsiColor.Green))]; string hint = step.Default ? "[Y/n]" : "[y/N]"; DialogResult result = await terminal.ChoiceAsync( new ChoiceRequest( Options: options, - Prompt: new LineBuilder().Bold($"{step.Prompt} {hint}").Build()), + Prompt: Line.Bold($"{step.Prompt} {hint}")), ct).ConfigureAwait(false); if (result.Outcome == DialogOutcome.Cancelled) @@ -143,9 +141,7 @@ private static async Task RenderYesNoAsync( private static Task RenderInfoAsync( ITerminal terminal, InfoStep step, CancellationToken ct) { - terminal.Scrollback.Append(new LineBuilder() - .Dim(step.Prompt) - .Build()); + terminal.Scrollback.Append(Line.Dim(step.Prompt)); return Task.FromResult(WizardStepOutcome.Answered); } } diff --git a/samples/Dcli.Demo/Program.cs b/samples/Dcli.Demo/Program.cs index d3196df..1842b5a 100644 --- a/samples/Dcli.Demo/Program.cs +++ b/samples/Dcli.Demo/Program.cs @@ -26,22 +26,18 @@ .Text(" -- smoke tour") .Build()); -t.Scrollback.Append(new LineBuilder() - .Fg(" Styled output flows into the real terminal scrollback.", Color.Named(Color.AnsiColor.Cyan)) - .Build()); +t.Scrollback.Append(Line.Fg(" Styled output flows into the real terminal scrollback.", Color.Named(Color.AnsiColor.Cyan))); t.Scrollback.Append(" A small interactive region is pinned at the bottom."); -t.Scrollback.Append(new LineBuilder() - .Dim(" Content above the commit horizon is frozen and terminal-owned.") - .Build()); +t.Scrollback.Append(Line.Dim(" Content above the commit horizon is frozen and terminal-owned.")); await Task.Delay(TimeSpan.FromMilliseconds(800)); // ── Phase 2: Streaming live block (~3s) ────────────────────────────────────── -t.Status.SetRows(new LineBuilder().Dim("Phase 2/6 -- streaming live block").Build()); -t.Scrollback.Append(new LineBuilder().Bold("--- Streaming live block ---").Build()); +t.Status.SetRows(Line.Dim("Phase 2/6 -- streaming live block")); +t.Scrollback.Append(Line.Bold("--- Streaming live block ---")); ILiveBlock live = t.Scrollback.BeginLive(); @@ -63,31 +59,29 @@ live.SetContent( [ new LineBuilder().Bold("Response (final): ").Text("Hello from dcli!").Build(), - new LineBuilder().Dim(" (SetContent replaced the streamed buffer)").Build(), + Line.Dim(" (SetContent replaced the streamed buffer)"), ]); await Task.Delay(TimeSpan.FromMilliseconds(400)); live.Commit(); -t.Scrollback.Append(new LineBuilder().Dim(" Live block committed.").Build()); +t.Scrollback.Append(Line.Dim(" Live block committed.")); await Task.Delay(TimeSpan.FromMilliseconds(300)); // ── Phase 3: Collapsible "thinking" block (~2s) ─────────────────────────────── -t.Status.SetRows(new LineBuilder().Dim("Phase 3/6 -- collapsible block").Build()); -t.Scrollback.Append(new LineBuilder().Bold("--- Collapsible block ---").Build()); +t.Status.SetRows(Line.Dim("Phase 3/6 -- collapsible block")); +t.Scrollback.Append(Line.Bold("--- Collapsible block ---")); // Build 24 hidden lines so the expand is visually obvious. List hiddenLines = []; for (int i = 1; i <= 24; i++) { - hiddenLines.Add(new LineBuilder() - .Dim($" thinking line {i,2}: reasoning about token {i * 7}...") - .Build()); + hiddenLines.Add(Line.Dim($" thinking line {i,2}: reasoning about token {i * 7}...")); } ICollapsible collapsed = t.Scrollback.BeginCollapsible( - summary: new LineBuilder().Dim("> thinking (24 lines hidden)").Build(), + summary: Line.Dim("> thinking (24 lines hidden)"), hiddenLines: hiddenLines); await Task.Delay(TimeSpan.FromMilliseconds(1000)); @@ -106,7 +100,7 @@ .Text(" and you would see suggestions; Esc to dismiss") .Build()); -t.Scrollback.Append(new LineBuilder().Bold("--- Autocomplete overlay ---").Build()); +t.Scrollback.Append(Line.Bold("--- Autocomplete overlay ---")); AutocompleteCandidate[] candidates = [ @@ -125,15 +119,15 @@ await Task.Delay(TimeSpan.FromMilliseconds(1500)); t.Autocomplete.Hide(); -t.Scrollback.Append(new LineBuilder().Dim(" Autocomplete dismissed.").Build()); +t.Scrollback.Append(Line.Dim(" Autocomplete dismissed.")); await Task.Delay(TimeSpan.FromMilliseconds(300)); // ── Phase 5: Wizard chain (Select → Input → MultiSelect → Choice) ──────────── // Dialogs require keyboard input to submit. The demo auto-cancels each via a // CancellationTokenSource timeout so the tour is fully self-driving. -t.Status.SetRows(new LineBuilder().Dim("Phase 5/6 -- wizard chain (auto-cancels after 1.5s each)").Build()); -t.Scrollback.Append(new LineBuilder().Bold("--- Wizard chain ---").Build()); +t.Status.SetRows(Line.Dim("Phase 5/6 -- wizard chain (auto-cancels after 1.5s each)")); +t.Scrollback.Append(Line.Bold("--- Wizard chain ---")); // 5a: Select { @@ -143,11 +137,11 @@ new SelectRequest( Items: [ - new LineBuilder().Fg("C#", Color.Named(Color.AnsiColor.Cyan)).Build(), - new LineBuilder().Fg("Go", Color.Named(Color.AnsiColor.Yellow)).Build(), - new LineBuilder().Fg("Rust", Color.Named(Color.AnsiColor.Red)).Build(), + Line.Fg("C#", Color.Named(Color.AnsiColor.Cyan)), + Line.Fg("Go", Color.Named(Color.AnsiColor.Yellow)), + Line.Fg("Rust", Color.Named(Color.AnsiColor.Red)), ], - Title: new LineBuilder().Bold("Pick your favourite language").Build()), + Title: Line.Bold("Pick your favourite language")), cts.Token); string langText = lang.Outcome == DialogOutcome.Submitted @@ -167,7 +161,7 @@ DialogResult name = await t.InputAsync( new InputRequest( - Prompt: new LineBuilder().Bold("What's your name?").Build(), + Prompt: Line.Bold("What's your name?"), Default: "ada"), cts.Token); @@ -195,7 +189,7 @@ Line.FromText("Autocomplete overlay"), Line.FromText("Dialog wizard chain"), ], - Title: new LineBuilder().Bold("Which features interest you?").Build()), + Title: Line.Bold("Which features interest you?")), cts.Token); string featText = features.Outcome == DialogOutcome.Submitted @@ -218,13 +212,13 @@ new ChoiceRequest( Options: [ - new LineBuilder().Fg("Yes", Color.Named(Color.AnsiColor.Green)).Build(), - new LineBuilder().Fg("No", Color.Named(Color.AnsiColor.Red)).Build(), + Line.Fg("Yes", Color.Named(Color.AnsiColor.Green)), + Line.Fg("No", Color.Named(Color.AnsiColor.Red)), ], Prompt: [ - new LineBuilder().Bold("Run the tour again?").Build(), - new LineBuilder().Dim("Arrow keys to navigate; Enter to confirm.").Build(), + Line.Bold("Run the tour again?"), + Line.Dim("Arrow keys to navigate; Enter to confirm."), ]), cts.Token); @@ -241,14 +235,10 @@ // ── Phase 6: Finale (~3s) ──────────────────────────────────────────────────── -t.Status.SetRows(new LineBuilder() - .Fg("DONE - Tour complete - press Ctrl+C to exit, or wait 3s.", Color.Named(Color.AnsiColor.Green)) - .Build()); +t.Status.SetRows(Line.Fg("DONE - Tour complete - press Ctrl+C to exit, or wait 3s.", Color.Named(Color.AnsiColor.Green))); -t.Scrollback.Append(new LineBuilder().Bold("--- Tour complete ---").Build()); -t.Scrollback.Append(new LineBuilder() - .Fg("All dcli public surfaces exercised successfully.", Color.Named(Color.AnsiColor.BrightGreen)) - .Build()); +t.Scrollback.Append(Line.Bold("--- Tour complete ---")); +t.Scrollback.Append(Line.Fg("All dcli public surfaces exercised successfully.", Color.Named(Color.AnsiColor.BrightGreen))); await Task.Delay(TimeSpan.FromSeconds(3)); diff --git a/src/Dcli.Testing/Dcli.Testing.csproj b/src/Dcli.Testing/Dcli.Testing.csproj index 48c91c1..81e0452 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.2 + 0.2.0-rc.3 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 7812795..3d0677f 100644 --- a/src/Dcli/Dcli.csproj +++ b/src/Dcli/Dcli.csproj @@ -9,7 +9,7 @@ true dcli - 0.2.0-rc.2 + 0.2.0-rc.3 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. diff --git a/src/Dcli/DialogRequests.cs b/src/Dcli/DialogRequests.cs index 4fadfab..67dc870 100644 --- a/src/Dcli/DialogRequests.cs +++ b/src/Dcli/DialogRequests.cs @@ -130,7 +130,16 @@ private static List ConvertItems(IReadOnlyList items) /// When or empty, no preamble row is painted and the full overlay /// budget is available to the list. Single-line forms (see convenience constructors) are /// internally equivalent to a one-element list. -public sealed record MultiSelectRequest(IReadOnlyList Items, IReadOnlyList? Title = null) +/// +/// When , pressing [ at any time (including after toggling items +/// with Space) closes the dialog with . +/// Backspace is intentionally NOT bound for multi-select: because Space toggles items, +/// Backspace-at-empty is ambiguous and was never shipped. Use [ for unambiguous +/// back-navigation in wizard flows. Defaults to ; existing callers +/// are unaffected. When , [ has no effect and the dialog +/// remains open — v1 callers that omit this parameter are unaffected. +/// +public sealed record MultiSelectRequest(IReadOnlyList Items, IReadOnlyList? Title = null, bool AllowBack = false) { /// /// Constructs a with a single title. @@ -142,13 +151,19 @@ public sealed record MultiSelectRequest(IReadOnlyList Items, IReadOnlyList /// /// The list items to display. /// Optional single-line preamble; means no preamble. - public MultiSelectRequest(IReadOnlyList Items, Line? Title) - : this(Items, Title is null ? null : (IReadOnlyList)[Title]) { } + /// + /// When , [ closes the dialog with + /// at any time. Defaults to . + /// + public MultiSelectRequest(IReadOnlyList Items, Line? Title, bool AllowBack = false) + : this(Items, Title is null ? null : (IReadOnlyList)[Title], AllowBack) { } /// /// Constructs a with multiple preamble /// entries supplied as a array. Each line is painted in order /// above the list. No implicit conversion is defined; pass lines explicitly. + /// AllowBack cannot be set via this constructor because must + /// be the last parameter; use the primary constructor or another overload. /// /// The list items to display. /// Preamble lines in top-to-bottom order (may be empty). @@ -164,8 +179,12 @@ public MultiSelectRequest(IReadOnlyList items, params Line[] title) /// /// The plain-text items to display. /// Optional multi-line string preamble; means no preamble. - public MultiSelectRequest(IReadOnlyList items, IReadOnlyList? title) - : this(ConvertItems(items), ConvertPreamble(title)) { } + /// + /// When , [ closes the dialog with + /// at any time. Defaults to . + /// + public MultiSelectRequest(IReadOnlyList items, IReadOnlyList? title, bool allowBack = false) + : this(ConvertItems(items), ConvertPreamble(title), allowBack) { } /// /// Constructs a from plain-text item strings with an @@ -175,14 +194,20 @@ public MultiSelectRequest(IReadOnlyList items, IReadOnlyList? ti /// /// The plain-text items to display. /// Optional leading title row. - public MultiSelectRequest(IReadOnlyList items, Line? title = null) - : this(ConvertItems(items), title) { } + /// + /// When , [ closes the dialog with + /// at any time. Defaults to . + /// + public MultiSelectRequest(IReadOnlyList items, Line? title = null, bool allowBack = false) + : this(ConvertItems(items), title, allowBack) { } /// /// Constructs a from plain-text item strings with a /// string preamble. Each preamble string is converted via /// . /// Shorthand for inline multi-line preambles without pre-building a list. + /// AllowBack cannot be set via this constructor because must + /// be the last parameter; use the primary constructor or another overload. /// /// The list items to display. /// Preamble strings in top-to-bottom order (may be empty). @@ -192,6 +217,8 @@ public MultiSelectRequest(IReadOnlyList items, params string[] title) /// /// Constructs a from a params array of plain-text item strings. /// Shorthand equivalent to passing items.Select(Line.FromText).ToList() as Items. + /// AllowBack cannot be set via this constructor because must + /// be the last parameter; use the primary constructor or another overload. /// /// The plain-text items to display. public MultiSelectRequest(params string[] items) @@ -203,8 +230,12 @@ public MultiSelectRequest(params string[] items) /// /// The list items to display. /// Optional plain-text title string; means no preamble. - public MultiSelectRequest(IReadOnlyList items, string? title) - : this(items, title is null ? null : (IReadOnlyList)[Line.FromText(title)]) { } + /// + /// When , [ closes the dialog with + /// at any time. Defaults to . + /// + public MultiSelectRequest(IReadOnlyList items, string? title, bool allowBack = false) + : this(items, title is null ? null : (IReadOnlyList)[Line.FromText(title)], allowBack) { } private static List ConvertItems(IReadOnlyList items) { diff --git a/src/Dcli/ITerminal.cs b/src/Dcli/ITerminal.cs index cbb9ea4..f7df0aa 100644 --- a/src/Dcli/ITerminal.cs +++ b/src/Dcli/ITerminal.cs @@ -41,6 +41,16 @@ public interface IScrollback /// The lines revealed when the block is expanded. /// A handle whose posts a command to the render loop. ICollapsible BeginCollapsible(Line summary, IReadOnlyList hiddenLines); + + /// + /// Appends a horizontal rule to the scrollback live window. + /// + /// + /// The rule spans the full live-window content width at paint time using U+2500 BOX DRAWINGS + /// LIGHT HORIZONTAL characters. Width is resolved at render time, so the rule expands or + /// contracts correctly after a terminal resize. + /// + void AppendRule(); } /// diff --git a/src/Dcli/Internal/FixedRegion/Autocomplete.cs b/src/Dcli/Internal/FixedRegion/Autocomplete.cs index 58826f2..831c064 100644 --- a/src/Dcli/Internal/FixedRegion/Autocomplete.cs +++ b/src/Dcli/Internal/FixedRegion/Autocomplete.cs @@ -113,6 +113,14 @@ public bool HandleKey(KeyEvent key) } } + /// + /// + /// Autocomplete is non-modal: the caret stays in the base input editor while the dropdown + /// is visible, so paste belongs to the base editor. Returning lets + /// the resulting re-drive autocomplete as normal. + /// + public bool HandlePaste(string text) => false; + /// public IReadOnlyList Render(int width) => _list.Render(width); diff --git a/src/Dcli/Internal/FixedRegion/Dialog.cs b/src/Dcli/Internal/FixedRegion/Dialog.cs index 5ab1177..16ed807 100644 --- a/src/Dcli/Internal/FixedRegion/Dialog.cs +++ b/src/Dcli/Internal/FixedRegion/Dialog.cs @@ -53,9 +53,12 @@ internal sealed class Dialog : IModalOverlay /// List.MaxRows is reduced accordingly so the total output never exceeds the budget. /// /// - /// When , Backspace closes the dialog with - /// provided the user has not yet moved the selection - /// cursor and the filter text is empty. Defaults to . + /// When , [ closes the dialog with + /// at any time for multi-select (toggling items with + /// Space does not disarm it). For single-select/choice, [ is additionally accepted as + /// a secondary Back key subject to the same movement-suppression as Backspace (only before + /// the selection cursor has moved and the filter is empty). Backspace also fires Back for + /// single-select/choice under those same conditions. Defaults to . /// internal Dialog(bool multiSelect = false, bool modal = true, bool typeToFilter = false, IReadOnlyList? title = null, bool allowBack = false) { @@ -110,6 +113,12 @@ public int MaxRows /// has not yet moved the selection cursor, and is empty → /// = Back; consumed. (Does not fire when the cursor has moved or /// filter text is present, so it cannot mask the type-to-filter Backspace-trim behaviour.) + /// [ (U+005B) when constructed with allowBack: true and + /// is empty → = Back; consumed. + /// For multi-select ( is ), + /// fires at any time (Space-toggle does not disarm it). For single-select/choice, + /// additionally requires that the selection cursor has not yet moved (same movement- + /// suppression as Backspace). /// / → navigate the list (sets the internal moved flag); consumed. /// Space (U+0020) when → toggle current; consumed. /// Printable rune (≥ U+0020, ≠ U+007F) when → append to ; consumed. Backspace → trim ; consumed. @@ -143,6 +152,20 @@ public bool HandleKey(KeyEvent key) return true; } + // 3.5. '['-Back: fires when AllowBack is true and filter text is empty. For multi-select, + // fires at any time — Space-toggle does not disarm it. For single-select/choice, + // additionally requires the cursor has not yet moved (matching the movement-suppression + // of the Backspace-Back branch above). Placed before type-to-filter so '[' cannot be + // appended to the filter string when it should produce Back. + if (key.Code.Kind == KeyCode.KeyCodeKind.UnicodeScalar && + key.Code.RuneValue.Value == '[' && + _allowBack && _filterText.Length == 0 && + (List.MultiSelect || !_hasMoved)) + { + CloseRequest = OverlayCloseKind.Back; + return true; + } + // 4. Arrow navigation (sets _hasMoved so the Back branch above is no longer eligible) if (key.Code.Kind == KeyCode.KeyCodeKind.Named && key.Code.NamedValue == NamedKey.Up) { @@ -195,6 +218,12 @@ public bool HandleKey(KeyEvent key) return false; } + /// + /// + /// A modal dialog captures all input including paste. A non-modal dialog passes paste through. + /// + public bool HandlePaste(string text) => Modal; + /// /// /// When is non-null and non-empty, its lines are prepended above the list diff --git a/src/Dcli/Internal/FixedRegion/IOverlay.cs b/src/Dcli/Internal/FixedRegion/IOverlay.cs index be688a3..01d7241 100644 --- a/src/Dcli/Internal/FixedRegion/IOverlay.cs +++ b/src/Dcli/Internal/FixedRegion/IOverlay.cs @@ -80,6 +80,16 @@ internal interface IOverlay /// bool HandleKey(KeyEvent key); + /// + /// Attempts to handle a paste event. + /// + /// The pasted text. + /// + /// if the overlay consumed the paste and the event must not be forwarded + /// to the base editor; if the paste falls through to the next handler. + /// + bool HandlePaste(string text); + /// /// Renders the overlay rows at the given terminal width. /// Delegates to the hosted . diff --git a/src/Dcli/Internal/FixedRegion/InputDialog.cs b/src/Dcli/Internal/FixedRegion/InputDialog.cs index 3a4e27e..d5ed019 100644 --- a/src/Dcli/Internal/FixedRegion/InputDialog.cs +++ b/src/Dcli/Internal/FixedRegion/InputDialog.cs @@ -171,6 +171,14 @@ public bool HandleKey(KeyEvent key) return true; } + /// + public bool HandlePaste(string text) + { + _userEdited = true; + _buffer.Insert(text); + return true; + } + /// /// /// When is non- and non-empty, its lines are diff --git a/src/Dcli/Internal/RenderLoop/LoopEngine.cs b/src/Dcli/Internal/RenderLoop/LoopEngine.cs index add20a9..ab9e04b 100644 --- a/src/Dcli/Internal/RenderLoop/LoopEngine.cs +++ b/src/Dcli/Internal/RenderLoop/LoopEngine.cs @@ -400,8 +400,6 @@ private void ApplyInputEvent(InputEvent ev) // Emit InputChanged when the editor text changed due to user input. // This covers Insert, Backspace, Delete, and history recall (Up/Down), // but excludes pure caret movement (Left/Right/Home/End/MoveUp/MoveDown). - // PasteEvent is not currently routed to the editor — wiring paste is out of - // Chunk C scope; paste would need a separate InputChanged trigger when added. if (consumed && editor.Text != textBefore) _outbound.Writer.TryWrite(new InputChanged(editor.Text)); } @@ -441,6 +439,31 @@ private void ApplyInputEvent(InputEvent ev) _snapshotRows = re.Rows; _outbound.Writer.TryWrite(new Resized(re.Columns, re.Rows)); break; + + case PasteEvent pe: + { + // Intercept-chain: active overlay is the FRONT (first refusal). + bool pasteConsumed = false; + if (_model.ActiveOverlay is { } pasteOverlay) + { + pasteConsumed = pasteOverlay.HandlePaste(pe.Text); + if (pasteOverlay.IsDismissed) + { + _model.PendingModalCompletion?.Invoke(); + _model.ClearOverlay(); + } + } + + if (!pasteConsumed) + { + TextBuffer editor = _model.FixedRegion.Editor; + string textBefore = editor.Text; + editor.Insert(pe.Text); + if (editor.Text != textBefore) + _outbound.Writer.TryWrite(new InputChanged(editor.Text)); + } + } + break; } } diff --git a/src/Dcli/Internal/Scrollback/Collapsible.cs b/src/Dcli/Internal/Scrollback/Collapsible.cs index f46a755..fc34122 100644 --- a/src/Dcli/Internal/Scrollback/Collapsible.cs +++ b/src/Dcli/Internal/Scrollback/Collapsible.cs @@ -11,6 +11,12 @@ namespace Dcli.Internal.Scrollback; /// collapse operation — the spec forbids re-collapse. /// /// +/// Incremental append: while still collapsed-and-live, callers may append +/// lines to the hidden set via . The caller +/// () guarantees this is invoked only while the collapsible is +/// collapsed and present in the live list. +/// +/// /// Oversized expansion: if the expanded height would exceed the live-window /// cap the expansion is handled by instead /// of being performed inline. The collapsible stays a collapsed marker in the live list and @@ -30,7 +36,8 @@ namespace Dcli.Internal.Scrollback; internal sealed class Collapsible : ILineObject { private readonly Line _summary; - private readonly IReadOnlyList _hiddenLines; + // Owned mutable copy — callers cannot mutate the original list after construction. + private readonly List _hiddenLines; /// Whether has been called (successfully). internal bool IsExpanded { get; private set; } @@ -39,12 +46,25 @@ internal sealed class Collapsible : ILineObject /// Initialises a new in the collapsed state. /// /// The single summary line shown while collapsed. - /// The lines revealed on expansion. + /// The lines revealed on expansion. Copied at construction so + /// off-thread mutations to the original list cannot affect this object. internal Collapsible(Line summary, IReadOnlyList hiddenLines) { ArgumentNullException.ThrowIfNull(hiddenLines); _summary = summary; - _hiddenLines = hiddenLines; + _hiddenLines = hiddenLines.ToList(); + } + + /// + /// Appends to the hidden-line set. + /// + /// + /// The caller () guarantees this is invoked only while the + /// block is collapsed and present in the live list. Guards live in the model, not here. + /// + internal void AppendHidden(Line line) + { + _hiddenLines.Add(line); } /// diff --git a/src/Dcli/Internal/Scrollback/RuleBlock.cs b/src/Dcli/Internal/Scrollback/RuleBlock.cs new file mode 100644 index 0000000..67e53d4 --- /dev/null +++ b/src/Dcli/Internal/Scrollback/RuleBlock.cs @@ -0,0 +1,27 @@ +namespace Dcli.Internal.Scrollback; + +/// +/// An immutable line-object that renders as a horizontal rule spanning the full content width. +/// +/// +/// +/// Width is resolved at paint time — receives the current live-window +/// content width (in columns) and returns a single of U+2500 BOX DRAWINGS +/// LIGHT HORIZONTAL characters. This means the rule expands or contracts correctly after a +/// terminal resize without any stored fixed width. +/// +/// +/// Like , this block performs no wrapping — a single row is always +/// returned regardless of width. +/// +/// +internal sealed class RuleBlock : ILineObject +{ + /// + public IReadOnlyList Render(int width) + { + int w = Math.Max(1, width); + Segment rule = new(new string('─', w)); + return [new Line([rule])]; + } +} diff --git a/src/Dcli/Internal/Scrollback/ScrollbackModel.cs b/src/Dcli/Internal/Scrollback/ScrollbackModel.cs index 6f1aee6..116b511 100644 --- a/src/Dcli/Internal/Scrollback/ScrollbackModel.cs +++ b/src/Dcli/Internal/Scrollback/ScrollbackModel.cs @@ -92,6 +92,29 @@ internal Collapsible BeginCollapsible(Line summary, IReadOnlyList hiddenLi return collapsible; } + /// + /// Appends to the hidden-line set of a + /// that is still collapsed-and-live. No-op if the collapsible has already expanded or + /// frozen past the commit horizon (mirroring the guard precedence of + /// ). + /// + internal void AppendToCollapsible(Collapsible collapsible, Line line, RenderModel model) + { + ArgumentNullException.ThrowIfNull(collapsible); + ArgumentNullException.ThrowIfNull(line); + + // Freeze-collapsed-at-horizon: already committed → not in live list → no-op. + if (!_liveObjects.Contains(collapsible)) + return; + + // Already expanded → no-op. + if (collapsible.IsExpanded) + return; + + collapsible.AppendHidden(line); + model.MarkDirty(); + } + /// /// Expands a that is still in the live list. /// diff --git a/src/Dcli/Line.cs b/src/Dcli/Line.cs index 65aad18..7244fa6 100644 --- a/src/Dcli/Line.cs +++ b/src/Dcli/Line.cs @@ -59,6 +59,120 @@ public Line(IEnumerable segments) public static Line FromText(string text, Style? style = null) => new(new[] { new Segment(text ?? throw new ArgumentNullException(nameof(text)), style ?? default) }); + /// + /// Shorthand for a single bold line — equivalent to + /// Line.FromText(text, new Style(Format: Format.Bold)). + /// + /// The text is routed through the ordinary sanitizing constructor: + /// control/escape bytes are stripped (or replaced) exactly as they would be under + /// . + /// + /// + /// Only four single-style shorthands exist on : , + /// , , and . There is deliberately no + /// Line.Italic, Line.Underline, Line.Reverse, + /// Line.Strikethrough, or Line.Raw factory. and + /// remain the only verbatim/escape seams. + /// No implicit conversion is defined. + /// + /// + /// The text content. Must not be . + /// + /// A containing a single bold . + /// + /// + /// Thrown when is . + /// + public static Line Bold(string text) => + FromText(text, new Style(Format: Format.Bold)); + + /// + /// Shorthand for a single dim line — equivalent to + /// Line.FromText(text, new Style(Format: Format.Dim)). + /// + /// The text is routed through the ordinary sanitizing constructor: + /// control/escape bytes are stripped (or replaced) exactly as they would be under + /// . + /// + /// + /// Only four single-style shorthands exist on : , + /// , , and . There is deliberately no + /// Line.Italic, Line.Underline, Line.Reverse, + /// Line.Strikethrough, or Line.Raw factory. and + /// remain the only verbatim/escape seams. + /// No implicit conversion is defined. + /// + /// + /// The text content. Must not be . + /// + /// A containing a single dim . + /// + /// + /// Thrown when is . + /// + public static Line Dim(string text) => + FromText(text, new Style(Format: Format.Dim)); + + /// + /// Shorthand for a single foreground-colored line — equivalent to + /// Line.FromText(text, new Style(Foreground: foreground)). + /// No format flags are set; is . + /// + /// The text is routed through the ordinary sanitizing constructor: + /// control/escape bytes are stripped (or replaced) exactly as they would be under + /// . + /// + /// + /// Only four single-style shorthands exist on : , + /// , , and . There is deliberately no + /// Line.Italic, Line.Underline, Line.Reverse, + /// Line.Strikethrough, or Line.Raw factory. and + /// remain the only verbatim/escape seams. + /// No implicit conversion is defined. + /// + /// + /// The text content. Must not be . + /// The foreground color to apply. + /// + /// A containing a single foreground-colored + /// with . + /// + /// + /// Thrown when is . + /// + public static Line Fg(string text, Color foreground) => + FromText(text, new Style(Foreground: foreground)); + + /// + /// Shorthand for a single background-colored line — equivalent to + /// Line.FromText(text, new Style(Background: background)). + /// No format flags are set; is . + /// + /// The text is routed through the ordinary sanitizing constructor: + /// control/escape bytes are stripped (or replaced) exactly as they would be under + /// . + /// + /// + /// Only four single-style shorthands exist on : , + /// , , and . There is deliberately no + /// Line.Italic, Line.Underline, Line.Reverse, + /// Line.Strikethrough, or Line.Raw factory. and + /// remain the only verbatim/escape seams. + /// No implicit conversion is defined. + /// + /// + /// The text content. Must not be . + /// The background color to apply. + /// + /// A containing a single background-colored + /// with . + /// + /// + /// Thrown when is . + /// + public static Line Bg(string text, Color background) => + FromText(text, new Style(Background: background)); + /// /// Returns when contains exactly the same /// segments in the same order. diff --git a/src/Dcli/ScrollbackSurface.cs b/src/Dcli/ScrollbackSurface.cs index 74a3554..1f04246 100644 --- a/src/Dcli/ScrollbackSurface.cs +++ b/src/Dcli/ScrollbackSurface.cs @@ -46,10 +46,11 @@ public interface ILiveBlock /// /// /// Obtain a handle via . -/// The underlying Collapsible object is pre-created on the calling thread with -/// immutable summary and hiddenLines; only the loop thread mutates its expanded -/// state. Incremental append to the hidden-line list after construction is a documented gap — -/// the model stores hidden lines as an immutable snapshot at construction time. +/// The underlying Collapsible object is pre-created on the calling thread; only the +/// loop thread mutates its state. Incremental append to the hidden-line set is supported via +/// / while the block remains +/// collapsed-and-live. Once expanded or frozen past the commit horizon, further +/// AppendLine calls are no-ops. /// public interface ICollapsible { @@ -60,6 +61,22 @@ public interface ICollapsible /// expanding in-place. /// void Expand(); + + /// + /// Appends to the collapsible's hidden-line set. + /// Honored only while the block is still collapsed-and-live; a no-op once expanded or + /// frozen past the commit horizon. + /// + void AppendLine(Line line); + + /// + /// Appends a plain-text line (via ) to the collapsible's + /// hidden-line set. Equivalent to with + /// Line.FromText(). + /// Honored only while the block is still collapsed-and-live; a no-op once expanded or + /// frozen past the commit horizon. + /// + void AppendLine(string text); } // ───────────────────────────────────────────────────────────────────────────── @@ -79,13 +96,6 @@ public interface ICollapsible /// they post to the loop's inbound channel. The render-loop thread is the sole mutator of /// scrollback state. /// -/// -/// Documented gaps: -/// -/// AppendRule — needs a width-aware rule line-object that does not yet exist; -/// deferred to a future change. -/// -/// /// public sealed class ScrollbackSurface : IScrollback { @@ -115,6 +125,14 @@ public void Append(string text) Append(Line.FromText(text)); } + /// + /// Appends a horizontal rule to the scrollback live window. + /// + public void AppendRule() + { + _loop.Post(new AppendRuleToScrollbackCommand()); + } + /// /// Begins a new live block in the scrollback, returning a handle for incremental mutation. /// @@ -132,14 +150,15 @@ public ILiveBlock BeginLive() } /// - /// Begins a new collapsible block, returning a handle for one-time expansion. + /// Begins a new collapsible block, returning a handle for one-time expansion and + /// incremental append. /// /// The summary line shown while the block is collapsed. - /// The lines revealed when the block is expanded. + /// The initial lines revealed when the block is expanded. /// - /// Hidden lines are captured as an immutable snapshot at construction time. Incremental - /// append to the hidden-line list after the handle is created is a documented gap for a - /// future refinement. + /// Hidden lines are copied at construction time. Additional lines may be appended via + /// while the block remains collapsed-and-live; + /// those calls are no-ops once the block has been expanded or frozen past the commit horizon. /// public ICollapsible BeginCollapsible(Line summary, IReadOnlyList hiddenLines) { @@ -196,6 +215,18 @@ public void Expand() { _loop.Post(new ExpandCollapsibleFacadeCommand(_collapsible)); } + + public void AppendLine(Line line) + { + ArgumentNullException.ThrowIfNull(line); + _loop.Post(new AppendLineToCollapsibleFacadeCommand(_collapsible, line)); + } + + public void AppendLine(string text) + { + ArgumentNullException.ThrowIfNull(text); + AppendLine(Line.FromText(text)); + } } // ── Façade commands (obtain scrollback from model.Scrollback in Apply) ──── @@ -213,6 +244,15 @@ void ILoopCommand.Apply(RenderModel model) } } + private sealed class AppendRuleToScrollbackCommand : ILoopCommand + { + void ILoopCommand.Apply(RenderModel model) + { + model.Scrollback.Append(new RuleBlock(), model); + model.MarkDirty(); + } + } + private sealed class InsertLiveBlockCommand : ILoopCommand { private readonly LiveBlock _block; @@ -298,4 +338,21 @@ void ILoopCommand.Apply(RenderModel model) model.Scrollback.ExpandCollapsible(_collapsible, model); } } + + private sealed class AppendLineToCollapsibleFacadeCommand : ILoopCommand + { + private readonly Collapsible _collapsible; + private readonly Line _line; + + internal AppendLineToCollapsibleFacadeCommand(Collapsible collapsible, Line line) + { + _collapsible = collapsible; + _line = line; + } + + void ILoopCommand.Apply(RenderModel model) + { + model.Scrollback.AppendToCollapsible(_collapsible, _line, model); + } + } } diff --git a/src/Dcli/Terminal.cs b/src/Dcli/Terminal.cs index 7320ba3..5f79b9b 100644 --- a/src/Dcli/Terminal.cs +++ b/src/Dcli/Terminal.cs @@ -165,7 +165,7 @@ public Task> MultiSelectAsync( CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(req); - Dialog dialog = new(multiSelect: true, modal: true, typeToFilter: false, title: req.Title); + Dialog dialog = new(multiSelect: true, modal: true, typeToFilter: false, title: req.Title, allowBack: req.AllowBack); dialog.List.SetItems(req.Items); return OpenModalAsync( dialog, diff --git a/tests/Dcli.Tests/ChoiceDialogTests.cs b/tests/Dcli.Tests/ChoiceDialogTests.cs index 07abc7a..c819234 100644 --- a/tests/Dcli.Tests/ChoiceDialogTests.cs +++ b/tests/Dcli.Tests/ChoiceDialogTests.cs @@ -1,3 +1,4 @@ +using System.Text; using Dcli.Internal.FixedRegion; using Dcli.Internal.RenderLoop; using Dcli.Testing; @@ -241,6 +242,56 @@ public async Task ChoiceAllowBackFalseDefaultBackspaceIsNoOp() finally { engine.Dispose(); } } + // ── §5.5 — Choice AllowBack=true accepts '[' as secondary Back key ─────────── + + /// + /// §5.5 — Choice AllowBack=true + '[' before moving → DialogOutcome.Back. + /// + [Fact] + public async Task ChoiceAllowBackBracketBeforeMovingReturnsBack() + { + (LoopEngine engine, VirtualClock clock) = CreateEngine(); + try + { + Task> task = PostChoiceDialogAllowBack(engine, Options("Yes", "No", "Maybe")); + + engine.InputWriter.TryWrite(new KeyEvent(KeyCode.FromRune(new Rune('[')), Modifiers.None)); + + await SettleAsync(engine, clock); + DialogResult result = await task; + + Assert.Equal(DialogOutcome.Back, result.Outcome); + } + finally { engine.Dispose(); } + } + + /// + /// §5.5 — Choice AllowBack=true + ↓ then '[' → NOT Back (movement-suppression applies to '['). + /// + [Fact] + public async Task ChoiceAllowBackBracketAfterMovementIsNoOp() + { + (LoopEngine engine, VirtualClock clock) = CreateEngine(); + try + { + Task> task = PostChoiceDialogAllowBack(engine, Options("Yes", "No", "Maybe")); + + engine.InputWriter.TryWrite(new KeyEvent(KeyCode.Named(NamedKey.Down), Modifiers.None)); + engine.InputWriter.TryWrite(new KeyEvent(KeyCode.FromRune(new Rune('[')), Modifiers.None)); + + await SettleAsync(engine, clock); + + Assert.False(task.IsCompleted, "Choice dialog should still be open after '[' post-movement"); + + engine.InputWriter.TryWrite(new KeyEvent(KeyCode.Named(NamedKey.Escape), Modifiers.None)); + await SettleAsync(engine, clock); + + DialogResult result = await task; + Assert.Equal(DialogOutcome.Cancelled, result.Outcome); + } + finally { engine.Dispose(); } + } + // ── §3.3 — Multi-line Prompt on ChoiceRequest renders all lines above options ─ // Spec scenario: "Multi-line preamble renders all lines above the widget" diff --git a/tests/Dcli.Tests/CollapsibleAppendTests.cs b/tests/Dcli.Tests/CollapsibleAppendTests.cs new file mode 100644 index 0000000..cd79f40 --- /dev/null +++ b/tests/Dcli.Tests/CollapsibleAppendTests.cs @@ -0,0 +1,251 @@ +using Dcli.Internal.RenderLoop; +using Dcli.Internal.Scrollback; +using Xunit; + +namespace Dcli.Tests; + +/// +/// Behavioural tests for §3 (api-ergonomics-pass-2): incremental AppendLine on +/// / / . +/// All tests operate purely on the model (no real terminal, no loop thread). +/// +public sealed class CollapsibleAppendTests +{ + // ─── Helpers ───────────────────────────────────────────────────────────── + + private static RenderModel MakeModel(int cols = 80, int rows = 24) => + new(new FixedSizeSource(cols, rows)); + + private static Line PlainLine(string text) => + new([new Segment(text)]); + + private static string JoinRows(IEnumerable rows) => + string.Join("\n", rows.Select(r => string.Concat(r.Segments.Select(s => s.Text)))); + + private sealed class FixedSizeSource(int cols, int rows) : ITerminalSizeSource + { + public (int Columns, int Rows) GetSize() => (cols, rows); + } + + // ─── 3.4: Append before expansion → revealed on expand ─────────────────── + + /// + /// AppendLine before Expand adds the line to the hidden set; after Expand the live window + /// shows original hidden lines followed by the appended line, in order. + /// + [Fact] + public void AppendBeforeExpansionGrowsHiddenSetRevealedOnExpand() + { + // rows=24 → maxHeight=24; hidden set fits. + RenderModel model = MakeModel(cols: 80, rows: 24); + ScrollbackModel sm = new(); + + Collapsible c = sm.BeginCollapsible( + PlainLine("summary"), + [PlainLine("original-h1"), PlainLine("original-h2")], + model); + + // Append before expansion. + sm.AppendToCollapsible(c, PlainLine("appended-h3"), model); + + // Expand (normal path — fits live window). + sm.ExpandCollapsible(c, model); + Assert.True(c.IsExpanded); + + sm.PrePaint(model); + + string liveText = JoinRows(model.LiveWindowRows); + // All four rows must appear in order. + int idxSummary = liveText.IndexOf("summary", StringComparison.Ordinal); + int idxOrigH1 = liveText.IndexOf("original-h1", StringComparison.Ordinal); + int idxOrigH2 = liveText.IndexOf("original-h2", StringComparison.Ordinal); + int idxAppended = liveText.IndexOf("appended-h3", StringComparison.Ordinal); + + Assert.True(idxSummary >= 0, "summary must appear"); + Assert.True(idxOrigH1 >= 0, "original-h1 must appear"); + Assert.True(idxOrigH2 >= 0, "original-h2 must appear"); + Assert.True(idxAppended >= 0, "appended-h3 must appear"); + + // Order: summary < original-h1 < original-h2 < appended-h3. + Assert.True(idxSummary < idxOrigH1, + "summary must precede original-h1"); + Assert.True(idxOrigH1 < idxOrigH2, + "original-h1 must precede original-h2"); + Assert.True(idxOrigH2 < idxAppended, + "original-h2 must precede appended-h3 (append order preserved)"); + } + + // ─── 3.5: Append after expansion is a no-op ────────────────────────────── + + /// + /// AppendLine after Expand is silently dropped; the revealed content is unchanged. + /// + [Fact] + public void AppendAfterExpansionIsNoOp() + { + RenderModel model = MakeModel(cols: 80, rows: 24); + ScrollbackModel sm = new(); + + Collapsible c = sm.BeginCollapsible( + PlainLine("summary"), + [PlainLine("hidden-original")], + model); + + // Expand first. + sm.ExpandCollapsible(c, model); + Assert.True(c.IsExpanded); + + sm.PrePaint(model); + string liveBeforeAppend = JoinRows(model.LiveWindowRows); + + // Now attempt to append — must be no-op. + sm.AppendToCollapsible(c, PlainLine("should-not-appear"), model); + + sm.PrePaint(model); + string liveAfterAppend = JoinRows(model.LiveWindowRows); + + Assert.DoesNotContain("should-not-appear", liveAfterAppend, StringComparison.Ordinal); + // Visible content is the same as before the append attempt. + Assert.Equal(liveBeforeAppend, liveAfterAppend); + } + + // ─── 3.6: Append after horizon-freeze is a no-op ───────────────────────── + + /// + /// AppendLine on a collapsible that has been committed past the horizon (dropped from the + /// live list while still collapsed) is silently dropped. + /// + [Fact] + public void AppendAfterHorizonFreezeIsNoOp() + { + // maxHeight=2. The collapsible (1 row) gets pushed past the horizon by 2 filler rows. + RenderModel model = MakeModel(cols: 80, rows: 2); + ScrollbackModel sm = new(); + + Collapsible c = sm.BeginCollapsible( + PlainLine("frozen-summary"), + [PlainLine("frozen-hidden")], + model); + + sm.Append(new TextBlock(PlainLine("filler1")), model); + sm.Append(new TextBlock(PlainLine("filler2")), model); + + // Force the collapsible past the horizon. + sm.PrePaint(model); + sm.ClearCommitted(model); + + // The collapsible is no longer in the live list. Append must be a no-op. + sm.AppendToCollapsible(c, PlainLine("post-freeze-append"), model); + + sm.PrePaint(model); + + string liveText = JoinRows(model.LiveWindowRows); + Assert.DoesNotContain("post-freeze-append", liveText, StringComparison.Ordinal); + Assert.DoesNotContain("frozen-hidden", liveText, StringComparison.Ordinal); + // Nothing extra committed by the append attempt. + Assert.Empty(model.NewlyCommittedRows); + } + + // ─── 3.7: Oversized-expansion ordering with prior AppendLine ───────────── + + /// + /// AppendLine before an oversized expansion does not reorder or duplicate hidden rows. + /// The committed flow contains original hidden lines followed by the appended line, in + /// append order — the natural ordering the oversized reprint would produce. + /// + [Fact] + public void AppendBeforeOversizedExpansionPreservesCommitOrdering() + { + // maxHeight=3. summary=1 row; original hidden=4 rows; appended=1 row → total hidden=5. + // Expanded height = 1 (summary) + 5 (hidden) = 6 > 3 → oversized path. + RenderModel model = MakeModel(cols: 80, rows: 3); + ScrollbackModel sm = new(); + + IReadOnlyList initialHidden = + [ + PlainLine("h1"), PlainLine("h2"), PlainLine("h3"), PlainLine("h4") + ]; + Collapsible c = sm.BeginCollapsible(PlainLine("summary"), initialHidden, model); + + // Append before expansion. + sm.AppendToCollapsible(c, PlainLine("h5-appended"), model); + + // Trigger oversized expansion. + sm.ExpandCollapsible(c, model); + + // Block must still be collapsed (oversized path does not call Expand()). + Assert.False(c.IsExpanded); + + sm.PrePaint(model); + + // Live window: only the summary (collapsed marker). + string liveText = JoinRows(model.LiveWindowRows); + Assert.Contains("summary", liveText, StringComparison.Ordinal); + Assert.DoesNotContain("h1", liveText, StringComparison.Ordinal); + + // Committed flow: all hidden rows in order (h1..h4 then h5-appended). + string committed = JoinRows(model.NewlyCommittedRows); + Assert.Contains("h1", committed, StringComparison.Ordinal); + Assert.Contains("h4", committed, StringComparison.Ordinal); + Assert.Contains("h5-appended", committed, StringComparison.Ordinal); + + // Verify natural ordering: h1 < h2 < h3 < h4 < h5-appended. + int idxH1 = committed.IndexOf("h1", StringComparison.Ordinal); + int idxH2 = committed.IndexOf("h2", StringComparison.Ordinal); + int idxH3 = committed.IndexOf("h3", StringComparison.Ordinal); + int idxH4 = committed.IndexOf("h4", StringComparison.Ordinal); + int idxH5 = committed.IndexOf("h5-appended", StringComparison.Ordinal); + + Assert.True(idxH1 < idxH2, "h1 before h2"); + Assert.True(idxH2 < idxH3, "h2 before h3"); + Assert.True(idxH3 < idxH4, "h3 before h4"); + Assert.True(idxH4 < idxH5, "h4 before h5-appended (appended row last)"); + + // Verify reprint is emitted only once (guard from existing oversized reprint test). + sm.ClearCommitted(model); + model.MarkDirty(); + sm.PrePaint(model); + Assert.Empty(model.NewlyCommittedRows); + } + + // ─── Collapsible.AppendHidden unit test ─────────────────────────────────── + + /// + /// adds to the hidden set visible after expansion. + /// + [Fact] + public void CollapsibleAppendHiddenAppearsAfterExpand() + { + Collapsible c = new(PlainLine("summary"), [PlainLine("h1")]); + c.AppendHidden(PlainLine("h2-appended")); + c.Expand(); + + IReadOnlyList rows = c.Render(80); + // summary + h1 + h2-appended = 3 rows. + Assert.Equal(3, rows.Count); + Assert.Equal("summary", rows[0].Segments[0].Text); + Assert.Equal("h1", rows[1].Segments[0].Text); + Assert.Equal("h2-appended", rows[2].Segments[0].Text); + } + + /// + /// The ctor makes an owned copy: mutating the caller's list + /// after construction does not affect the collapsible's hidden set. + /// + [Fact] + public void CollapsibleCtorCopiesHiddenLinesList() + { + List callerList = [PlainLine("original")]; + Collapsible c = new(PlainLine("summary"), callerList); + + // Mutate the original list after construction. + callerList.Add(PlainLine("injected-after-construction")); + + c.Expand(); + IReadOnlyList rows = c.Render(80); + + // Only summary + original; the post-construction add must NOT appear. + Assert.Equal(2, rows.Count); + Assert.DoesNotContain(rows, r => r.Segments.Any(s => s.Text.Contains("injected", StringComparison.Ordinal))); + } +} diff --git a/tests/Dcli.Tests/DialogSelectionTests.cs b/tests/Dcli.Tests/DialogSelectionTests.cs index 740814a..cb6677a 100644 --- a/tests/Dcli.Tests/DialogSelectionTests.cs +++ b/tests/Dcli.Tests/DialogSelectionTests.cs @@ -708,13 +708,206 @@ public async Task SelectAllowBackFalseDefaultBackspaceIsNoOp() finally { engine.Dispose(); } } - // ── 3.8 — MultiSelectRequest deliberately omits AllowBack ──────────────── + // ── §5 — MultiSelectRequest.AllowBack + '[' Back key ──────────────────────── + // Helper: post a multi-select dialog with AllowBack=true; completion maps Back correctly. + private static Task> PostMultiSelectDialogAllowBack( + LoopEngine engine, + List items) + { + Dialog dialog = new(multiSelect: true, modal: true, allowBack: true); + dialog.List.SetItems(items); + + TaskCompletionSource> tcs = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + Action completion = () => + { + DialogResult result = dialog.CloseRequest switch + { + OverlayCloseKind.Submit => new DialogResult(DialogOutcome.Submitted, [.. dialog.List.CheckedIndices]), + OverlayCloseKind.Back => new DialogResult(DialogOutcome.Back, []), + _ => new DialogResult(DialogOutcome.Cancelled, []), + }; + tcs.TrySetResult(result); + }; + + Action reject = () => + tcs.TrySetException(new InvalidOperationException("A dialog is already active.")); + + engine.Post(new OpenDialogCommand(dialog, completion, reject)); + return tcs.Task; + } + + /// + /// §5.1/5.4 — MultiSelect AllowBack=true + '[' → DialogOutcome.Back. + /// + [Fact] + public async Task MultiSelectAllowBackBracketReturnsBack() + { + (LoopEngine engine, VirtualClock clock, _) = CreateEngine(); + try + { + Task> task = PostMultiSelectDialogAllowBack(engine, Items("A", "B", "C")); + + engine.InputWriter.TryWrite(new KeyEvent(KeyCode.FromRune(new Rune('[')), Modifiers.None)); + + await SettleAsync(engine, clock); + DialogResult result = await task; + + Assert.Equal(DialogOutcome.Back, result.Outcome); + } + finally { engine.Dispose(); } + } + + /// + /// §5.4 — MultiSelect AllowBack=true: '[' still produces Back AFTER toggling items with Space. + /// Toggling does NOT disarm the '[' Back key. + /// + [Fact] + public async Task MultiSelectAllowBackBracketAfterSpaceToggleReturnsBack() + { + (LoopEngine engine, VirtualClock clock, _) = CreateEngine(); + try + { + Task> task = PostMultiSelectDialogAllowBack(engine, Items("Alpha", "Beta", "Gamma")); + + // Toggle the first item with Space, then press '['. + engine.InputWriter.TryWrite(new KeyEvent(KeyCode.FromRune(new Rune(' ')), Modifiers.None)); + engine.InputWriter.TryWrite(new KeyEvent(KeyCode.FromRune(new Rune('[')), Modifiers.None)); + + await SettleAsync(engine, clock); + DialogResult result = await task; + + Assert.Equal(DialogOutcome.Back, result.Outcome); + } + finally { engine.Dispose(); } + } + + /// + /// §5.4 — Regression guard: MultiSelect AllowBack=true: '[' still produces Back even when an + /// arrow key (↑ or ↓) is pressed FIRST. Multi-select does NOT apply movement-suppression on '[': + /// the predicate is List.MultiSelect || !_hasMoved, so movement never disarms Back for + /// multi-select. This test pins that contract against a future regression. + /// [Fact] - public void MultiSelectRequestDoesNotHaveAllowBackProperty() + public async Task MultiSelectAllowBackBracketAfterArrowMovementStillReturnsBack() { - // AllowBack is intentionally absent from MultiSelectRequest (design decision §3.8). - Assert.Null(typeof(MultiSelectRequest).GetProperty("AllowBack")); + (LoopEngine engine, VirtualClock clock, _) = CreateEngine(); + try + { + Task> task = PostMultiSelectDialogAllowBack(engine, Items("Alpha", "Beta", "Gamma")); + + // Move the cursor first (↓), then press '['. + engine.InputWriter.TryWrite(new KeyEvent(KeyCode.Named(NamedKey.Down), Modifiers.None)); + engine.InputWriter.TryWrite(new KeyEvent(KeyCode.FromRune(new Rune('[')), Modifiers.None)); + + await SettleAsync(engine, clock); + DialogResult result = await task; + + // Movement must NOT suppress '[' Back for multi-select. + Assert.Equal(DialogOutcome.Back, result.Outcome); + } + finally { engine.Dispose(); } + } + + /// + /// §5.4 — MultiSelect AllowBack=false (default): '[' has no Back effect; dialog stays open. + /// + [Fact] + public async Task MultiSelectAllowBackFalseDefaultBracketIsNoOp() + { + (LoopEngine engine, VirtualClock clock, _) = CreateEngine(); + try + { + // Use the standard helper (AllowBack=false by default). + Task> task = PostMultiSelectDialog(engine, Items("X", "Y", "Z")); + + engine.InputWriter.TryWrite(new KeyEvent(KeyCode.FromRune(new Rune('[')), Modifiers.None)); + + await SettleAsync(engine, clock); + + // Task must still be pending — '[' was swallowed by the modal catch-all. + Assert.False(task.IsCompleted, "Dialog should still be open after '[' when AllowBack=false"); + + // Submit to close cleanly. + engine.InputWriter.TryWrite(new KeyEvent(KeyCode.Named(NamedKey.Enter), Modifiers.None)); + await SettleAsync(engine, clock); + + DialogResult result = await task; + Assert.Equal(DialogOutcome.Submitted, result.Outcome); + } + finally { engine.Dispose(); } + } + + /// + /// §5.5 — Select AllowBack=true + '[' before moving → DialogOutcome.Back. + /// + [Fact] + public async Task SelectAllowBackBracketBeforeMovingReturnsBack() + { + (LoopEngine engine, VirtualClock clock, _) = CreateEngine(); + try + { + Task> task = PostSelectDialogAllowBack(engine, Items("A", "B", "C")); + + engine.InputWriter.TryWrite(new KeyEvent(KeyCode.FromRune(new Rune('[')), Modifiers.None)); + + await SettleAsync(engine, clock); + DialogResult result = await task; + + Assert.Equal(DialogOutcome.Back, result.Outcome); + } + finally { engine.Dispose(); } + } + + /// + /// §5.5 — Select AllowBack=true + ↓ then '[' → NOT Back (movement-suppression applies to '['). + /// + [Fact] + public async Task SelectAllowBackBracketAfterMovementIsNoOp() + { + (LoopEngine engine, VirtualClock clock, _) = CreateEngine(); + try + { + Task> task = PostSelectDialogAllowBack(engine, Items("A", "B", "C")); + + // ↓ marks _hasMoved = true; subsequent '[' must not close the dialog. + engine.InputWriter.TryWrite(new KeyEvent(KeyCode.Named(NamedKey.Down), Modifiers.None)); + engine.InputWriter.TryWrite(new KeyEvent(KeyCode.FromRune(new Rune('[')), Modifiers.None)); + + await SettleAsync(engine, clock); + + Assert.False(task.IsCompleted, "Dialog should still be open after '[' post-movement"); + + engine.InputWriter.TryWrite(new KeyEvent(KeyCode.Named(NamedKey.Escape), Modifiers.None)); + await SettleAsync(engine, clock); + + DialogResult result = await task; + Assert.Equal(DialogOutcome.Cancelled, result.Outcome); + } + finally { engine.Dispose(); } + } + + /// + /// §5.5 — Regression: Select AllowBack=true Backspace-Back still works after adding '['-Back. + /// + [Fact] + public async Task SelectAllowBackBackspaceStillWorksAfterBracketKeyAdded() + { + (LoopEngine engine, VirtualClock clock, _) = CreateEngine(); + try + { + Task> task = PostSelectDialogAllowBack(engine, Items("A", "B")); + + engine.InputWriter.TryWrite(new KeyEvent(KeyCode.Named(NamedKey.Backspace), Modifiers.None)); + + await SettleAsync(engine, clock); + DialogResult result = await task; + + Assert.Equal(DialogOutcome.Back, result.Outcome); + } + finally { engine.Dispose(); } } // ── §3.1 — Multi-line Title on SelectRequest renders all lines above items ─ diff --git a/tests/Dcli.Tests/FakeTerminalTests.cs b/tests/Dcli.Tests/FakeTerminalTests.cs index 52de19d..41c9efa 100644 --- a/tests/Dcli.Tests/FakeTerminalTests.cs +++ b/tests/Dcli.Tests/FakeTerminalTests.cs @@ -11,12 +11,13 @@ namespace Dcli.Tests; #region Fake sub-surfaces -/// Records every Append/BeginLive/BeginCollapsible call for assertion. +/// Records every Append/BeginLive/BeginCollapsible/AppendRule call for assertion. internal sealed class FakeScrollback : IScrollback { internal List Appended { get; } = []; internal int BeginLiveCount { get; private set; } internal List<(Line Summary, IReadOnlyList Hidden)> Collapsibles { get; } = []; + internal int AppendRuleCount { get; private set; } public void Append(Line line) { @@ -44,6 +45,11 @@ public ICollapsible BeginCollapsible(Line summary, IReadOnlyList hiddenLin return new NoOpCollapsible(); } + public void AppendRule() + { + AppendRuleCount++; + } + private sealed class NoOpLiveBlock : ILiveBlock { public void AppendText(string text) { } @@ -54,6 +60,8 @@ public void Commit() { } private sealed class NoOpCollapsible : ICollapsible { public void Expand() { } + public void AppendLine(Line line) { } + public void AppendLine(string text) { } } } diff --git a/tests/Dcli.Tests/InputDialogTests.cs b/tests/Dcli.Tests/InputDialogTests.cs index 2e21c27..2a2ec9b 100644 --- a/tests/Dcli.Tests/InputDialogTests.cs +++ b/tests/Dcli.Tests/InputDialogTests.cs @@ -727,6 +727,141 @@ public async Task InputRequestOversizedPreambleTruncatesAndKeepsWidgetVisible() await dialogTask; } + // ── §4 PasteEvent routing ───────────────────────────────────────────────── + + // 4.4 — Paste inserts text at the caret in the base editor and the caret advances. + [Fact] + public async Task PasteInsertsTextAtCaretInBaseEditor() + { + (LoopEngine engine, VirtualClock clock, CapturingOutputSink sink) = CreateEngine(); + try + { + engine.InputWriter.TryWrite(new PasteEvent("hello")); + await SettleAsync(engine, clock); + + Assert.NotNull(sink.LastModel); + TextBuffer editor = sink.LastModel.FixedRegion.Editor; + Assert.Equal("hello", editor.Text); + // Caret must have advanced to the end of the inserted text. + Assert.Equal("hello".Length, editor.CaretIndex); + } + finally { engine.Dispose(); } + } + + // 4.5 — Paste of text wider than the available width wraps and caret lands correctly. + [Fact] + public async Task PasteWiderThanWidthWrapsAndCaretLandsAtEnd() + { + // Use a narrow width so a long paste forces wrapping. + (LoopEngine engine, VirtualClock clock, CapturingOutputSink sink) = CreateEngine(cols: 10); + try + { + // 25 chars — more than 2 rows at width=10. + string payload = "ABCDEFGHIJKLMNOPQRSTUVWXY"; + engine.InputWriter.TryWrite(new PasteEvent(payload)); + await SettleAsync(engine, clock); + + Assert.NotNull(sink.LastModel); + TextBuffer editor = sink.LastModel.FixedRegion.Editor; + Assert.Equal(payload, editor.Text); + Assert.Equal(payload.Length, editor.CaretIndex); + + // Render at width=10: must produce more than one visual row. + IReadOnlyList rows = editor.Render(width: 10, allottedHeight: 10).VisibleRows; + Assert.True(rows.Count > 1, + $"Expected wrapping to produce >1 row for {payload.Length}-char paste at width 10; got {rows.Count}"); + + // The caret visual position reported by the model must be in the last row. + (int caretRow, _) = sink.LastModel.CaretPosition!.Value; + // The fixed region's rows start at 0 in the frame; the editor occupies the last rows. + // We just need the caret row to be positive (beyond the first row) — confirming wrap. + Assert.True(caretRow > 0, $"Caret row should be > 0 after wrap; was {caretRow}"); + } + finally { engine.Dispose(); } + } + + // 4.6 — Paste as first interaction on IsSecret=true+Default flips masking and Submit returns + // the real edited buffer (not the seeded default). + [Fact] + public async Task PasteAsFirstInteractionOnSecretDefaultFlipsMaskingAndSubmitReturnsBuffer() + { + (LoopEngine engine, VirtualClock clock, CapturingOutputSink sink) = CreateEngine(); + try + { + Task> task = PostInputDialog(engine, @default: "original", isSecret: true); + await SettleAsync(engine, clock); + + // Before paste: default is seeded; _userEdited=false → masking uses default length (8 bullets). + RenderModel? beforeModel = sink.LastModel; + Assert.NotNull(beforeModel); + + // Paste as the first user interaction. + engine.InputWriter.TryWrite(new PasteEvent("newpass")); + await SettleAsync(engine, clock); + + // After paste: _userEdited=true → masking uses buffer width (original 8 + new 7 = 15 chars). + RenderModel? afterModel = sink.LastModel; + Assert.NotNull(afterModel); + string renderedText = string.Concat( + afterModel.FixedRegionRows.SelectMany(l => l.Segments).Select(s => s.Text)); + + // The rendered overlay must show bullets, not the clear-text content. + Assert.DoesNotContain("original", renderedText, StringComparison.Ordinal); + Assert.DoesNotContain("newpass", renderedText, StringComparison.Ordinal); + Assert.Contains("•", renderedText, StringComparison.Ordinal); + + // The bullets must reflect the buffer content ("originalnewpass" = 15 chars → 15 bullets). + int bulletCount = renderedText.Count(c => c == '•'); + Assert.Equal("original".Length + "newpass".Length, bulletCount); + + // Submit must return the actual buffer text (not the original default). + engine.InputWriter.TryWrite(new KeyEvent(KeyCode.Named(NamedKey.Enter), Modifiers.None)); + await SettleAsync(engine, clock); + DialogResult result = await task; + + Assert.Equal(DialogOutcome.Submitted, result.Outcome); + Assert.Equal("originalnewpass", result.Value); + } + finally { engine.Dispose(); } + } + + // 4.7 — Paste is consumed by an active modal dialog and does NOT leak to the base editor. + [Fact] + public async Task PasteConsumedByModalDialogDoesNotLeakToBaseEditor() + { + (LoopEngine engine, VirtualClock clock, CapturingOutputSink sink) = CreateEngine(); + try + { + // Type something into the base editor first so we have a baseline. + engine.InputWriter.TryWrite(new KeyEvent(KeyCode.FromRune(new Rune('x')), Modifiers.None)); + await SettleAsync(engine, clock); + Assert.Equal("x", sink.LastModel!.FixedRegion.Editor.Text); + + // Open a modal select dialog. + Dialog selectDialog = new(multiSelect: false, modal: true); + selectDialog.List.SetItems([PlainLine("A"), PlainLine("B")]); + TaskCompletionSource tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + engine.Post(new OpenDialogCommand( + selectDialog, + () => tcs.TrySetResult(selectDialog.CloseRequest == OverlayCloseKind.Submit ? 1 : 0), + () => tcs.TrySetResult(-1))); + await SettleAsync(engine, clock); + + // Paste while modal dialog is active. + engine.InputWriter.TryWrite(new PasteEvent("should-not-appear")); + await SettleAsync(engine, clock); + + // The base editor must be unchanged — paste was consumed by the modal dialog. + Assert.Equal("x", sink.LastModel!.FixedRegion.Editor.Text); + + // Dismiss the dialog. + engine.InputWriter.TryWrite(new KeyEvent(KeyCode.Named(NamedKey.Escape), Modifiers.None)); + await SettleAsync(engine, clock); + await tcs.Task; + } + finally { engine.Dispose(); } + } + // ── Helper ──────────────────────────────────────────────────────────────── private static int FindRowIndexContaining(IReadOnlyList rows, string needle) diff --git a/tests/Dcli.Tests/ScrollbackRuleTests.cs b/tests/Dcli.Tests/ScrollbackRuleTests.cs new file mode 100644 index 0000000..e071cfe --- /dev/null +++ b/tests/Dcli.Tests/ScrollbackRuleTests.cs @@ -0,0 +1,77 @@ +using Dcli.Testing; +using Xunit; + +namespace Dcli.Tests; + +/// +/// Tests for §2 — : +/// width-aware rule rendering and resize re-expansion. +/// +public sealed class ScrollbackRuleTests +{ + // ── §2.5 — AppendRule renders a horizontal separator spanning the content width ─── + + /// + /// After AppendRule(), the live window contains a single row whose text is exactly + /// the content-width count of U+2500 box-drawing light-horizontal characters. + /// + [Fact] + public async Task AppendRuleRendersHorizontalSeparatorAtContentWidth() + { + const int width = 20; + await using HeadlessTerminal harness = await HeadlessTerminal.StartAsync( + new HeadlessTerminalOptions { InitialColumns = width, InitialRows = 24 }); + + harness.Terminal.Scrollback.AppendRule(); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + FrameSnapshot snapshot = harness.Snapshot; + string expected = new string('─', width); + bool found = snapshot.LiveWindowRows.Any(row => + row.Segments.Count == 1 && + row.Segments[0].Text == expected); + Assert.True(found, + $"Expected a rule row of {width} '─' chars; live rows: [{string.Join(", ", snapshot.LiveWindowRows.Select(r => $"'{string.Concat(r.Segments.Select(s => s.Text))}'"))}]"); + } + + // ── §2.6 — Rule re-expands to new content width after resize ───────────── + + /// + /// After a terminal resize, the rule row width tracks the new content width, not the old one. + /// + [Fact] + public async Task AppendRuleReExpandsToNewWidthAfterResize() + { + const int initialWidth = 20; + const int resizedWidth = 40; + + await using HeadlessTerminal harness = await HeadlessTerminal.StartAsync( + new HeadlessTerminalOptions { InitialColumns = initialWidth, InitialRows = 24 }); + + harness.Terminal.Scrollback.AppendRule(); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + // Verify initial render at initialWidth. + string expectedInitial = new string('─', initialWidth); + bool foundInitial = harness.Snapshot.LiveWindowRows.Any(row => + row.Segments.Count == 1 && + row.Segments[0].Text == expectedInitial); + Assert.True(foundInitial, + $"Expected initial rule row of {initialWidth} '─' chars."); + + // Resize to resizedWidth — harness.Resize keeps both sizeSource and resizeWatcher + // in sync, so the model column count and the live-window re-render both update correctly. + harness.Resize(resizedWidth, 24); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + FrameSnapshot snapshot = harness.Snapshot; + Assert.Equal(resizedWidth, snapshot.Size.Columns); + + string expectedResized = new string('─', resizedWidth); + bool foundResized = snapshot.LiveWindowRows.Any(row => + row.Segments.Count == 1 && + row.Segments[0].Text == expectedResized); + Assert.True(foundResized, + $"Expected resized rule row of {resizedWidth} '─' chars; live rows: [{string.Join(", ", snapshot.LiveWindowRows.Select(r => $"'{string.Concat(r.Segments.Select(s => s.Text))}'"))}]"); + } +} diff --git a/tests/Dcli.Tests/StyledTextTests.cs b/tests/Dcli.Tests/StyledTextTests.cs index 7d202ec..7a42be3 100644 --- a/tests/Dcli.Tests/StyledTextTests.cs +++ b/tests/Dcli.Tests/StyledTextTests.cs @@ -312,6 +312,104 @@ public void LineFromTextStructurallyEqualsManualConstruction() Assert.Equal(manual, fromFactory); } + // ----------------------------------------------------------------------------------------- + // 2.3 Line single-style shorthand factories (Section 1 — api-ergonomics-pass-2) + // ----------------------------------------------------------------------------------------- + + [Fact] + public void LineBoldProducesSingleBoldSegment() + { + // 1.6: Bold → single bold segment. + Line line = Line.Bold("hello"); + Assert.Single(line.Segments); + Assert.Equal("hello", line.Segments[0].Text); + Assert.True(line.Segments[0].Style.Format.HasFlag(Format.Bold)); + } + + [Fact] + public void LineDimProducesSingleDimSegment() + { + // 1.6: Dim → single dim segment. + Line line = Line.Dim("muted"); + Assert.Single(line.Segments); + Assert.Equal("muted", line.Segments[0].Text); + Assert.True(line.Segments[0].Style.Format.HasFlag(Format.Dim)); + } + + [Fact] + public void LineFgProducesSingleFgColoredSegmentWithFormatNone() + { + // 1.6: Fg → single fg-colored segment + Format.None. + Color red = Color.Named(Color.AnsiColor.Red); + Line line = Line.Fg("colored", red); + Assert.Single(line.Segments); + Assert.Equal("colored", line.Segments[0].Text); + Assert.Equal(red, line.Segments[0].Style.Foreground); + Assert.Equal(Format.None, line.Segments[0].Style.Format); + } + + [Fact] + public void LineBgProducesSingleBgColoredSegment() + { + // 1.6: Bg → single bg-colored segment. + Color blue = Color.Named(Color.AnsiColor.Blue); + Line line = Line.Bg("highlighted", blue); + Assert.Single(line.Segments); + Assert.Equal("highlighted", line.Segments[0].Text); + Assert.Equal(blue, line.Segments[0].Style.Background); + } + + [Fact] + public void LineBoldEqualsFromTextWithBoldStyle() + { + // 1.7: Line.Bold("x") ≡ Line.FromText("x", new Style(Format: Format.Bold)). + Line shorthand = Line.Bold("x"); + Line longForm = Line.FromText("x", new Style(Format: Format.Bold)); + Assert.Equal(longForm, shorthand); + } + + [Fact] + public void LineDimEqualsFromTextWithDimStyle() + { + // 1.7: Equivalence check for Dim. + Line shorthand = Line.Dim("x"); + Line longForm = Line.FromText("x", new Style(Format: Format.Dim)); + Assert.Equal(longForm, shorthand); + } + + [Fact] + public void LineFgEqualsFromTextWithFgStyle() + { + // 1.7: Equivalence check for Fg. + Color green = Color.Named(Color.AnsiColor.Green); + Line shorthand = Line.Fg("x", green); + Line longForm = Line.FromText("x", new Style(Foreground: green)); + Assert.Equal(longForm, shorthand); + } + + [Fact] + public void LineBgEqualsFromTextWithBgStyle() + { + // 1.7: Equivalence check for Bg. + Color cyan = Color.Named(Color.AnsiColor.Cyan); + Line shorthand = Line.Bg("x", cyan); + Line longForm = Line.FromText("x", new Style(Background: cyan)); + Assert.Equal(longForm, shorthand); + } + + [Fact] + public void LineBoldNeutralizesControlByteIdenticallyToFromText() + { + // 1.7: ESC byte (0x1B) is a Class-A control byte; under default strip mode it is + // removed. The shorthand and FromText must produce identical sanitized results. + const string dirty = "hello\x1Bworld"; + Line shorthand = Line.Bold(dirty); + Line longForm = Line.FromText(dirty, new Style(Format: Format.Bold)); + Assert.Equal(longForm, shorthand); + // Both should have the ESC stripped. + Assert.Equal("helloworld", shorthand.Segments[0].Text); + } + // ----------------------------------------------------------------------------------------- // 2.3 LineBuilder // -----------------------------------------------------------------------------------------