diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e97744 --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# dcli + +**Inline terminal-rendering for .NET.** Styled output flows into the terminal's *real* +scrollback, while a small interactive region — input line, status bar, dropdowns, modal +dialogs — stays pinned at the bottom. It's the rendering model behind Claude-Code-style CLIs, +packaged as a reusable library. + +``` + dcli inline terminal rendering library — smoke tour + Styled output flows into the real terminal scrollback. + A small interactive region is pinned at the bottom. + Content above the commit horizon is frozen and terminal-owned. + ─────────────────────────────────────────────────────────────── + > type a message_ ← input editor + Phase 2/6 — streaming live block ← status bar +``` + +dcli is **not** a full-screen TUI framework. It never takes over the alternate screen; your +program's output and the user's history remain in the native scrollback, exactly where a CLI +user expects them. dcli owns the *rendering and widget mechanics*; your application owns the +*data and semantics*. + +- **Target framework:** `net10.0` +- **Platforms:** Linux, macOS, Windows (modern VT terminals — Windows Terminal / Win10 1809+ / xterm-class) +- **Package:** [`dcli`](https://www.nuget.org/packages/dcli) on NuGet · **License:** MPL-2.0 + +--- + +## Install + +```bash +dotnet add package dcli --prerelease +``` + +> The current release line is a release candidate (`0.2.0-rc.x`), hence `--prerelease`. Drop the +> flag once a stable version ships. + +A companion package, [`Dcli.Testing`](docs/testing.md), provides a headless harness so you can +unit-test terminal UIs without a real TTY. + +## Quick start + +```csharp +using System.Text; +using Dcli; + +// StartAsync enters raw mode and starts the render loop. `await using` guarantees the +// terminal is restored on every exit path — including crashes and signals. +await using Terminal terminal = await Terminal.StartAsync(new TerminalOptions()); + +terminal.Status.SetRows(Line.FromText("Type a message and press Enter. Ctrl+C to quit.")); +terminal.Scrollback.Append("Welcome to the echo demo."); + +// Events flow out on a channel; drain them on your own loop. dcli never runs your code +// on its render thread. +await foreach (TerminalEvent evt in terminal.Events.ReadAllAsync()) +{ + switch (evt) + { + case InputSubmitted(var text): + terminal.Scrollback.Append(new LineBuilder().Dim("> ").Text(text).Build()); + terminal.Input.Clear(); + break; + + // In raw mode Ctrl+C does NOT raise SIGINT — it arrives here as a key event, + // and your app decides what it means. + case KeyPressed(var key) + when key.Modifiers == Modifiers.Ctrl + && key.Code.Kind == KeyCode.KeyCodeKind.UnicodeScalar + && key.Code.RuneValue == new Rune('c'): + return; + } +} +``` + +Awaitable modal dialogs read like ordinary `async` calls: + +```csharp +DialogResult pick = await terminal.SelectAsync( + new SelectRequest( + [Line.FromText("Anthropic"), Line.FromText("OpenAI"), Line.FromText("Local")], + "Choose a provider")); + +if (pick.Outcome == DialogOutcome.Submitted) + terminal.Scrollback.Append($"You picked option {pick.Value}."); +``` + +## The mental model in 30 seconds + +| Concept | What it means | +| --- | --- | +| **Inline rendering** | No alternate screen. Output scrolls into the terminal's native history. | +| **Commit horizon** | Only a bounded window near the bottom is re-rendered; everything above is frozen, write-once, and owned by the terminal. | +| **Fixed region** | The pinned bottom stack: input editor + status bar, with a dialog slot above and an autocomplete dropdown below. | +| **Single-writer loop** | One actor thread owns all UI state and stdout. You post fire-and-forget commands in; events flow out on a channel. | +| **Mechanics vs. semantics** | dcli renders and routes keys. *You* own protocols, completion candidates, and what a "submit" means. | + +Read [docs/concepts.md](docs/concepts.md) for the full picture. + +## Documentation + +| Guide | Covers | +| --- | --- | +| [Getting started](docs/getting-started.md) | Install, your first program, the lifecycle, terminal requirements | +| [Core concepts](docs/concepts.md) | Inline rendering, the commit horizon, the fixed region, the actor loop | +| [Styled text](docs/styled-text.md) | `Segment` / `Line` / `Style` / `Color` / `Format` / `LineBuilder` | +| [Scrollback](docs/scrollback.md) | `Append`, live blocks, one-way collapsibles | +| [The fixed region](docs/fixed-region.md) | Input editor, status bar, autocomplete dropdown | +| [Dialogs](docs/dialogs.md) | `SelectAsync` / `MultiSelectAsync` / `ChoiceAsync` / `InputAsync`, wizards | +| [Input & events](docs/events.md) | The event channel, key encoding, paste, resize | +| [Testing](docs/testing.md) | Faking `ITerminal`, the `HeadlessTerminal` harness, frame snapshots | +| [Architecture](docs/architecture.md) | The design decisions and why they're shaped the way they are | +| [API reference](docs/api-reference.md) | Every public type, at a glance | + +## Samples + +Runnable samples live under [`samples/`](samples): + +```bash +dotnet run --project samples/Dcli.Demo # self-driving tour of every surface +dotnet run --project samples/Dcli.Demo.DmonWizard # a multi-step wizard built on the dialogs +``` + +## Building from source + +```bash +dotnet build # analyzers run as warnings-as-errors; must be clean +dotnet test # all green +dotnet format --verify-no-changes # style gate +``` + +The repository uses [OpenSpec](openspec/) for spec-driven development; shipped changes (with their +design notes) are archived under `openspec/changes/archive/`. + +## License + +[MPL-2.0](LICENSE). diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..3a04eea --- /dev/null +++ b/docs/README.md @@ -0,0 +1,41 @@ +# dcli documentation + +dcli is an inline terminal-rendering library for .NET. These guides explain the model, walk +through each surface, and document the full public API. + +New here? Read [Core concepts](concepts.md) first, then [Getting started](getting-started.md). + +## Guides + +| Guide | Covers | +| --- | --- | +| [Getting started](getting-started.md) | Install, your first program, the lifecycle, terminal requirements | +| [Core concepts](concepts.md) | Inline rendering, the commit horizon, the fixed region, the actor loop, the mechanics/semantics boundary | +| [Styled text](styled-text.md) | `Segment` / `Line` / `Style` / `Color` / `Format` / `LineBuilder` | +| [Scrollback](scrollback.md) | `Append`, live (streaming) blocks, one-way collapsibles | +| [The fixed region](fixed-region.md) | The input editor, status bar, and autocomplete dropdown | +| [Dialogs](dialogs.md) | `SelectAsync` / `MultiSelectAsync` / `ChoiceAsync` / `InputAsync`, building wizards | +| [Input & events](events.md) | The event channel, key encoding, modifiers, paste, resize | +| [Testing](testing.md) | Faking `ITerminal`, the `HeadlessTerminal` harness, frame snapshots | +| [Architecture](architecture.md) | The design decisions and the reasoning behind them | +| [API reference](api-reference.md) | Every public type, at a glance | + +## At a glance + +```csharp +using Dcli; + +await using Terminal terminal = await Terminal.StartAsync(new TerminalOptions()); + +terminal.Scrollback.Append("Hello from dcli."); +terminal.Status.SetRows(Line.FromText("ready")); + +await foreach (TerminalEvent evt in terminal.Events.ReadAllAsync()) +{ + if (evt is InputSubmitted(var text)) + { + terminal.Scrollback.Append($"> {text}"); + terminal.Input.Clear(); + } +} +``` diff --git a/docs/api-reference.md b/docs/api-reference.md new file mode 100644 index 0000000..0d7ef48 --- /dev/null +++ b/docs/api-reference.md @@ -0,0 +1,393 @@ +# API reference + +Every public type in the `Dcli` namespace, plus the `Dcli.Testing` harness. Signatures are exact; +this is a quick reference, not a substitute for the per-topic guides linked throughout. + +- Package `dcli` → namespace `Dcli` +- Package `Dcli.Testing` → namespace `Dcli.Testing` + +## Contents + +- [Entry point](#entry-point) · [Terminal](#terminal) · [ITerminal](#iterminal) · [TerminalOptions](#terminaloptions) · [TerminalNotSupportedException](#terminalnotsupportedexception) +- [Scrollback](#scrollback-surfaces) · [IScrollback](#iscrollback) · [ILiveBlock](#iliveblock) · [ICollapsible](#icollapsible) +- [Fixed-region surfaces](#fixed-region-surfaces) · [IInput](#iinput) · [IStatus](#istatus) · [IAutocomplete](#iautocomplete) · [AutocompleteCandidate](#autocompletecandidate) +- [Dialogs](#dialogs) · [DialogResult\](#dialogresultt) · [DialogOutcome](#dialogoutcome) · request types +- [Styled text](#styled-text) · [Line](#line) · [Segment](#segment) · [Style](#style) · [Color](#color) · [Format](#format) · [LineBuilder](#linebuilder) +- [Events & input](#events--input) · [TerminalEvent](#terminalevent) · [InputEvent](#inputevent) · [KeyEvent](#keyevent) · [KeyCode](#keycode) · [NamedKey](#namedkey) · [Modifiers](#modifiers) · [PasteEvent](#pasteevent) +- [Testing harness (Dcli.Testing)](#testing-harness-dclitesting) + +--- + +## Entry point + +### Terminal + +`public sealed class Terminal : ITerminal` — the lifecycle handle. + +```csharp +static Task StartAsync(TerminalOptions options, CancellationToken cancellationToken = default); +ValueTask DisposeAsync(); +``` + +`StartAsync` enters raw mode and starts the render loop; throws [`TerminalNotSupportedException`](#terminalnotsupportedexception) +when the environment isn't VT-capable. `DisposeAsync` stops the loop, joins threads, and restores +the terminal (idempotent; rethrows a captured loop-thread fault after restoring). Implements all of +[`ITerminal`](#iterminal). See [Getting started](getting-started.md). + +### ITerminal + +`public interface ITerminal : IAsyncDisposable` — the abstraction to depend on. + +```csharp +IScrollback Scrollback { get; } +IInput Input { get; } +IStatus Status { get; } +IAutocomplete Autocomplete { get; } +ChannelReader Events { get; } + +(int Columns, int Rows) GetTerminalSize(); + +Task> SelectAsync(SelectRequest req, CancellationToken cancellationToken = default); +Task> MultiSelectAsync(MultiSelectRequest req, CancellationToken cancellationToken = default); +Task> ChoiceAsync(ChoiceRequest req, CancellationToken cancellationToken = default); +Task> InputAsync(InputRequest req, CancellationToken cancellationToken = default); +``` + +> Note: the current terminal size is read via the `GetTerminalSize()` **method**, not a property. + +### TerminalOptions + +`public sealed class TerminalOptions` + +```csharp +int? MaxFixedHeight { get; init; } // null → ~50% of terminal height, min 8 rows +int MinFrameIntervalMs { get; init; } = 16; // ≈ 60 fps ceiling +``` + +### TerminalNotSupportedException + +`public sealed class TerminalNotSupportedException : Exception` — thrown by `Terminal.StartAsync` +when stdout/stdin isn't a tty, `TERM=dumb`, or the platform isn't VT-capable. Standard three +constructors (`()`, `(string)`, `(string, Exception)`). + +--- + +## Scrollback surfaces + +See [Scrollback](scrollback.md). + +### IScrollback + +```csharp +void Append(Line line); +void Append(string text); // = Append(Line.FromText(text)) +ILiveBlock BeginLive(); +ICollapsible BeginCollapsible(Line summary, IReadOnlyList hiddenLines); +``` + +### ILiveBlock + +A handle to an in-progress mutable block. All methods are fire-and-forget. + +```csharp +void AppendText(string text); // append to buffer; no-op after SetContent/Commit +void SetContent(IReadOnlyList lines); // replace wholesale; AppendText becomes a no-op +void Commit(); // freeze; flows into native scrollback next paint +``` + +### ICollapsible + +```csharp +void Expand(); // one-way, idempotent; reprints if already past the commit horizon +``` + +--- + +## Fixed-region surfaces + +See [The fixed region](fixed-region.md). + +### IInput + +```csharp +void SetText(string text); // replace buffer, caret to end; does NOT emit InputChanged +void Clear(); // empty buffer, caret to 0; does NOT emit InputChanged +``` + +### IStatus + +```csharp +void SetRows(params Line[] rows); // replaces all rows; empty clears +void SetRows(IReadOnlyList rows); // replaces all rows; empty clears +``` + +The status bar is never truncated by the height budget. + +### IAutocomplete + +```csharp +void Show(IReadOnlyList candidates); // no-op while a modal dialog is open +void Hide(); // no-op unless autocomplete is active +``` + +### AutocompleteCandidate + +`public sealed record AutocompleteCandidate(string InsertText, Line Display)` + +- `Display` — the styled row shown in the dropdown. +- `InsertText` — applied on accept via **whole-buffer replace** (caret to end). + +--- + +## Dialogs + +All four dialog methods are on [`ITerminal`](#iterminal). See [Dialogs](dialogs.md). + +### DialogResult\ + +`public readonly record struct DialogResult(DialogOutcome Outcome, T Value)` + +`Value` is meaningful only when `Outcome == Submitted`. `T` is `int` (Select/Choice), `int[]` +(MultiSelect), or `string` (Input). + +### DialogOutcome + +`public enum DialogOutcome { Submitted, Back, Cancelled }` + +`Back` is produced only by `SelectAsync`/`ChoiceAsync` when the request sets `AllowBack = true`. + +### SelectRequest + +`public sealed record SelectRequest(IReadOnlyList Items, IReadOnlyList? Title = null, bool AllowBack = false)` + +Convenience constructors accept a single `Line?`/`string?` title, a `params Line[]`/`params string[]` +multi-line title, and plain-string item lists (each converted via `Line.FromText`). Submitting an +empty item list returns `Value == -1`. + +### MultiSelectRequest + +`public sealed record MultiSelectRequest(IReadOnlyList Items, IReadOnlyList? Title = null)` + +Same family of string/`Line`/`params` convenience constructors. Result is checked indices, ascending. + +### ChoiceRequest + +`public sealed record ChoiceRequest(IReadOnlyList Options, IReadOnlyList? Prompt = null, bool AllowBack = false)` + +Semantically equivalent to `SelectRequest`; carries a `Prompt` instead of a `Title`. + +### InputRequest + +`public sealed record InputRequest(IReadOnlyList? Prompt = null, string? Default = null, bool IsSecret = false)` + +- `Default` — pre-filled text; caret starts at its end. +- `IsSecret` — renders each char as `•`; the returned `Value` is always the real text. +- The `params Line[]` / `params string[]` prompt constructors **cannot** also set `Default`/`IsSecret` + — use the single-line or `IReadOnlyList<…>` overloads when you need those. + +--- + +## Styled text + +See [Styled text](styled-text.md). + +### Line + +`public sealed record Line(IReadOnlyList Segments)` + +```csharp +Line(IEnumerable segments); // copies into a read-only list +static Line FromText(string text, Style? style = null); // single-segment line +``` + +Structural (sequence) equality over segments. No implicit `string` → `Line` conversion. + +### Segment + +`public record Segment(string Text, Style Style = default)` — text emitted verbatim, no markup. + +### Style + +`public readonly record struct Style(Color? Foreground = null, Color? Background = null, Format Format = Format.None)` + +`default(Style)` = terminal defaults, no attributes. + +### Color + +`public readonly record struct Color` — construct via factories only: + +```csharp +static Color Named(Color.AnsiColor color); // 16 ANSI colors +static Color FromIndex(byte index); // 256-palette index 0–255 +static Color FromRgb(byte r, byte g, byte b);// 24-bit truecolor + +Color.ColorKind Kind { get; } // Named | Indexed | Rgb +Color.AnsiColor NamedValue { get; } // valid when Kind == Named (else throws) +byte IndexValue { get; } // valid when Kind == Indexed (else throws) +byte R { get; } byte G { get; } byte B { get; } // valid when Kind == Rgb (else throws) +``` + +`enum AnsiColor` — `Black, Red, Green, Yellow, Blue, Magenta, Cyan, White` and `Bright*` (0–15). + +### Format + +`[Flags] public enum Format` — `None=0, Bold=1, Italic=2, Underline=4, Dim=8, Reverse=16, Strikethrough=32`. + +### LineBuilder + +`public sealed class LineBuilder` — fluent; each method returns `this`. Don't reuse after `Build()`. + +```csharp +LineBuilder Text(string s); +LineBuilder Append(string s, Style style = default); +LineBuilder Bold(string s); Italic(string s); Underline(string s); +LineBuilder Dim(string s); Reverse(string s); Strikethrough(string s); +LineBuilder Fg(string s, Color foreground); +LineBuilder Bg(string s, Color background); +Line Build(); +``` + +--- + +## Events & input + +See [Input & events](events.md). + +### TerminalEvent + +`public abstract record TerminalEvent` — the outbound stream (`ITerminal.Events`): + +```csharp +sealed record InputSubmitted(string Text) : TerminalEvent; +sealed record InputChanged(string Text) : TerminalEvent; +sealed record KeyPressed(KeyEvent Key) : TerminalEvent; +sealed record Resized(int Columns, int Rows) : TerminalEvent; +``` + +### InputEvent + +`public abstract record InputEvent` — the low-level VT-pipeline vocabulary: + +```csharp +sealed record KeyEvent(KeyCode Code, Modifiers Modifiers) : InputEvent; +sealed record PasteEvent(string Text) : InputEvent; +sealed record ResizeEvent(int Columns, int Rows) : InputEvent; +``` + +### KeyEvent + +`public sealed record KeyEvent(KeyCode Code, Modifiers Modifiers)`. + +### KeyCode + +`public readonly record struct KeyCode` — a printable scalar or a named key: + +```csharp +static KeyCode FromRune(System.Text.Rune rune); // KeyCodeKind.UnicodeScalar +static KeyCode Named(NamedKey key); // KeyCodeKind.Named + +KeyCode.KeyCodeKind Kind { get; } // UnicodeScalar | Named +System.Text.Rune RuneValue { get; } // valid when UnicodeScalar (else throws) +NamedKey NamedValue { get; } // valid when Named (else throws) +``` + +### NamedKey + +`public enum NamedKey` — `Enter, Tab, Backspace, Escape, Up, Down, Right, Left, Home, End, PageUp, +PageDown, Insert, Delete, F1`…`F12, BackTab`. + +### Modifiers + +`[Flags] public enum Modifiers` — `None=0, Ctrl=1, Alt=2, Shift=4`. `Shift` appears only on named +keys; it's never reported together with `Ctrl`. + +### PasteEvent + +`public sealed record PasteEvent(string Text)` — full UTF-8-decoded bracketed-paste body. + +--- + +## Testing harness (`Dcli.Testing`) + +See [Testing](testing.md). + +### HeadlessTerminal + +`public sealed class HeadlessTerminal : IAsyncDisposable` + +```csharp +static Task StartAsync(HeadlessTerminalOptions? options = null, + CancellationToken cancellationToken = default); + +ITerminal Terminal { get; } // the real façade — drive it as production does +VirtualClock Clock { get; } +FrameSnapshot Snapshot { get; } // immutable; empty before first paint + +void Feed(ReadOnlySpan bytes); // raw bytes through the real VT parser +void SendKey(KeyEvent key); // inject one key event (use for named keys) +void Type(string text); // type printables rune-by-rune +void Paste(string text); // inject a bracketed-paste block +void Resize(int columns, int rows); // fire a resize via the SIGWINCH path +Task SettleAsync(CancellationToken cancellationToken = default); +ValueTask DisposeAsync(); +``` + +### HeadlessTerminalOptions + +`public sealed class HeadlessTerminalOptions` + +```csharp +int InitialColumns { get; init; } = 80; +int InitialRows { get; init; } = 24; +TimeSpan MinFrameInterval{ get; init; } = TimeSpan.Zero; // non-zero for cadence tests +int? MaxFixedHeight { get; init; } +VirtualClock? Clock { get; init; } // share one clock across harnesses +``` + +### VirtualClock + +`public sealed class VirtualClock : IClock` + +```csharp +VirtualClock(TimeSpan? initial = null); // defaults to TimeSpan.Zero +TimeSpan Now { get; } +void Advance(TimeSpan by); // completes waiters whose deadline has passed +``` + +### FrameSnapshot + +`public sealed record FrameSnapshot` (all members `required init`): + +```csharp +IReadOnlyList LiveWindowRows { get; init; } +IReadOnlyList FixedRegionRows { get; init; } +(int Row, int Col)? Caret { get; init; } // null when hidden / modal +bool IsCursorVisible { get; init; } +(int Columns, int Rows) Size { get; init; } +IReadOnlyList NewlyCommittedRows { get; init; } +OverlayDescriptor Overlay { get; init; } +``` + +### OverlayDescriptor + +`public sealed record OverlayDescriptor` + +```csharp +OverlayKind Kind { get; init; } // None | Autocomplete | Dialog | Input +int SelectedIndex { get; init; } = -1; // list overlays; -1 when empty/N/A +int VisibleRowCount { get; init; } +string? InputText { get; init; } // Input overlay, when not secret +bool IsSecret { get; init; } +``` + +`public enum OverlayKind { None, Autocomplete, Dialog, Input }` + +### FrameSnapshotPrinter + +`public static class FrameSnapshotPrinter` + +```csharp +static string PrettyPrint(FrameSnapshot snapshot); // stable, style-stripped, ASCII-bordered +``` + +Use for golden-frame assertions — asserts structure, not raw ANSI. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..6769ffb --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,174 @@ +# Architecture + +This page explains *why* dcli is shaped the way it is. The decisions below are binding and +interlocking — each one falls out of the first. The authoritative record (with alternatives +considered) lives in the OpenSpec archive under +[`openspec/changes/archive/2026-05-28-core-rendering-architecture/design.md`](../openspec/changes/archive/2026-05-28-core-rendering-architecture/design.md); +this is the readable distillation. + +## 1. Inline rendering with native scrollback — "the Fork" + +dcli renders on the terminal's **main screen**, appending styled output into the native scrollback, +and only ever re-renders a bounded region near the bottom. It deliberately does **not** use the +alternate screen. + +**Why.** The target UX is a conversational CLI (Claude-Code-style), not a dashboard. Users expect +to scroll back through real history, select and copy any prior line, and get a clean shell prompt +on exit. The alternate screen — the standard TUI choice — throws all of that away. Choosing the +main screen is the fork in the road from which everything else follows. + +This is the single most important thing to understand about dcli: **it is not a full-screen TUI +framework, and its constraints are features, not limitations.** + +## 2. The commit horizon / live window + +Since only a bounded region is re-rendered, there is a line below which content is **committed** — +frozen, write-once, terminal-owned. Above that line is the **live window** (`≤ rows − +fixedRegionHeight`), which dcli can still repaint. + +**Why.** It falls directly out of Decision 1, and it bounds memory and per-frame work to a single +screen regardless of how much has scrolled past. `Render()` is only ever called on live blocks; +frozen blocks were painted once and dropped; a resize reflows only the live window. + +**Consequence.** The horizon moves only downward. Opening tall bottom UI (a big dropdown, a long +dialog, a large paste) shrinks the live window and **commits** whatever scrolls above it — and that +content stays committed when the UI closes. Documented as expected behavior, on-brand for inline +rendering. See [the commit horizon](concepts.md#the-commit-horizon). + +## 3. dcli = mechanics + rendering; consumer = data + semantics + +dcli owns the render model, the widgets, the input driver, and key routing. The consumer owns the +data, the protocol (RPC/JSONL), completion candidates, Markdown→lines conversion, and the notion of +a "turn". + +**Why.** A clean boundary is what lets one library back very different CLIs. Concretely: Ctrl+C is +surfaced as an event rather than acted upon; autocomplete is a round-trip the consumer drives; +Markdown rendering stays in the consumer. The protocol never leaks into the library. See +[mechanics vs. semantics](concepts.md#mechanics-vs-semantics--the-dcliconsumer-boundary). + +## 4. Platform: C# / .NET (`net10.0`), modern VT only + +Shipped as a NuGet package targeting `net10.0`. The terminal floor is a modern VT-capable emulator +(Windows Terminal / Win10 1809+ / xterm-class); legacy conhost is unsupported. + +**Why.** A modern VT floor lets the input and rendering layers assume a single, well-understood +escape-sequence vocabulary instead of carrying compatibility shims for terminals nobody targets. + +## 5. Scrollback data model: flat list, content ≠ visual + +Scrollback is a flat list of line-objects (`Segment → Line → {TextBlock | Collapsible}`). The +**content model** (logical lines) is distinct from the **visual rows** (after cell-wrapping to the +current width). + +**Why.** Separating content from presentation means a resize reflows by re-wrapping the live +window's content, with no need to remember per-row pixel state. Frozen content needs no model at +all — it already lives in the terminal. + +## 6. One-way collapsibles + +A collapsible expands **once** and never re-collapses; its hidden lines are an immutable snapshot +taken at construction. + +**Why.** Inline rendering means height can only grow — there's no way to push committed content back +*up* the terminal. One-way ⇒ monotonic height ⇒ the model never has to do the impossible, and all +toggle/remeasure state disappears. If a collapsible has already scrolled past the horizon when +expanded, its hidden lines are reprinted into the native scrollback flow instead. See +[collapsibles](scrollback.md#collapsibles--one-way-expandable-blocks). + +## 7. Styling: one programmatic primitive, shared + +A single `Segment`/`Style` primitive (with a `[Flags] Format` enum and a `LineBuilder`) is shared by +both zones. There is **no markup parser**. + +**Why.** A markup language would be a second, lossy way to express styling and an injection surface. +Programmatic construction is type-safe and unambiguous; Markdown (or any other source format) → +`Line[]` is the consumer's job (Decision 3). See [styled text](styled-text.md). + +## 8. Fixed region: component stack, intercept routing, height budget + +The pinned bottom region is a stack — input editor + status line — with two **mutually-exclusive** +overlays: a dialog slot above the input and an autocomplete dropdown below. Keys flow down an +**intercept chain** (`bool HandleKey(KeyEvent)`), active overlay first, falling through to the input +editor. The whole region is bounded by a `MaxHeight` budget (default ≈ 50%, min 8 rows); the status +bar is never truncated. + +**Why.** One overlay slot keeps the visual model simple and the routing unambiguous. A dialog is a +*slot that hosts a component* — not a wizard engine; a multi-step wizard is the consumer swapping +the slot's contents page by page (Decision 3). See [the fixed region](fixed-region.md). + +## 9. Raw-mode input: one VT byte-stream, one parser, built in-house + +dcli runs its own raw-mode session (its own `termios` / Win32-console shims — no `Console.ReadKey`), +a single `VtInputParser` state machine, and a School-A `KeyEvent` contract (`Char(Rune) | Named` + +modifier flags), with bracketed paste and eaten Ctrl+C. + +**Why.** Raw-mode keyboard input is a .NET soft spot; owning the byte stream and parser is the only +way to get correct, testable key decoding. The parser is pure and unit-tested against byte fixtures. + +**Accepted limits (terminal physics, not bugs):** Ctrl-letter collisions, Shift implicit in +printable runes, Shift lost under Ctrl. None are fixable without a richer protocol (Kitty), which is +deferred. See [key encoding caveats](events.md#key-encoding-caveats-terminal-physics-not-bugs). + +## 10. Render loop: actor model, event-driven, dual channels + +A single thread owns **all** UI state, stdout, and the raw-mode session. Producers post to an +inbound `Channel`; dcli→consumer events flow out on a separate `Channel`. Frames are +drain-coalesce-throttled; the public command API is fire-and-forget. + +**Why.** Interleaved writes (async RPC + keystrokes) are the classic way to corrupt a terminal +display. A single owner of stdout makes corruption structurally impossible — there is exactly one +writer. The consumer never runs code on the loop thread, so a slow consumer can't stall rendering. +See [the render loop](concepts.md#the-single-writer-render-loop). + +## 11. Public API: commands in, events out, dialogs awaited + +The surface is a background `Terminal` façade: fire-and-forget scrollback/status/input commands, an +outbound `Events` stream, and `await`-able modal dialogs. + +**The keystone — dialogs are awaitable Tasks, messages internally.** `SelectAsync(...)` posts an +open-dialog command carrying a `TaskCompletionSource`; the loop drives the modal overlay and +completes the TCS on submit/back/cancel. So request/response reads as `await` on the outside while +staying a pure message inside the actor loop. See [dialogs](dialogs.md). + +## 12. Testability: a fakeable façade + a public headless harness + +The façade is an interface (`ITerminal`) with publicly-constructible event/result types, so +consumers can fake it (tier A). A companion `Dcli.Testing` package ships a headless harness — +`HeadlessTerminal` + scripted input + deterministic `SettleAsync` + a structured `FrameSnapshot` — +for terminal-free integration tests (tier B). + +**Why.** A renderer that can only be exercised against a real TTY is one the consumer can't test — +so testability is a first-class API constraint, not an afterthought. The harness swaps only the OS +edges over the *shared* core (never a second engine), so it can't drift from production. It's the +same substrate dcli uses to test itself. See [testing](testing.md). + +## Guaranteed terminal restore + +The gravest failure mode for a raw-mode program is crashing and leaving the user's shell with no +echo. dcli guarantees restore on **every** exit path, all converging on a single idempotent restore +so no double-restore can occur: + +1. **Normal shutdown** — `DisposeAsync` stops the loop, joins threads, restores. +2. **Loop-thread crash** — the loop's `finally` restores before the thread dies (`DisposeAsync` + then rethrows the captured fault so the caller learns of it). +3. **External signals / `ProcessExit`** — a `RestoreCoordinator` (wired to `SIGINT`/`SIGTERM`/ + `SIGQUIT`/`SIGCONT` and `AppDomain.ProcessExit`) halts the loop and restores as a last resort. + +The signal path routes through the loop thread (single-writer discipline holds even during teardown: +ANSI restore → termios restore → signal), so even the emergency path doesn't violate Decision 10. + +## Open questions (mechanism, not architecture) + +The decisions above are settled. What remains open is *mechanism* that can't contradict them: + +- **Frame paint strategy** — the v1 lean is a synchronized-output fence (`ESC [ ? 2026 h … l`) plus + clear/repaint of the bounded live window, with a per-line diff reconciler as a later optimization + for terminals that ignore sync-output. +- **Resize + capability detection mechanics** — `SIGWINCH`/buffer-size delivery is settled (it's an + inbound message); the reflow/repaint mechanics and truecolor/sync-output/unicode-width detection + and fallbacks are the open part. + +## See also + +- [Core concepts](concepts.md) — the same ideas, framed for first-time readers. +- The OpenSpec archive — full proposals, specs, and per-change `DEVLOG.md` narratives. diff --git a/docs/concepts.md b/docs/concepts.md new file mode 100644 index 0000000..23f99dc --- /dev/null +++ b/docs/concepts.md @@ -0,0 +1,120 @@ +# Core concepts + +dcli renders a CLI the way Claude Code does: your program's output scrolls into the terminal's +**real history**, and only a small region at the bottom is interactive and repainted. This page +explains the handful of ideas that make the rest of the API obvious. + +## Inline rendering, not a full-screen TUI + +Most TUI frameworks (ncurses, Spectre.Console's live displays, Terminal.Gui) switch the terminal +to its **alternate screen** — a separate buffer that wipes on exit and leaves nothing in +scrollback. That's right for a dashboard or a text editor. It's wrong for a conversational CLI, +where the user expects to scroll up through everything that happened, copy a line from twenty +turns ago, and have their shell prompt return cleanly when the program exits. + +dcli stays on the **main screen**. Styled output is *appended* to the terminal's native +scrollback, just like `printf` would, and the terminal's own scrollback buffer keeps the history. +dcli only ever rewrites a bounded region near the cursor. + +``` + ── native terminal scrollback (frozen, terminal-owned) ── + line written 3 minutes ago + a streamed model response that has since committed + ... + ── live window (re-renderable) ── + the block currently streaming + ── fixed region (pinned, re-rendered every frame) ── + > the user's input line_ + status: connected · 12 turns +``` + +## The commit horizon + +Because dcli only repaints a bounded region, there has to be a line below which content is +**frozen** — written once, never touched again, and handed to the terminal forever. That boundary +is the **commit horizon**. + +- Below the horizon is the **live window**: at most `rows − fixedRegionHeight` rows that dcli can + still re-render (e.g. a block that's still streaming). +- Above the horizon, everything is **committed**: frozen text in the terminal's scrollback. + +The horizon only ever moves **down**. As new content arrives or the fixed region grows, the +top lines of the live window scroll off and commit. This is a one-way street by design — you +cannot un-commit a line, because it's no longer dcli's to rewrite. + +A practical consequence: opening a tall dialog or a big dropdown shrinks the live window, which +**commits** whatever scrolls above it. That committed content stays committed after the dialog +closes. This is expected and on-brand for inline rendering — see +[Architecture](architecture.md) for the full trade-off. + +## The two zones + +dcli renders exactly two zones: + +1. **Scrollback** — the append-mostly content stream. You add lines, streaming "live" blocks, and + one-way collapsibles. See [Scrollback](scrollback.md). +2. **The fixed region** — the pinned bottom stack: the input editor, the status bar, and two + mutually-exclusive overlays (a **dialog** slot above the input, an **autocomplete** dropdown + below). See [The fixed region](fixed-region.md). + +Both zones render the same styled-text primitive — `Segment` / `Line` / `Style`. There is no +markup language to parse; you build styled lines programmatically. See [Styled text](styled-text.md). + +## The single-writer render loop + +All UI state and the sole handle to stdout live on **one** thread — an actor-model render loop. +This is what makes interleaved async writes safe: a background task streaming RPC results and a +user hammering the keyboard can't corrupt the display, because neither touches stdout directly. + +You interact with the loop through two channels: + +- **Commands in (fire-and-forget).** `Scrollback.Append`, `Status.SetRows`, `Input.SetText`, + `Autocomplete.Show`, and friends *post* a command and return immediately. The loop applies it + on its own thread and paints when it next coalesces a frame. Your call does not block on a paint. +- **Events out (a channel you drain).** Key presses the editor didn't consume, input submissions, + buffer changes, and resizes flow out on `Terminal.Events`, a `ChannelReader`. You + read them on your own loop. **dcli never runs your code on its render thread.** + +Awaitable dialogs bridge the two: `await terminal.SelectAsync(...)` posts an open-dialog command +carrying a `TaskCompletionSource`, the loop drives the modal, and on submit/cancel it completes +your task. It reads like a request/response `await` on the outside while staying a pure message on +the inside. + +Frames are **coalesced and throttled**: many commands between paints collapse into a single frame, +capped at `TerminalOptions.MinFrameIntervalMs` (default 16 ms ≈ 60 fps). + +## Mechanics vs. semantics — the dcli/consumer boundary + +This is the most important boundary to internalize: + +> **dcli owns rendering and widget mechanics. Your application owns data and semantics.** + +dcli decodes a keystroke, routes it down the overlay→input chain, and repaints. It does **not** +know what your commands are, what a "submit" should do, or how to compute completions. Concretely: + +- **Ctrl+C is eaten** and surfaced to you as a `KeyPressed` event. dcli takes no action — *you* + decide whether it means clear-input, interrupt, or exit. (In raw mode it does not raise SIGINT.) +- **Autocomplete is a round-trip.** dcli emits `InputChanged`; you compute candidates from the + text and call `Autocomplete.Show(...)`. dcli never calls back into your code to ask for them. +- **Markdown, protocols, "turns"** — all yours. dcli ships `Segment`/`Line`/`Style`; converting + Markdown (or JSON, or an RPC stream) into lines is your job. + +Keeping this line clean is why the same library can back very different CLIs. + +## Lifecycle and the restore guarantee + +`Terminal.StartAsync` enters raw mode and starts the loop. Disposal stops the loop, joins its +threads, and restores the terminal. The restore is **guaranteed on every exit path**: + +- normal `DisposeAsync` (use `await using`), +- an unhandled exception on the loop thread (the loop's `finally` restores), +- external signals and `ProcessExit` (a last-resort coordinator restores). + +All paths converge on a single idempotent restore, so a crash can't leave the user's shell with +no echo. See [Getting started](getting-started.md) for the lifecycle in code. + +## Where to go next + +- [Getting started](getting-started.md) — install and run something. +- [Styled text](styled-text.md) — build the lines you'll append everywhere. +- [Architecture](architecture.md) — the decisions behind all of the above. diff --git a/docs/dialogs.md b/docs/dialogs.md new file mode 100644 index 0000000..64cf352 --- /dev/null +++ b/docs/dialogs.md @@ -0,0 +1,206 @@ +# Dialogs + +Dialogs are dcli's headline ergonomic: a modal prompt you **`await`**. Under the hood it's a pure +message to the render loop carrying a `TaskCompletionSource`; the loop drives the modal overlay and +completes your task on submit/cancel. On the outside it reads like an ordinary async call, so a +wizard is just a sequence of `await`s. + +There are four: + +| Method | Request | Result | Prompt the user to… | +| --- | --- | --- | --- | +| `SelectAsync` | `SelectRequest` | `DialogResult` | pick one item from a list | +| `MultiSelectAsync` | `MultiSelectRequest` | `DialogResult` | check any number of items | +| `ChoiceAsync` | `ChoiceRequest` | `DialogResult` | pick one option (with an explanatory prompt) | +| `InputAsync` | `InputRequest` | `DialogResult` | type free text (optionally masked) | + +All four are on [`ITerminal`](api-reference.md#iterminal) and take an optional `CancellationToken`. + +## The result shape + +Every dialog returns a [`DialogResult`](api-reference.md#dialogresultt): an `Outcome` plus a +`Value` that's meaningful only when `Outcome == Submitted`. + +```csharp +public enum DialogOutcome { Submitted, Back, Cancelled } +public readonly record struct DialogResult(DialogOutcome Outcome, T Value); +``` + +- **`Submitted`** — the user confirmed (Enter). `Value` holds the selection/text. +- **`Cancelled`** — the user pressed Escape, or the `CancellationToken` fired. `Value` is `default`. +- **`Back`** — only when the request opted into `AllowBack` and the user pressed Backspace to step + back (for wizards). `Value` is `default`. See [Wizards](#building-a-wizard). + +Always branch on `Outcome` before reading `Value`: + +```csharp +DialogResult r = await terminal.SelectAsync(req); +if (r.Outcome == DialogOutcome.Submitted) + Use(r.Value); +``` + +## Select — pick one + +```csharp +DialogResult pick = await terminal.SelectAsync( + new SelectRequest( + [Line.FromText("Anthropic"), Line.FromText("OpenAI"), Line.FromText("Local")], + "Choose a provider")); + +if (pick.Outcome == DialogOutcome.Submitted) + terminal.Scrollback.Append($"Provider #{pick.Value}"); +``` + +`Value` is the **zero-based index** into the items. (Edge case: submitting an empty item list +returns `Value == -1`.) + +### Items and titles: strings or styled lines + +Items are `IReadOnlyList`. The title can be a `Line`, a list of lines (multi-line), or a +plain `string`. The handy combination is **`Line` items with a plain-string title**: + +```csharp +var req = new SelectRequest( + [ + new LineBuilder().Fg("C#", Color.Named(Color.AnsiColor.Cyan)).Build(), + new LineBuilder().Fg("Rust", Color.Named(Color.AnsiColor.Red)).Build(), + ], + "Pick a language"); +``` + +For a fully plain list with no title, string items work directly: + +```csharp +new SelectRequest(["C#", "Go", "Rust"]); // items only, all strings +``` + +Titles can be **multi-line** — pass a list of lines: + +```csharp +new SelectRequest( + [Line.FromText("C#"), Line.FromText("Go")], + [Line.FromText("Pick a language"), new LineBuilder().Dim("↑/↓, Enter to confirm").Build()]); +``` + +> The convenience constructors don't cover *every* mix — notably **string items with a string +> title** has no overload. Use `Line` items (`Line.FromText(...)`) whenever you want a string title, +> as above. + +## MultiSelect — check several + +Space toggles items; Enter submits the checked set. + +```csharp +DialogResult picks = await terminal.MultiSelectAsync( + new MultiSelectRequest( + [Line.FromText("Logs"), Line.FromText("Metrics"), Line.FromText("Traces")], + "Enable which signals?")); + +if (picks.Outcome == DialogOutcome.Submitted) + foreach (int i in picks.Value) // checked indices, ascending + Enable(i); +``` + +`Value` is an `int[]` of the checked indices in ascending order; an empty submission is an empty +array (still `Submitted`). `Cancelled` also yields an empty array. + +## Choice — pick one, with a prompt + +Semantically the same as `Select`, but the type name signals "mutually-exclusive options with an +explanation", and it carries a `Prompt` instead of a `Title`. Handy for confirmations: + +```csharp +DialogResult confirm = await terminal.ChoiceAsync( + new ChoiceRequest( + [Line.FromText("Yes"), Line.FromText("No")], + [ + new LineBuilder().Bold("Overwrite the existing file?").Build(), + new LineBuilder().Dim("This cannot be undone.").Build(), + ])); + +bool yes = confirm.Outcome == DialogOutcome.Submitted && confirm.Value == 0; +``` + +## Input — free text + +```csharp +DialogResult name = await terminal.InputAsync( + new InputRequest(prompt: "What's your name?", Default: "ada")); + +if (name.Outcome == DialogOutcome.Submitted) + Greet(name.Value); +``` + +[`InputRequest`](api-reference.md#inputrequest) fields: + +- `Prompt` — optional preamble (string, `Line`, or multi-line list). +- `Default` — pre-filled text; the caret starts at its end. +- `IsSecret` — when `true`, each character renders as a bullet (`•`). **The returned `Value` + always carries the real, unmasked text** — masking is display-only. + +```csharp +var key = await terminal.InputAsync(new InputRequest(prompt: "API key:", IsSecret: true)); +``` + +> The `params` constructors of `InputRequest` (multi-line prompt shorthand) can't also set +> `Default`/`IsSecret`. When you need those, use the `IReadOnlyList<…>`/single-line overloads, as +> above. + +## Cancellation + +Pass a `CancellationToken` to close the dialog programmatically (e.g. a timeout, or because a +background event made the prompt moot). When the token fires, the dialog closes and the result is +`Cancelled`. If the token is already cancelled when you call, it returns `Cancelled` immediately +without opening anything. + +```csharp +using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); +DialogResult r = await terminal.SelectAsync(req, cts.Token); +``` + +## One dialog at a time + +Only one modal overlay can be open at once (it shares the single overlay slot with autocomplete — +opening a dialog suppresses autocomplete). **Awaiting two dialogs concurrently is a programming +error**: the second `…Async` call faults its task with `InvalidOperationException("A dialog is +already active.")`. Await them in sequence instead — which is the natural shape for a wizard anyway. + +## Building a wizard + +A multi-step wizard is just sequential `await`s — dcli owns the slot and key routing; *you* own the +step graph and what each answer means ([mechanics vs. semantics](concepts.md#mechanics-vs-semantics--the-dcliconsumer-boundary)). + +```csharp +// ModelsFor returns IReadOnlyList, e.g. names.Select(Line.FromText).ToList(). +async Task RunWizardAsync(ITerminal t, CancellationToken ct) +{ + var provider = await t.SelectAsync( + new SelectRequest( + [Line.FromText("Anthropic"), Line.FromText("OpenAI")], "Provider", allowBack: false), ct); + if (provider.Outcome != DialogOutcome.Submitted) return null; + + var model = await t.SelectAsync( + new SelectRequest(ModelsFor(provider.Value), "Model", allowBack: true), ct); + if (model.Outcome == DialogOutcome.Back) return await RunWizardAsync(t, ct); // step back + if (model.Outcome != DialogOutcome.Submitted) return null; + + var key = await t.InputAsync(new InputRequest(prompt: "API key:", IsSecret: true), ct); + if (key.Outcome != DialogOutcome.Submitted) return null; + + return new Config(provider.Value, model.Value, key.Value); +} +``` + +**`AllowBack`** (on `SelectRequest` and `ChoiceRequest`) is what makes back-navigation possible: +when `true`, pressing Backspace before moving the cursor (and before typing any filter text) closes +the dialog with `Outcome == Back`, so your wizard can pop to the previous step. It defaults to +`false`, so existing prompts are unaffected. + +The [`Dcli.Demo.DmonWizard`](../samples/Dcli.Demo.DmonWizard) sample is a complete worked example +of this pattern. + +## See also + +- [The fixed region](fixed-region.md) — the overlay slot dialogs render into. +- [Testing](testing.md) — driving dialogs from a headless test. +- [API reference](api-reference.md#dialogs). diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 0000000..8058c83 --- /dev/null +++ b/docs/events.md @@ -0,0 +1,158 @@ +# Input & events + +dcli is the sole owner of stdin and the render thread, so everything the user does reaches you as a +**message on a channel**. You drain `terminal.Events` on your own loop; dcli never calls into your +code. This page covers the event stream, how keys are encoded, and the sharp edges. + +## The event channel + +`terminal.Events` is a `ChannelReader`. Drain it however you like: + +```csharp +await foreach (TerminalEvent evt in terminal.Events.ReadAllAsync(cancellationToken)) +{ + // handle evt +} +``` + +A consumer that never reads simply accumulates events; it does **not** stall the render loop. Drain +on a dedicated task if your handling is slow. + +## TerminalEvent — what comes out + +[`TerminalEvent`](api-reference.md#terminalevent) is a closed set of records. Pattern-match them: + +| Event | Meaning | +| --- | --- | +| `InputSubmitted(string Text)` | User pressed Enter. `Text` is the line as submitted. | +| `InputChanged(string Text)` | The input buffer changed (user edit). `Text` is the new buffer. Drives autocomplete. | +| `KeyPressed(KeyEvent Key)` | A key the editor and overlays didn't consume, forwarded to you for app-level shortcuts. | +| `Resized(int Columns, int Rows)` | The terminal was resized. | + +```csharp +switch (evt) +{ + case InputSubmitted(var text): Submit(text); terminal.Input.Clear(); break; + case InputChanged(var text): terminal.Autocomplete.Show(Complete(text)); break; + case Resized(var cols, var rows): Reflow(cols, rows); break; + case KeyPressed(var key): HandleShortcut(key); break; +} +``` + +Only **unconsumed** keys arrive as `KeyPressed`. While the editor has focus it consumes printable +characters, Backspace, arrows, etc.; an open dialog or dropdown consumes its navigation keys first. +What's left over — function keys, Ctrl-chords you haven't bound, etc. — falls through to you. + +> `Input.SetText` / `Input.Clear` are programmatic and do **not** emit `InputChanged` (see +> [the fixed region](fixed-region.md#driving-input-programmatically)) — so setting text yourself +> won't trigger your autocomplete handler. + +## KeyEvent — how keys are encoded + +A [`KeyEvent`](api-reference.md#keyevent) is a `KeyCode` plus `Modifiers`. + +```csharp +public sealed record KeyEvent(KeyCode Code, Modifiers Modifiers); +``` + +### KeyCode — a printable scalar or a named key + +[`KeyCode`](api-reference.md#keycode) is a discriminated value: + +- **`UnicodeScalar`** — a printable character (or a Ctrl-modified letter). Read `RuneValue`. +- **`Named`** — a non-printable key from [`NamedKey`](api-reference.md#namedkey) (arrows, F-keys, + Home/End, Enter, Tab, Escape, Delete, …). Read `NamedValue`. + +```csharp +void HandleShortcut(KeyEvent key) +{ + switch (key.Code.Kind) + { + case KeyCode.KeyCodeKind.Named when key.Code.NamedValue == NamedKey.F5: + Refresh(); + break; + + case KeyCode.KeyCodeKind.UnicodeScalar + when key.Modifiers == Modifiers.Ctrl && key.Code.RuneValue == new Rune('r'): + ReverseSearch(); + break; + } +} +``` + +Reading the wrong accessor for the stored kind throws `InvalidOperationException`, so always check +`Kind` first (or match on it). + +### Modifiers + +[`Modifiers`](api-reference.md#modifiers) is a `[Flags]` enum: `None`, `Ctrl`, `Alt`, `Shift`. + +```csharp +if (key.Modifiers.HasFlag(Modifiers.Alt)) { /* ... */ } +``` + +### Key encoding caveats (terminal physics, not bugs) + +VT terminals lose information that a richer protocol (e.g. Kitty) would preserve. These are +documented limits, not defects: + +- **Ctrl-letter collisions.** `Ctrl+I` is indistinguishable from Tab, `Ctrl+M` from Enter, + `Ctrl+H` from Backspace — the terminal sends the same bytes. dcli reports them as the named key. +- **Shift is implicit on printables.** `Shift+a` arrives as the rune `'A'`, not as `'a'` + a Shift + flag. `Shift` only appears on **named** keys (e.g. `Shift+Tab` → `BackTab`, Shift+Arrow). +- **Shift is dropped under Ctrl.** The terminal collapses `Ctrl+Shift+x` on the wire, so dcli never + reports `Ctrl | Shift` together. + +## Ctrl+C and signals + +In raw mode, terminal signal generation is off — so **Ctrl+C does not raise SIGINT**. It arrives as +an ordinary key event: `KeyEvent(KeyCode.FromRune('c'), Modifiers.Ctrl)`. dcli "eats" it and hands +it to you; *you* decide whether it means clear-input, interrupt the current operation, or exit. + +```csharp +case KeyPressed(var key) + when key.Modifiers == Modifiers.Ctrl + && key.Code.Kind == KeyCode.KeyCodeKind.UnicodeScalar + && key.Code.RuneValue == new Rune('c'): + return; // exit; `await using` restores the terminal +``` + +External signals are different: a real `SIGTERM`/`SIGINT`/`SIGQUIT` from `kill`, or `ProcessExit`, +is caught by dcli's restore coordinator, which **always restores the terminal** before the process +dies — even on a crash. See [Architecture](architecture.md#guaranteed-terminal-restore). + +## Paste + +Pasted text arrives via **bracketed paste** as a single unit, not as a flurry of key events. The +input editor inserts it literally. If you read raw `InputEvent`s (advanced / testing), a paste is a +[`PasteEvent(string Text)`](api-reference.md#pasteevent) with the full UTF-8-decoded text; feed-call +boundaries inside the paste body are transparent. + +## Resize + +A terminal resize (POSIX `SIGWINCH` or a Windows buffer-size event) is delivered to you as +`Resized(Columns, Rows)`. dcli also reflows the live window and fixed region itself; the event lets +*you* react (e.g. recompute a status line or re-wrap your own content). The current size is also +available synchronously via `terminal.GetTerminalSize()`. + +```csharp +case Resized(var cols, var rows): + terminal.Status.SetRows(Line.FromText($"{cols}×{rows}")); + break; +``` + +## InputEvent vs. TerminalEvent + +Two families exist, at different layers: + +- [`InputEvent`](api-reference.md#inputevent) (`KeyEvent` / `PasteEvent` / `ResizeEvent`) is the + **low-level** VT-pipeline vocabulary. You mostly meet it via `KeyPressed.Key` and in the + [headless test harness](testing.md), which scripts `KeyEvent`s and `PasteEvent`s directly. +- [`TerminalEvent`](api-reference.md#terminalevent) (`InputSubmitted` / `InputChanged` / + `KeyPressed` / `Resized`) is the **high-level** outbound stream you actually consume in an app. + +## See also + +- [The fixed region](fixed-region.md) — what consumes keys before they reach you. +- [Dialogs](dialogs.md) — modal flows that consume their own navigation keys. +- [API reference](api-reference.md#events--input). diff --git a/docs/fixed-region.md b/docs/fixed-region.md new file mode 100644 index 0000000..8858148 --- /dev/null +++ b/docs/fixed-region.md @@ -0,0 +1,146 @@ +# The fixed region + +The **fixed region** is the interactive stack pinned at the bottom of the terminal. dcli repaints +it every frame. From bottom to top it is: + +``` + ┌─ overlay slot (above input): a modal dialog, when one is open + ├─ autocomplete dropdown (below input): when shown + │ > the input editor line_ + └─ the status bar (one or more rows) +``` + +The input editor and status bar are always present; the dialog slot and autocomplete dropdown are +two **mutually-exclusive** overlays (only one shows at a time). Its total height is bounded by +`TerminalOptions.MaxFixedHeight` (default ≈ 50% of the terminal, minimum 8 rows). Growing the fixed +region [commits](concepts.md#the-commit-horizon) whatever scrolls above it. + +This page covers the input editor, the status bar, and autocomplete. Modal dialogs have their own +guide: [Dialogs](dialogs.md). + +## The input editor + +dcli owns a single-line input editor: it handles typing, the caret, Backspace/Delete, Home/End, +left/right movement, and so on. You don't render it — you observe it and occasionally drive it. + +### Observing input + +The editor emits events on `terminal.Events`: + +- `InputChanged(string Text)` — the buffer changed (a character typed or deleted). +- `InputSubmitted(string Text)` — the user pressed Enter; carries the line as submitted. + +```csharp +await foreach (TerminalEvent evt in terminal.Events.ReadAllAsync()) +{ + switch (evt) + { + case InputSubmitted(var text): + Handle(text); + terminal.Input.Clear(); // editors don't auto-clear on submit; you decide + break; + + case InputChanged(var text): + terminal.Autocomplete.Show(ComputeCandidates(text)); + break; + } +} +``` + +Note that submission does **not** clear the buffer — that's a semantic choice, so it's yours. Call +`Input.Clear()` (or `SetText`) when you want it cleared. + +### Driving input programmatically + +`terminal.Input` is an [`IInput`](api-reference.md#iinput): + +| Method | Effect | +| --- | --- | +| `SetText(string)` | Replace the whole buffer; move the caret to the end. | +| `Clear()` | Empty the buffer; move the caret to position 0. | + +Both are **programmatic** edits and deliberately do **not** emit `InputChanged` — that event is +reserved for user-driven edits, so you won't get an echo loop when you set the text yourself. + +```csharp +terminal.Input.SetText("/help"); // pre-fill, e.g. from history +terminal.Input.Clear(); // after handling a submission +``` + +## The status bar + +`terminal.Status` is an [`IStatus`](api-reference.md#istatus). Set one or more rows; they render at +the very bottom and are **never truncated** by the height budget. + +```csharp +terminal.Status.SetRows( + new LineBuilder().Bold("connected").Text(" · ").Dim("12 turns").Build()); + +// multiple rows: +terminal.Status.SetRows( + Line.FromText("model: claude-opus-4-8"), + Line.FromText("tokens: 1,204 / 200,000")); + +terminal.Status.SetRows(); // empty call clears the status bar +``` + +`SetRows` has a `params Line[]` overload and an `IReadOnlyList` overload. Each call +**replaces** the entire status content (it's not additive). + +## Autocomplete + +The autocomplete dropdown appears below the input. dcli renders it and handles its navigation +(↑/↓ to move, Enter/Tab to accept, Esc to dismiss). **You** supply the candidates — typically in +response to an `InputChanged` event. + +```csharp +AutocompleteCandidate[] candidates = +[ + new("/help", new LineBuilder().Bold("/help").Dim(" show commands").Build()), + new("/clear", new LineBuilder().Bold("/clear").Dim(" clear scrollback").Build()), +]; + +terminal.Autocomplete.Show(candidates); +// ... +terminal.Autocomplete.Hide(); +``` + +[`AutocompleteCandidate`](api-reference.md#autocompletecandidate) has two parts: + +- `Display` — the styled `Line` shown in the dropdown row. +- `InsertText` — the text applied to the buffer **when the candidate is accepted**. Acceptance is + a **whole-buffer replace**: the entire input is replaced with `InsertText` and the caret moves to + the end. (Replacing only the typed prefix is not supported in this release.) + +[`IAutocomplete`](api-reference.md#iautocomplete) behavior: + +| Method | Notes | +| --- | --- | +| `Show(IReadOnlyList)` | No-op while a modal dialog is open (a dialog wins the single overlay slot). | +| `Hide()` | No-op unless the active overlay is the autocomplete. | + +### The round-trip pattern + +dcli never calls back into your code to *ask* for completions — that would run your code on the +render thread, which is forbidden. Instead it's a round-trip you drive: + +``` +user types ──▶ InputChanged event ──▶ you compute candidates ──▶ Autocomplete.Show +``` + +When the user accepts a candidate, dcli applies the `InsertText` and emits `InputChanged` again, +so your loop sees the new buffer naturally. If you want the dropdown to vanish after acceptance, +call `Hide()`. + +## Height budget + +The whole fixed region is capped at `MaxFixedHeight` rows. When content would exceed it, dialogs +and dropdowns scroll internally; the status bar is always shown in full. Remember the trade-off: a +tall fixed region shrinks the live window and **commits** scrollback above it +([commit horizon](concepts.md#the-commit-horizon)). + +## See also + +- [Dialogs](dialogs.md) — the modal overlay that shares the slot with autocomplete. +- [Input & events](events.md) — the full event stream and key encoding. +- [API reference](api-reference.md#fixed-region-surfaces). diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..ffb72e8 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,125 @@ +# Getting started + +This guide takes you from an empty project to a working interactive CLI. + +## Requirements + +- **.NET 10.0** or later (`net10.0`). +- A **modern VT-capable terminal**, attached to a real tty: + - **macOS** — Terminal.app, iTerm2, Ghostty, WezTerm. + - **Windows** — Windows Terminal, or any Win10 1809+ console host. Legacy `conhost.exe` is not supported. + - **Linux** — xterm, `xterm-256color`, or any xterm-class emulator. + +If stdout/stdin is redirected to a file or pipe, or `TERM=dumb`, or the platform isn't VT-capable, +`Terminal.StartAsync` throws [`TerminalNotSupportedException`](api-reference.md#terminalnotsupportedexception). + +## Install + +```bash +dotnet add package dcli --prerelease +``` + +The `--prerelease` flag is needed while the library is on a release-candidate line +(`0.2.0-rc.x`). For tests, also add the harness: + +```bash +dotnet add package Dcli.Testing --prerelease +``` + +## Your first program + +```csharp +using System.Text; +using Dcli; + +await using Terminal terminal = await Terminal.StartAsync(new TerminalOptions()); + +terminal.Status.SetRows(Line.FromText("Type a message and press Enter. Ctrl+C to quit.")); +terminal.Scrollback.Append("Welcome to the echo demo."); + +await foreach (TerminalEvent evt in terminal.Events.ReadAllAsync()) +{ + switch (evt) + { + case InputSubmitted(var text): + terminal.Scrollback.Append(new LineBuilder().Dim("> ").Text(text).Build()); + terminal.Input.Clear(); + break; + + case KeyPressed(var key) + when key.Modifiers == Modifiers.Ctrl + && key.Code.Kind == KeyCode.KeyCodeKind.UnicodeScalar + && key.Code.RuneValue == new Rune('c'): + return; // `await using` restores the terminal on the way out + } +} +``` + +Run it. Type, press Enter, watch lines flow into scrollback above the input. Press Ctrl+C to exit +— and notice your shell prompt comes back clean. + +### What just happened + +1. **`StartAsync`** entered raw mode and started the [render loop](concepts.md#the-single-writer-render-loop). + `TerminalOptions` controls the fixed-region height cap and frame rate; the defaults are sensible. +2. **`Status.SetRows`** and **`Scrollback.Append`** posted fire-and-forget commands. They returned + immediately; the loop painted them on its own thread. +3. **`Events.ReadAllAsync`** drained the outbound channel on *your* loop. `InputSubmitted` fires + when the user presses Enter; `KeyPressed` carries keys the editor didn't consume. +4. **Ctrl+C** arrived as a key event, not a signal — in raw mode dcli eats it and hands it to you. + See [Input & events](events.md#ctrlc-and-signals). +5. **`await using`** disposed the terminal at the end, restoring raw mode. See below. + +## The lifecycle + +```csharp +// 1. Start — enters raw mode, starts the loop. Throws TerminalNotSupportedException +// if the environment can't do VT. +await using Terminal terminal = await Terminal.StartAsync(new TerminalOptions +{ + MaxFixedHeight = 12, // cap the pinned region at 12 rows (null = ~50% of height, min 8) + MinFrameIntervalMs = 16, // ≈ 60 fps ceiling +}); + +// 2. Drive it — post commands, await dialogs, drain events. + +// 3. Dispose — `await using` calls DisposeAsync, which stops the loop, joins threads, +// and restores the terminal. If the loop crashed, DisposeAsync rethrows the fault +// (after restoring), so you learn about it. +``` + +The restore is guaranteed on **every** exit path — clean dispose, a crash on the loop thread, or +an OS signal/`ProcessExit`. You never need a `try/finally` to un-break the terminal; that's the +library's job. See [Architecture](architecture.md#guaranteed-terminal-restore). + +> **Always use `await using` (or a `try/finally` with `DisposeAsync`).** Leaking a live `Terminal` +> leaves raw mode on. + +## Depend on the interface, not the class + +For anything beyond a `Program.cs` smoke test, type your code against +[`ITerminal`](api-reference.md#iterminal), not the concrete `Terminal`: + +```csharp +public sealed class ChatController(ITerminal terminal) +{ + public async Task RunAsync(CancellationToken ct) + { + await foreach (TerminalEvent evt in terminal.Events.ReadAllAsync(ct)) + { + // ... + } + } +} +``` + +`ITerminal` exposes every surface and dialog method. Depending on it lets you substitute a fake or +the [`HeadlessTerminal`](testing.md) harness in tests, with no real tty and no static state to +reset. See [Testing](testing.md). + +## Next steps + +- [Styled text](styled-text.md) — build the `Line`s you append. +- [Scrollback](scrollback.md) — streaming blocks and collapsibles. +- [Dialogs](dialogs.md) — prompt the user with `await`. +- [The fixed region](fixed-region.md) — status bar and autocomplete. diff --git a/docs/scrollback.md b/docs/scrollback.md new file mode 100644 index 0000000..b4cdfff --- /dev/null +++ b/docs/scrollback.md @@ -0,0 +1,108 @@ +# Scrollback + +The scrollback is the append-mostly content stream — everything *above* the pinned fixed region. +You reach it through `terminal.Scrollback`, an [`IScrollback`](api-reference.md#iscrollback). It +offers three things: append a line, start a **live** (mutable, streaming) block, and start a +one-way **collapsible** block. + +All methods are **fire-and-forget**: they post a command and return before the loop paints. See +[the render loop](concepts.md#the-single-writer-render-loop). + +## Appending lines + +```csharp +terminal.Scrollback.Append("a plain line"); // string overload +terminal.Scrollback.Append(Line.FromText("styled", someStyle)); // Line overload +terminal.Scrollback.Append(new LineBuilder().Bold("done").Build()); +``` + +`Append(string)` is exactly `Append(Line.FromText(text))`. Each call adds one logical line; it +will be cell-wrapped to the terminal width at paint time. + +Appended lines enter the [live window](concepts.md#the-commit-horizon) and scroll upward as more +content arrives. Once a line passes the commit horizon it's frozen into the terminal's native +scrollback — you can't rewrite it after that, which is exactly why a plain `Append` is the right +tool for finished output. + +## Live blocks — streaming, mutable content + +When you're streaming a response token-by-token, or building a block whose final form you don't +know yet, use a **live block**. It stays mutable as long as it remains in the live window. + +```csharp +ILiveBlock block = terminal.Scrollback.BeginLive(); + +foreach (string token in StreamTokensAsync()) + block.AppendText(token); // grows the block, repainting as it goes + +block.Commit(); // freeze it; it flows into native scrollback +``` + +[`ILiveBlock`](api-reference.md#iliveblock) has three methods: + +| Method | Effect | +| --- | --- | +| `AppendText(string)` | Append to the block's accumulation buffer. Newlines split it into rows. No-op after `SetContent` or `Commit`. | +| `SetContent(IReadOnlyList)` | Replace the whole block wholesale with styled lines. After this, `AppendText` is a no-op. | +| `Commit()` | Freeze the block and remove it from the live window on the next paint; its final rows flow into scrollback. | + +`AppendText` is for raw streaming (think model tokens). `SetContent` is for "I've now parsed the +buffer into proper styled lines, swap them in" — e.g. streaming raw Markdown text, then replacing +it with rendered lines once a block completes: + +```csharp +ILiveBlock block = terminal.Scrollback.BeginLive(); + +await foreach (string chunk in modelStream) + block.AppendText(chunk); // show raw text as it arrives + +block.SetContent(RenderMarkdown(fullText)); // swap in the formatted version +block.Commit(); +``` + +A typical loop has at most one live block open at a time. After `Commit()`, the handle is inert — +start a new block for the next streamed item. + +## Collapsibles — one-way expandable blocks + +A **collapsible** shows a one-line summary and hides a body that can be revealed once. Use it for +"thinking" traces, verbose logs, tool output — anything the user can optionally expand. + +```csharp +ICollapsible thinking = terminal.Scrollback.BeginCollapsible( + summary: new LineBuilder().Dim("▸ thinking (24 lines hidden)").Build(), + hiddenLines: reasoningLines); // IReadOnlyList, snapshotted now + +// later, e.g. when the user presses a key you've mapped to "expand": +thinking.Expand(); +``` + +[`ICollapsible`](api-reference.md#icollapsible) has a single method, `Expand()`: + +- It is **one-way and idempotent** — expanded stays expanded; calling it again does nothing. +- The hidden lines are an **immutable snapshot taken at `BeginCollapsible` time**. You can't add to + them afterward; build the full body first. + +### Why one-way? + +Inline rendering means a block's height can only grow, never shrink — there's no way to push +already-committed content back *up* the terminal. So collapsibles expand but never re-collapse. +This keeps height monotonic and the model simple. If a collapsible has already scrolled past the +commit horizon when you call `Expand()`, the hidden lines are reprinted into the native scrollback +flow instead of expanding in place (a no-op-looking call still does the right thing). See +[Architecture](architecture.md#one-way-collapsibles). + +## What scrollback is *not* + +- It's not a log you can rewrite. Committed lines are the terminal's; only the live window is + mutable. +- It's not where interactive widgets go — input, status, dialogs, and dropdowns live in + [the fixed region](fixed-region.md). +- It doesn't parse anything. You hand it `Line`s (or strings); converting Markdown/JSON/RPC into + lines is your job ([mechanics vs. semantics](concepts.md#mechanics-vs-semantics--the-dcliconsumer-boundary)). + +## See also + +- [Styled text](styled-text.md) — building the lines you append. +- [Core concepts: the commit horizon](concepts.md#the-commit-horizon) — why "live" and "committed" differ. +- [API reference](api-reference.md#iscrollback). diff --git a/docs/styled-text.md b/docs/styled-text.md new file mode 100644 index 0000000..175c0bc --- /dev/null +++ b/docs/styled-text.md @@ -0,0 +1,155 @@ +# Styled text + +Everything dcli renders — scrollback lines, status rows, dialog items, autocomplete rows — is +built from one small set of immutable types. There is **no markup language**: a string like +`"[bold]"` is literal text, never a directive. You compose styling programmatically. + +## The primitives + +``` +Segment = a run of text + one Style +Line = an ordered list of Segments (one logical line) +Style = optional foreground Color + optional background Color + Format flags +Color = 16 named ANSI | a 256-palette index | 24-bit RGB +Format = [Flags]: Bold, Italic, Underline, Dim, Reverse, Strikethrough +``` + +### Segment + +An immutable run of text with a single `Style` applied uniformly. + +```csharp +var plain = new Segment("hello"); +var styled = new Segment("hello", new Style(Foreground: Color.Named(Color.AnsiColor.Cyan))); +``` + +Text is stored and emitted **verbatim** — no escape sequences or control characters are +interpreted. + +### Line + +An immutable, ordered list of `Segment`s forming one logical line. Two `Line`s are equal when they +hold the same segments in the same order (structural equality — handy for golden-frame tests). + +The quickest way to make a single-style line: + +```csharp +Line label = Line.FromText("plain text"); +Line warning = Line.FromText("careful!", new Style(Foreground: Color.Named(Color.AnsiColor.Yellow))); +``` + +For multi-style lines, use [`LineBuilder`](#linebuilder). + +> **There is no implicit `string` → `Line` conversion**, deliberately — it would let a bare string +> slip in where a styled line was meant, silently dropping styling. Call `Line.FromText(s)` +> explicitly. (Many APIs, like `Scrollback.Append` and the dialog requests, also accept raw +> strings directly as a convenience and convert for you.) + +### Style + +```csharp +public readonly record struct Style( + Color? Foreground = null, // null → terminal default + Color? Background = null, // null → terminal default + Format Format = Format.None); +``` + +`default(Style)` (and `new Style()`) means "terminal defaults, no attributes". Because it's a +record struct, you can `with`-copy it: + +```csharp +var baseStyle = new Style(Foreground: Color.Named(Color.AnsiColor.Green)); +var emphatic = baseStyle with { Format = Format.Bold }; +``` + +### Format + +A `[Flags]` enum — combine with `|`: + +```csharp +var s = new Style(Format: Format.Bold | Format.Underline); +``` + +| Flag | Effect | +| --- | --- | +| `None` | no attributes | +| `Bold` | bold / increased intensity | +| `Italic` | italic | +| `Underline` | underline | +| `Dim` | dim / faint | +| `Reverse` | swap foreground and background | +| `Strikethrough` | crossed-out | + +Actual rendering depends on the terminal; most honor bold/dim/underline/reverse, fewer honor +italic and strikethrough. + +### Color + +A discriminated value with three representations. Construct with a factory; never with `new`: + +```csharp +Color red = Color.Named(Color.AnsiColor.Red); // one of 16 ANSI colors +Color grey = Color.FromIndex(240); // 256-color palette index 0–255 +Color brand = Color.FromRgb(0x7C, 0x3A, 0xED); // 24-bit truecolor +``` + +Inspect via `Kind` and the matching accessor (`NamedValue` / `IndexValue` / `R`,`G`,`B`); reading +the wrong accessor for the stored kind throws: + +```csharp +if (brand.Kind == Color.ColorKind.Rgb) + Console.WriteLine($"#{brand.R:X2}{brand.G:X2}{brand.B:X2}"); +``` + +The 16 `AnsiColor` names are `Black`, `Red`, `Green`, `Yellow`, `Blue`, `Magenta`, `Cyan`, +`White`, and their `Bright*` variants. + +> **Truecolor fallback.** dcli detects terminal capabilities at startup; on a terminal without +> 24-bit color support, RGB colors are emitted in the best representation the terminal accepts. +> When in doubt, named colors are universally safe. + +## LineBuilder + +A fluent builder for multi-segment lines. Each method appends a segment and returns the builder; +`Build()` returns the finished `Line`. **Don't reuse a builder after calling `Build()`.** + +```csharp +Line line = new LineBuilder() + .Dim("✻ ") + .Bold("Thinking") + .Text(" — ") + .Fg("42 tokens", Color.Named(Color.AnsiColor.Cyan)) + .Build(); +``` + +| Method | Appends a segment that is… | +| --- | --- | +| `Text(s)` | unstyled (terminal defaults) | +| `Append(s, style)` | styled with an explicit `Style` | +| `Bold(s)` / `Italic(s)` / `Underline(s)` / `Dim(s)` / `Reverse(s)` / `Strikethrough(s)` | the corresponding `Format` | +| `Fg(s, color)` | the given foreground color | +| `Bg(s, color)` | the given background color | + +The shortcut methods apply exactly one attribute. For combinations (e.g. bold **and** colored), +build the `Style` yourself and use `Append`: + +```csharp +Line line = new LineBuilder() + .Append("error", new Style( + Foreground: Color.Named(Color.AnsiColor.BrightRed), + Format: Format.Bold)) + .Text(": connection refused") + .Build(); +``` + +## A note on sanitization + +Segment text is emitted verbatim. If your content comes from an untrusted source and may contain +raw VT escape sequences, **sanitize it before wrapping it in a `Segment`** — dcli does not strip +control characters from segment text in the current release. + +## See also + +- [Scrollback](scrollback.md) — where most of your lines go. +- [The fixed region](fixed-region.md) — status rows and autocomplete display lines. +- [API reference](api-reference.md#styled-text) — exact signatures. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..24c4af3 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,183 @@ +# Testing + +A renderer you can only exercise against a real TTY is a renderer you can't test. dcli is built so +that terminal UIs are testable two ways, neither of which needs a terminal: + +- **Tier A — fake the façade.** Your code depends on [`ITerminal`](api-reference.md#iterminal); in a + unit test you substitute a hand-written fake. Best for testing *your controller logic*. +- **Tier B — the headless harness.** The `Dcli.Testing` package ships + [`HeadlessTerminal`](#headlessterminal), which runs the **real** render engine, input parser, and + fixed-region composer against in-memory OS edges. Best for *integration* tests that assert on what + actually renders. It's the same harness dcli uses to test itself. + +```bash +dotnet add package Dcli.Testing --prerelease +``` + +## Tier A — faking `ITerminal` + +`ITerminal` has no static state and exposes every surface and dialog method, so a fake is +straightforward. Because all the event/result types are publicly constructible, you can synthesize +events and dialog results without a terminal. + +```csharp +public sealed class FakeTerminal : ITerminal +{ + private readonly Channel _events = Channel.CreateUnbounded(); + public List Appended { get; } = []; + + public ChannelReader Events => _events.Reader; + public (int Columns, int Rows) GetTerminalSize() => (80, 24); + + // Drive your controller by writing events the way the real terminal would: + public void EmitSubmit(string text) => _events.Writer.TryWrite(new InputSubmitted(text)); + + // Stub the dialogs to return canned answers: + public Task> SelectAsync(SelectRequest req, CancellationToken ct = default) + => Task.FromResult(new DialogResult(DialogOutcome.Submitted, 0)); + // ... implement the remaining members (Scrollback/Input/Status/Autocomplete + the other dialogs) +} +``` + +Then test your controller in isolation: + +```csharp +var terminal = new FakeTerminal(); +var controller = new ChatController(terminal); +var run = controller.RunAsync(CancellationToken.None); + +terminal.EmitSubmit("hello"); +// assert the controller did what you expect with the fake's recorded calls +``` + +This is the right tool when the thing under test is *your* logic and you don't care about pixel-level +rendering. + +## Tier B — `HeadlessTerminal` + +When you want to assert on what dcli actually paints — that a key edited the buffer, that a dialog +opened, that content committed on resize — drive the real engine through `HeadlessTerminal`. + +```csharp +using Dcli; +using Dcli.Testing; + +await using HeadlessTerminal h = await HeadlessTerminal.StartAsync( + new HeadlessTerminalOptions { InitialColumns = 80, InitialRows = 24 }); + +// Drive the real ITerminal exactly as production code does: +h.Terminal.Scrollback.Append("hello"); +h.Terminal.Status.SetRows(Line.FromText("ready")); + +await h.SettleAsync(); // drain pending work, wait for one coalesced frame +FrameSnapshot frame = h.Snapshot; // immutable snapshot of what was painted + +Assert.Contains(frame.LiveWindowRows, line => line == Line.FromText("hello")); +``` + +### Scripting input + +`HeadlessTerminal` posts through the same channels production uses — it never pokes model state +directly. + +| Method | Use | +| --- | --- | +| `Type(string)` | Types text rune-by-rune as `KeyCode.FromRune` events (printables only). | +| `SendKey(KeyEvent)` | Injects one key event — use for named keys (Enter, Tab, arrows, …). | +| `Paste(string)` | Injects a bracketed-paste block. | +| `Feed(ReadOnlySpan)` | Feeds **raw bytes** through the real VT parser (test the parser itself). | +| `Resize(int columns, int rows)` | Fires a resize through the same path as POSIX `SIGWINCH`. | + +```csharp +h.Type("/hel"); +h.SendKey(new KeyEvent(KeyCode.Named(NamedKey.Enter), Modifiers.None)); +await h.SettleAsync(); +``` + +### Settling deterministically + +`SettleAsync()` drains all pending inbound work and waits for exactly one coalesced frame (when the +model is dirty), **without advancing wall-clock time**. Call it after scripting input, before +reading `Snapshot`. + +- Event-level scripting (`Type` / `SendKey` / `Paste` / `Resize`) posts directly to the loop, so a + **single** `SettleAsync` makes it visible. +- `Feed` enqueues raw bytes the independent reader thread decodes asynchronously — you may need + **two** `SettleAsync` calls after a large feed: one to let the reader drain the bytes, one to let + the resulting events reach the loop. + +### Asserting on a dialog + +Dialogs are awaitable, so kick one off, settle, drive its keys, and await the result: + +```csharp +Task> pending = h.Terminal.SelectAsync( + new SelectRequest([Line.FromText("A"), Line.FromText("B"), Line.FromText("C")], "pick")); + +await h.SettleAsync(); +Assert.Equal(OverlayKind.Dialog, h.Snapshot.Overlay.Kind); + +h.SendKey(new KeyEvent(KeyCode.Named(NamedKey.Down), Modifiers.None)); // move to "B" +h.SendKey(new KeyEvent(KeyCode.Named(NamedKey.Enter), Modifiers.None)); // submit +await h.SettleAsync(); + +DialogResult result = await pending; +Assert.Equal(DialogOutcome.Submitted, result.Outcome); +Assert.Equal(1, result.Value); +``` + +### Cadence tests with the virtual clock + +For tests that exercise throttling/coalescing, set a non-zero `MinFrameInterval` and advance the +[`VirtualClock`](api-reference.md#virtualclock) yourself — no real sleeps: + +```csharp +var clock = new VirtualClock(); +await using var h = await HeadlessTerminal.StartAsync(new HeadlessTerminalOptions +{ + MinFrameInterval = TimeSpan.FromMilliseconds(16), + Clock = clock, +}); + +h.Terminal.Scrollback.Append("a"); +clock.Advance(TimeSpan.FromMilliseconds(16)); // release the throttled paint +await h.SettleAsync(); +``` + +## Frame snapshots + +[`FrameSnapshot`](api-reference.md#framesnapshot) is the immutable logical frame from the most +recent paint. Key fields: + +| Field | Meaning | +| --- | --- | +| `LiveWindowRows` | The re-renderable rows above the fixed region. | +| `FixedRegionRows` | Input editor, status, and any overlay. | +| `NewlyCommittedRows` | Rows that crossed the commit horizon on this frame. | +| `Caret` | `(Row, Col)` or `null` (hidden / modal dialog active). | +| `IsCursorVisible` | Hardware cursor visibility at end of frame. | +| `Size` | `(Columns, Rows)` at snapshot time. | +| `Overlay` | An [`OverlayDescriptor`](api-reference.md#overlaydescriptor): `Kind` (`None`/`Autocomplete`/`Dialog`/`Input`), `SelectedIndex`, `VisibleRowCount`, `InputText`, `IsSecret`. | + +Before the first paint, `Snapshot` returns an empty frame (size `(0,0)`, empty lists) — it never +throws. + +### Golden-frame assertions + +Assert on *structure*, not raw ANSI. `FrameSnapshotPrinter.PrettyPrint` renders a stable, +style-stripped, ASCII-bordered view ideal for golden strings and readable diffs: + +```csharp +string rendered = FrameSnapshotPrinter.PrettyPrint(h.Snapshot); +// Compare against a stored golden string, or assert on substrings. +``` + +The format lists any newly-committed rows first (`[committed N]`), then a box with numbered +live-window rows, a `--- horizon ---` separator, the fixed-region rows, and a summary line for +caret + overlay state. Style information is intentionally stripped so golden strings stay readable. + +## See also + +- [Getting started: depend on the interface](getting-started.md#depend-on-the-interface-not-the-class) +- [Input & events](events.md) — the event vocabulary you'll script and assert on. +- [API reference: testing harness](api-reference.md#testing-harness-dclitesting) diff --git a/src/Dcli/README.md b/src/Dcli/README.md index 6c2288d..9a42ee3 100644 --- a/src/Dcli/README.md +++ b/src/Dcli/README.md @@ -131,8 +131,15 @@ Drain `ITerminal.Events` (`ChannelReader`) on a background task. ## Testing -`Dcli.Testing` — a headless harness that runs the full library without a real terminal — -is shipping as a separate NuGet package in the next release. +The companion `Dcli.Testing` package ships a headless harness — `HeadlessTerminal` — that runs +the full render engine, input parser, and fixed-region composer against in-memory OS edges, so +you can drive and assert on terminal UIs without a real tty. See the +[testing guide](https://github.com/daemonicai/dcli/blob/main/docs/testing.md). + +## Documentation + +Full guides and a complete API reference live on GitHub: +**https://github.com/daemonicai/dcli#documentation** ## License