diff --git a/CHANGELOG.md b/CHANGELOG.md index f4521f0..3852775 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **`IInputPreamble` / `ITerminal.InputPreamble`** — a persistent multi-row band rendered immediately above the input editor. Set via `terminal.InputPreamble.SetRows(...)` using the same `Line`/`LineBuilder` API as status. Persists across input submissions; clear with `SetRows()` (no args). Truncates under a constrained `MaxFixedHeight` before the input editor loses rows; status rows remain sacred. No overlay/intercept — purely presentational. +- **`IInput.SetPrompt(Line)` / `IInput.SetPrompt(string)`** — sets a styled prefix that is rendered before the editable text on the first row of the input editor. The prefix is purely presentational: submitted values and `InputChanged` payloads contain only the user-typed text. Clears with an empty line (`SetPrompt(new Line([]))`) or via the string overload (`SetPrompt("")` or `SetPrompt(null)`). Persists across submissions and `Clear()`. First-row wrap capacity is reduced by the prompt width; continuation rows use full width. + ### Security - **VT-escape injection gap closed.** Consumer text passed to `Segment`, `Line.FromText`, or diff --git a/openspec/changes/input-prompt-prefix/.openspec.yaml b/openspec/changes/archive/2026-06-19-input-prompt-prefix/.openspec.yaml similarity index 100% rename from openspec/changes/input-prompt-prefix/.openspec.yaml rename to openspec/changes/archive/2026-06-19-input-prompt-prefix/.openspec.yaml diff --git a/openspec/changes/archive/2026-06-19-input-prompt-prefix/DEVLOG.md b/openspec/changes/archive/2026-06-19-input-prompt-prefix/DEVLOG.md new file mode 100644 index 0000000..eb27c86 --- /dev/null +++ b/openspec/changes/archive/2026-06-19-input-prompt-prefix/DEVLOG.md @@ -0,0 +1,49 @@ +# DEVLOG — input-prompt-prefix + +## Status + +| Section | Title | Status | +|---------|------------------------|----------| +| 1 | Public surface | complete | +| 2 | Command + editor model | complete | +| 3 | Tests | complete | +| 4 | Sample / demo | complete | +| 5 | Validation & packaging | complete | + +## Context + +- Base branch: `main` at commit after `persistent-input-preamble` merge (version `0.2.0-rc.5`). +- Target version: `0.2.0-rc.6`. +- Change branch: `change/input-prompt-prefix`. + +## Section 1+2 — Public surface & Command+editor model + +Briefed worker to implement sections 1 and 2 together (they are inseparable: the public surface +posts `SetPromptCommand` which lives inside `InputSurface`, and the command mutates `TextBuffer` +which needs the prompt field and render logic). + +### Key design notes + +- `SetPromptCommand` is a private inner class of `InputSurface` (mirrors `SetTextCommand`/`ClearCommand`). +- `TextBuffer.SetPrompt(Line?)` stores the prompt; null/empty = no prefix (default). +- Render changes are in `BuildVisualRows`: row 0 gets a reduced width (`width - promptWidth`); + the prompt is prepended to row 0's `Line` via segment prepend; continuation rows use full width. +- Caret column offset: when `VisualRow == 0`, `VisualCol += promptWidth` in `ComputeVisualPositionFromRows`. +- The `Line` type is used to carry styled segments; `DisplayWidth` measures the prompt width. +- `MoveHome`/`MoveEnd` on row 0 must not include the prompt columns in the buffer's char-index + mapping — the prompt is not in the buffer. The char-index mapping is unchanged; only the + reported visual column is offset. + +## Section 5 — Validation & packaging + +Gate results (all pass): +- `dotnet build` — clean, 0 warnings, 0 errors (890 tests compiled). +- `dotnet test` — 890 passed, 0 failed, 0 skipped. +- `dotnet format --verify-no-changes` — clean (no output). +- `openspec validate input-prompt-prefix --strict` — "Change 'input-prompt-prefix' is valid". + +Actions taken: +- Version bumped `0.2.0-rc.5` → `0.2.0-rc.6` in `src/Dcli/Dcli.csproj` and `src/Dcli.Testing/Dcli.Testing.csproj`. +- CHANGELOG.md updated: added `IInput.SetPrompt` bullet under `[Unreleased] ### Added`. + +Task 5.8 (dmon coordination) is informational — no dcli code change required. diff --git a/openspec/changes/input-prompt-prefix/design.md b/openspec/changes/archive/2026-06-19-input-prompt-prefix/design.md similarity index 100% rename from openspec/changes/input-prompt-prefix/design.md rename to openspec/changes/archive/2026-06-19-input-prompt-prefix/design.md diff --git a/openspec/changes/input-prompt-prefix/proposal.md b/openspec/changes/archive/2026-06-19-input-prompt-prefix/proposal.md similarity index 100% rename from openspec/changes/input-prompt-prefix/proposal.md rename to openspec/changes/archive/2026-06-19-input-prompt-prefix/proposal.md diff --git a/openspec/changes/input-prompt-prefix/specs/fixed-region/spec.md b/openspec/changes/archive/2026-06-19-input-prompt-prefix/specs/fixed-region/spec.md similarity index 100% rename from openspec/changes/input-prompt-prefix/specs/fixed-region/spec.md rename to openspec/changes/archive/2026-06-19-input-prompt-prefix/specs/fixed-region/spec.md diff --git a/openspec/changes/archive/2026-06-19-input-prompt-prefix/tasks.md b/openspec/changes/archive/2026-06-19-input-prompt-prefix/tasks.md new file mode 100644 index 0000000..f244d79 --- /dev/null +++ b/openspec/changes/archive/2026-06-19-input-prompt-prefix/tasks.md @@ -0,0 +1,35 @@ +## 1. Public surface + +- [x] 1.1 Add `SetPrompt(Line)` and `SetPrompt(string)` to `IInput` (string via `Line.FromText`); doc-comment that empty/`null` clears and that it does not emit `InputChanged` +- [x] 1.2 Implement both overloads on `InputSurface`: each posts `_loop.Post(new SetPromptCommand(line))` +- [x] 1.3 Remove the `Prompt` bullet from the `InputSurface` "Documented gaps" remarks (keep the `ReadOnly` bullet) + +## 2. Command + editor model + +- [x] 2.1 Add `SetPromptCommand : ILoopCommand` whose `Apply(model)` calls `model.FixedRegion.Editor.SetPrompt(line)` and `model.MarkDirty()` (mirror `SetTextCommand`) +- [x] 2.2 Add a prompt field to the owned editor (`TextBuffer`/editor) with a `SetPrompt` mutator; empty/`null` means no prefix +- [x] 2.3 Render the prefix on the editor's first visual row: editable text starts at `promptWidth`, caret column offset by `promptWidth` on row 0, first-row capacity = `width - promptWidth`; continuation rows begin at column 0 (no repeat). Ensure the prefix is never inserted into buffer contents + +## 3. Tests (tests/Dcli.Tests, via HeadlessTerminal) + +- [x] 3.1 Prefix renders before the editable text on the first row +- [x] 3.2 Caret parks immediately after the prefix when the buffer is empty +- [x] 3.3 No prompt set ⇒ editor renders identically to v1 (regression guard) +- [x] 3.4 Prompt persists across multiple submissions and across `Clear()` +- [x] 3.5 Submitted value and `InputChanged` payload exclude the prefix; history stores only user text +- [x] 3.6 First-row wrapping uses `width - promptWidth`; continuation row starts at column 0 + +## 4. Sample / demo + +- [x] 4.1 Update a sample (or the demo) to set an input prompt (e.g. `SetPrompt("❯ ")`) so the surface is exercised end-to-end + +## 5. Validation & packaging + +- [x] 5.1 `dotnet build` clean (analyzers warnings-as-errors; nullable enabled) +- [x] 5.2 `dotnet test` all green +- [x] 5.3 `dotnet format --verify-no-changes` clean +- [x] 5.4 `openspec validate input-prompt-prefix --strict` passes +- [x] 5.5 Version bump: `src/Dcli/Dcli.csproj` and `src/Dcli.Testing/Dcli.Testing.csproj` to the next preview revision after `persistent-input-preamble` (`0.2.0-rc.6`, or a shared bump if applied together) +- [x] 5.6 Update `CHANGELOG.md` with the new `SetPrompt` surface +- [x] 5.7 Keep a `DEVLOG.md` in the change directory while applying +- [x] 5.8 dmon coordination: dmon's Terminal UX change consumes `ITerminal.Input.SetPrompt(...)` for the `❯` glyph alongside `ITerminal.InputPreamble` diff --git a/openspec/changes/input-prompt-prefix/tasks.md b/openspec/changes/input-prompt-prefix/tasks.md deleted file mode 100644 index 26386d6..0000000 --- a/openspec/changes/input-prompt-prefix/tasks.md +++ /dev/null @@ -1,35 +0,0 @@ -## 1. Public surface - -- [ ] 1.1 Add `SetPrompt(Line)` and `SetPrompt(string)` to `IInput` (string via `Line.FromText`); doc-comment that empty/`null` clears and that it does not emit `InputChanged` -- [ ] 1.2 Implement both overloads on `InputSurface`: each posts `_loop.Post(new SetPromptCommand(line))` -- [ ] 1.3 Remove the `Prompt` bullet from the `InputSurface` "Documented gaps" remarks (keep the `ReadOnly` bullet) - -## 2. Command + editor model - -- [ ] 2.1 Add `SetPromptCommand : ILoopCommand` whose `Apply(model)` calls `model.FixedRegion.Editor.SetPrompt(line)` and `model.MarkDirty()` (mirror `SetTextCommand`) -- [ ] 2.2 Add a prompt field to the owned editor (`TextBuffer`/editor) with a `SetPrompt` mutator; empty/`null` means no prefix -- [ ] 2.3 Render the prefix on the editor's first visual row: editable text starts at `promptWidth`, caret column offset by `promptWidth` on row 0, first-row capacity = `width - promptWidth`; continuation rows begin at column 0 (no repeat). Ensure the prefix is never inserted into buffer contents - -## 3. Tests (tests/Dcli.Tests, via HeadlessTerminal) - -- [ ] 3.1 Prefix renders before the editable text on the first row -- [ ] 3.2 Caret parks immediately after the prefix when the buffer is empty -- [ ] 3.3 No prompt set ⇒ editor renders identically to v1 (regression guard) -- [ ] 3.4 Prompt persists across multiple submissions and across `Clear()` -- [ ] 3.5 Submitted value and `InputChanged` payload exclude the prefix; history stores only user text -- [ ] 3.6 First-row wrapping uses `width - promptWidth`; continuation row starts at column 0 - -## 4. Sample / demo - -- [ ] 4.1 Update a sample (or the demo) to set an input prompt (e.g. `SetPrompt("❯ ")`) so the surface is exercised end-to-end - -## 5. Validation & packaging - -- [ ] 5.1 `dotnet build` clean (analyzers warnings-as-errors; nullable enabled) -- [ ] 5.2 `dotnet test` all green -- [ ] 5.3 `dotnet format --verify-no-changes` clean -- [ ] 5.4 `openspec validate input-prompt-prefix --strict` passes -- [ ] 5.5 Version bump: `src/Dcli/Dcli.csproj` and `src/Dcli.Testing/Dcli.Testing.csproj` to the next preview revision after `persistent-input-preamble` (`0.2.0-rc.6`, or a shared bump if applied together) -- [ ] 5.6 Update `CHANGELOG.md` with the new `SetPrompt` surface -- [ ] 5.7 Keep a `DEVLOG.md` in the change directory while applying -- [ ] 5.8 dmon coordination: dmon's Terminal UX change consumes `ITerminal.Input.SetPrompt(...)` for the `❯` glyph alongside `ITerminal.InputPreamble` diff --git a/openspec/specs/fixed-region/spec.md b/openspec/specs/fixed-region/spec.md index a628b0d..a6e03ca 100644 --- a/openspec/specs/fixed-region/spec.md +++ b/openspec/specs/fixed-region/spec.md @@ -90,6 +90,44 @@ When `IsSecret = false`, the editor SHALL render the seeded default as plain tex - **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: Input editor prompt prefix + +The owned input editor SHALL support an optional, consumer-set prompt prefix rendered immediately before the editable region on the editor's first visual row. The persistent input surface (`ITerminal.Input`) SHALL expose `SetPrompt(Line)` and `SetPrompt(string)` (the latter converted via `Line.FromText`); an empty or `null` prompt SHALL render no prefix, which is the default (unchanged v1 behaviour). `SetPrompt` SHALL NOT emit `InputChanged`. The prompt SHALL persist across renders, submissions, history recall, and buffer `Clear` until changed. + +The caret SHALL be positioned immediately after the prefix while it is on the first row, accounting for the prefix's display width; the first row's text capacity SHALL be the available width reduced by the prefix width, and wrapping on the first row SHALL be computed against that reduced width. Wrapped continuation rows SHALL begin at column 0 and SHALL NOT repeat the prefix. + +The prompt prefix SHALL be presentational chrome: it SHALL NOT be part of the editor buffer, SHALL NOT be returned by `Submit`, SHALL NOT appear in `InputChanged` payloads, and SHALL NOT be stored in input history. + +#### Scenario: Prompt renders before the editable text + +- **WHEN** the consumer calls `ITerminal.Input.SetPrompt("❯ ")` and the user types `hello` +- **THEN** the editor's first row paints `❯ hello` with the prompt in its configured style + +#### Scenario: Caret sits after the prefix + +- **WHEN** a prompt is set and the buffer is empty +- **THEN** the hardware cursor parks at the column immediately after the prefix, not at column 0 + +#### Scenario: Empty prompt renders no prefix + +- **WHEN** no prompt is set (or `SetPrompt` is called with an empty/`null` value) +- **THEN** the editor renders exactly as v1 with the editable text starting at column 0 + +#### Scenario: Prompt persists across submissions + +- **WHEN** a prompt is set once and the user submits several inputs in succession +- **THEN** the prefix remains rendered on each new input line without being re-set + +#### Scenario: Prompt is not part of submitted text + +- **WHEN** a prompt is set and the user types `hello` and submits +- **THEN** the submitted value is `hello` (the prefix is not included), and input history stores only `hello` + +#### Scenario: First-row wrapping accounts for the prefix width + +- **WHEN** a prompt is set and the user types text that exceeds `width - promptWidth` on the first row +- **THEN** the text wraps at the reduced first-row width and the continuation row begins at column 0 + ### Requirement: Fixed-region height budget The fixed region SHALL have a `MaxHeight` equal to `clamp(appSet ?? 50% of rows, 8, rows)`, SHALL consume only the rows its content needs up to that cap, and SHALL scroll components internally beyond the cap. diff --git a/samples/Dcli.Demo/Program.cs b/samples/Dcli.Demo/Program.cs index cdb3e4c..68f1a44 100644 --- a/samples/Dcli.Demo/Program.cs +++ b/samples/Dcli.Demo/Program.cs @@ -34,6 +34,8 @@ t.InputPreamble.SetRows(Line.Dim("── dcli demo ─────────────────────────────────────────────────────────────")); t.Scrollback.Append(Line.Dim(" InputPreamble set -- a rule above the input editor.")); +t.Input.SetPrompt("❯ "); +t.Scrollback.Append(Line.Dim(" Input prompt set to ❯.")); await Task.Delay(TimeSpan.FromMilliseconds(800)); @@ -241,7 +243,8 @@ t.Status.SetRows(Line.Fg("DONE - Tour complete - press Ctrl+C to exit, or wait 3s.", Color.Named(Color.AnsiColor.Green))); t.InputPreamble.SetRows(); -t.Scrollback.Append(Line.Dim(" InputPreamble cleared.")); +t.Input.SetPrompt(""); +t.Scrollback.Append(Line.Dim(" InputPreamble and input prompt cleared.")); t.Scrollback.Append(Line.Bold("--- Tour complete ---")); t.Scrollback.Append(Line.Fg("All dcli public surfaces exercised successfully.", Color.Named(Color.AnsiColor.BrightGreen))); diff --git a/src/Dcli.Testing/Dcli.Testing.csproj b/src/Dcli.Testing/Dcli.Testing.csproj index 1e1008c..6a2d0ff 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.5 + 0.2.0-rc.6 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 e425f69..1e3689c 100644 --- a/src/Dcli/Dcli.csproj +++ b/src/Dcli/Dcli.csproj @@ -9,7 +9,7 @@ true dcli - 0.2.0-rc.5 + 0.2.0-rc.6 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/ITerminal.cs b/src/Dcli/ITerminal.cs index 22a14d5..9645a33 100644 --- a/src/Dcli/ITerminal.cs +++ b/src/Dcli/ITerminal.cs @@ -73,6 +73,23 @@ public interface IInput /// Does not emit . /// void Clear(); + + /// + /// Sets the prompt prefix rendered immediately before the editable region on the first visual row. + /// Passing an empty or null-equivalent line clears the prompt and renders no prefix. + /// Does not emit . + /// + /// The styled line to render as the prompt prefix. + void SetPrompt(Line line); + + /// + /// Sets the prompt prefix to a plain-text string rendered immediately before the editable region. + /// Passing a null or empty string clears the prompt and renders no prefix. + /// Shorthand equivalent to SetPrompt(Line.FromText(text)). + /// Does not emit . + /// + /// The plain text to use as the prompt prefix. Null or empty clears the prompt. + void SetPrompt(string text); } /// diff --git a/src/Dcli/InputSurface.cs b/src/Dcli/InputSurface.cs index d58a284..a7811f3 100644 --- a/src/Dcli/InputSurface.cs +++ b/src/Dcli/InputSurface.cs @@ -14,16 +14,15 @@ namespace Dcli; /// Thread safety: all methods are safe to call from any thread. /// /// -/// No InputChanged on programmatic mutation: -/// and do NOT emit . That event is reserved for -/// user-driven edits so that a consumer reacting to (e.g. to update -/// autocomplete candidates) cannot trigger a feedback loop from its own programmatic writes. +/// No InputChanged on programmatic mutation: , +/// , and do NOT emit . +/// That event is reserved for user-driven edits so that a consumer reacting to +/// (e.g. to update autocomplete candidates) cannot trigger a feedback +/// loop from its own programmatic writes. /// /// /// Documented gaps: /// -/// Prompt — a prompt prefix shown before the editable region — is not in the -/// §10 model and is deferred to a later §10 refinement pass. /// ReadOnly — preventing user edits — is similarly deferred. /// /// @@ -57,6 +56,28 @@ public void Clear() _loop.Post(new ClearCommand()); } + /// + /// Sets the prompt prefix rendered immediately before the editable region on the first visual row. + /// An empty line clears the prompt and renders no prefix. + /// Does not emit . + /// + public void SetPrompt(Line line) + { + _loop.Post(new SetPromptCommand(line)); + } + + /// + /// Sets the prompt prefix to a plain-text string. + /// Null or empty clears the prompt and renders no prefix. + /// Does not emit . + /// + /// The plain text to use as the prompt prefix. Null or empty clears the prompt. + public void SetPrompt(string text) + { + Line line = string.IsNullOrEmpty(text) ? new Line([]) : Line.FromText(text); + _loop.Post(new SetPromptCommand(line)); + } + // ── Commands ─────────────────────────────────────────────────────────────── private sealed class SetTextCommand : ILoopCommand @@ -80,4 +101,17 @@ void ILoopCommand.Apply(RenderModel model) model.MarkDirty(); } } + + private sealed class SetPromptCommand : ILoopCommand + { + private readonly Line _line; + + internal SetPromptCommand(Line line) => _line = line; + + void ILoopCommand.Apply(RenderModel model) + { + model.FixedRegion.Editor.SetPrompt(_line); + model.MarkDirty(); + } + } } diff --git a/src/Dcli/Internal/FixedRegion/TextBuffer.cs b/src/Dcli/Internal/FixedRegion/TextBuffer.cs index 78681df..cc0328c 100644 --- a/src/Dcli/Internal/FixedRegion/TextBuffer.cs +++ b/src/Dcli/Internal/FixedRegion/TextBuffer.cs @@ -80,6 +80,19 @@ internal void SetCaretIndexForTest(int index) /// private string? _historyStash; + // ── Prompt ───────────────────────────────────────────────────────────────────────────────── + + private Line _prompt = new([]); + + /// + /// Sets the prompt prefix rendered before the editable region on the first visual row. + /// An empty line removes the prefix. + /// + internal void SetPrompt(Line prompt) + { + _prompt = prompt; + } + // ── Helpers: grapheme cluster iteration ───────────────────────────────────────────────── /// @@ -445,19 +458,24 @@ internal VisualRowInfo(Line line, int startCharIndex, int endCharIndex) /// /// Computes all visual rows for the current text at the given width. /// Each logical line (delimited by \n) is independently wrapped. + /// When a prompt prefix is set, its width is subtracted from the first row's available width + /// and its segments are prepended to the first visual row's . /// private List BuildVisualRows(int width) { if (width < 1) width = 1; + int promptWidth = MeasureLineWidth(_prompt); + // Split text into logical lines on \n and wrap each independently. List result = []; // Edge case: empty text → one empty row (caret can sit here). if (_text.Length == 0) { - result.Add(new VisualRowInfo(new Line([]), 0, 0)); + Line emptyRow = promptWidth > 0 ? new Line([.. _prompt.Segments]) : new Line([]); + result.Add(new VisualRowInfo(emptyRow, 0, 0)); return result; } @@ -471,18 +489,32 @@ private List BuildVisualRows(int width) ? _text[searchFrom..] : _text[searchFrom..newlinePos]; + // On the very first row ever produced, reduce available width by the prompt. + bool isFirstLogicalLine = result.Count == 0; + int effectiveWidth = (isFirstLogicalLine && promptWidth > 0) + ? Math.Max(1, width - promptWidth) + : width; + // Wrap the logical line into visual rows. Line sourceLine = new([new Segment(logicalLine)]); - IReadOnlyList wrappedRows = LineWrapper.Wrap(sourceLine, width); + IReadOnlyList wrappedRows = LineWrapper.Wrap(sourceLine, effectiveWidth); // Assign char ranges: each wrapped row covers a contiguous span of logicalLine. int charInLogical = 0; + bool firstWrappedRow = true; foreach (Line wRow in wrappedRows) { int rowChars = wRow.Segments.Sum(s => s.Text.Length); int rowStart = searchFrom + charInLogical; - result.Add(new VisualRowInfo(wRow, rowStart, rowStart + rowChars)); + + // Prepend prompt segments to the very first visual row. + Line paintRow = (isFirstLogicalLine && firstWrappedRow && promptWidth > 0) + ? new Line([.. _prompt.Segments, .. wRow.Segments]) + : wRow; + + result.Add(new VisualRowInfo(paintRow, rowStart, rowStart + rowChars)); charInLogical += rowChars; + firstWrappedRow = false; } if (newlinePos == -1) @@ -583,6 +615,10 @@ private VisualPosition ComputeVisualPositionFromRows(List rows) string beforeCaret = _text[row.StartCharIndex..caretIdx]; int visualCol = MeasureDisplayWidth(beforeCaret); + // On the first visual row, offset the column by the prompt width. + if (i == 0) + visualCol += MeasureLineWidth(_prompt); + return new VisualPosition { VisualRow = i, @@ -599,6 +635,9 @@ private VisualPosition ComputeVisualPositionFromRows(List rows) VisualRowInfo last = rows[^1]; string beforeCaret = _text[last.StartCharIndex..Math.Min(caretIdx, _text.Length)]; int visualCol = MeasureDisplayWidth(beforeCaret); + // If the last row is also row 0, offset by prompt width. + if (rows.Count == 1) + visualCol += MeasureLineWidth(_prompt); return new VisualPosition { VisualRow = rows.Count - 1, @@ -702,6 +741,18 @@ private static int MeasureDisplayWidth(string text) } return col; } + + /// + /// Measures the display width of a by summing each segment's rune widths. + /// + private static int MeasureLineWidth(Line line) + { + int w = 0; + foreach (Segment seg in line.Segments) + foreach (Rune r in seg.Text.EnumerateRunes()) + w += DisplayWidth.Measure(r); + return w; + } } // ── Public result types ────────────────────────────────────────────────────────────────────── diff --git a/tests/Dcli.Tests/FakeTerminalTests.cs b/tests/Dcli.Tests/FakeTerminalTests.cs index 96e92fa..f031f38 100644 --- a/tests/Dcli.Tests/FakeTerminalTests.cs +++ b/tests/Dcli.Tests/FakeTerminalTests.cs @@ -70,6 +70,7 @@ internal sealed class FakeInput : IInput { internal List SetTextCalls { get; } = []; internal int ClearCount { get; private set; } + internal List SetPromptCalls { get; } = []; public void SetText(string text) { @@ -78,6 +79,11 @@ public void SetText(string text) } public void Clear() => ClearCount++; + + public void SetPrompt(Line line) => SetPromptCalls.Add(line); + + public void SetPrompt(string text) => + SetPromptCalls.Add(string.IsNullOrEmpty(text) ? new Line([]) : Line.FromText(text)); } /// Records SetRows calls. diff --git a/tests/Dcli.Tests/InputPromptTests.cs b/tests/Dcli.Tests/InputPromptTests.cs new file mode 100644 index 0000000..e12108f --- /dev/null +++ b/tests/Dcli.Tests/InputPromptTests.cs @@ -0,0 +1,219 @@ +using System.Threading.Channels; +using Dcli.Testing; +using Xunit; + +namespace Dcli.Tests; + +/// +/// Integration tests for the input prompt prefix feature (tasks 3.1–3.6). +/// These tests exercise the public IInput.SetPrompt surface end-to-end through HeadlessTerminal; +/// unit-level TextBuffer behaviour is covered in TextBufferTests.cs. +/// +public sealed class InputPromptTests +{ + // ── Helpers ────────────────────────────────────────────────────────────── + + private static int FindRow(IReadOnlyList rows, string text) => + rows.Select((r, i) => (r, i)) + .Where(x => x.r.Segments.Any(s => s.Text.Contains(text, StringComparison.Ordinal))) + .Select(x => x.i) + .FirstOrDefault(-1); + + // ── 3.1 — Prefix renders before the editable text on the first row ──────── + + [Fact] + public async Task PromptPrefixRendersBeforeEditableTextOnFirstRow() + { + await using HeadlessTerminal harness = await HeadlessTerminal.StartAsync( + new HeadlessTerminalOptions { InitialColumns = 80, InitialRows = 24 }); + + harness.Terminal.Input.SetPrompt("❯ "); + harness.Terminal.Input.SetText("hello"); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + FrameSnapshot snap = harness.Snapshot; + IReadOnlyList fixed_ = snap.FixedRegionRows; + + // Find the editor row (the one containing "hello"). + int editorIdx = FindRow(fixed_, "hello"); + Assert.True(editorIdx >= 0, "Editor row containing 'hello' not found in FixedRegionRows"); + + Line editorRow = fixed_[editorIdx]; + Assert.True(editorRow.Segments.Count >= 2, + $"Expected ≥2 segments in editor row (prompt + text) but got {editorRow.Segments.Count}"); + + // First segment is the prompt; second (or later) contains the user text. + Assert.Equal("❯ ", editorRow.Segments[0].Text); + Assert.Contains(editorRow.Segments, s => s.Text.Contains("hello", StringComparison.Ordinal)); + } + + // ── 3.2 — Caret parks immediately after the prompt when buffer is empty ─── + + [Fact] + public async Task CaretParksAfterPromptWhenBufferEmpty() + { + await using HeadlessTerminal harness = await HeadlessTerminal.StartAsync( + new HeadlessTerminalOptions { InitialColumns = 80, InitialRows = 24 }); + + harness.Terminal.Input.SetPrompt("❯ "); + // No SetText — buffer is empty. + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + FrameSnapshot snap = harness.Snapshot; + Assert.NotNull(snap.Caret); + + // "❯" is 1 display column, " " is 1 display column → prompt width = 2. + // With an empty buffer the caret sits exactly at column 2 (0-based: cols 0 and 1 are the prompt). + Assert.Equal(2, snap.Caret!.Value.Col); + } + + // ── 3.3 — No prompt set → editor renders identically to baseline (regression guard) ── + + [Fact] + public async Task NoPromptEditorRendersWithoutPrefix() + { + await using HeadlessTerminal harness = await HeadlessTerminal.StartAsync( + new HeadlessTerminalOptions { InitialColumns = 80, InitialRows = 24 }); + + // Deliberately do NOT call SetPrompt. + harness.Terminal.Input.SetText("hello"); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + FrameSnapshot snap = harness.Snapshot; + IReadOnlyList fixed_ = snap.FixedRegionRows; + + int editorIdx = FindRow(fixed_, "hello"); + Assert.True(editorIdx >= 0, "Editor row with 'hello' not found"); + + Line editorRow = fixed_[editorIdx]; + // Without a prompt the first segment must be the text itself, not a prompt prefix. + Assert.Equal("hello", editorRow.Segments[0].Text); + + // Caret should be at column 5 (end of "hello"), not offset by a prompt. + Assert.NotNull(snap.Caret); + Assert.Equal(5, snap.Caret!.Value.Col); + } + + // ── 3.4 — Prompt persists across Clear() calls ──────────────────────────── + + [Fact] + public async Task PromptPersistsAcrossClearCalls() + { + await using HeadlessTerminal harness = await HeadlessTerminal.StartAsync( + new HeadlessTerminalOptions { InitialColumns = 80, InitialRows = 24 }); + + harness.Terminal.Input.SetPrompt("❯ "); + + // First round: set text, then clear. + harness.Terminal.Input.SetText("first"); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + // Verify prompt present before clear. + Assert.True( + FindRow(harness.Snapshot.FixedRegionRows, "❯ ") >= 0, + "Prompt '❯ ' missing before first Clear()"); + + harness.Terminal.Input.Clear(); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + // After clear the prompt must still show (caret at col 2, buffer empty). + FrameSnapshot snap1 = harness.Snapshot; + Assert.NotNull(snap1.Caret); + Assert.Equal(2, snap1.Caret!.Value.Col); + + // Second round: set text, then clear again. + harness.Terminal.Input.SetText("second"); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + harness.Terminal.Input.Clear(); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + FrameSnapshot snap2 = harness.Snapshot; + Assert.NotNull(snap2.Caret); + Assert.Equal(2, snap2.Caret!.Value.Col); + } + + // ── 3.5 — InputSubmitted payload excludes the prompt prefix ────────────── + + [Fact] + public async Task InputSubmittedExcludesPromptPrefix() + { + await using HeadlessTerminal harness = await HeadlessTerminal.StartAsync( + new HeadlessTerminalOptions { InitialColumns = 80, InitialRows = 24 }); + + harness.Terminal.Input.SetPrompt("❯ "); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + // Type "hello" and press Enter to submit. + harness.Type("hello"); + harness.SendKey(new KeyEvent(KeyCode.Named(NamedKey.Enter), Modifiers.None)); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + // Drain the outbound events channel. + ChannelReader events = harness.Terminal.Events; + InputSubmitted? submitted = null; + while (events.TryRead(out TerminalEvent? ev)) + { + if (ev is InputSubmitted s) + { + submitted = s; + break; + } + } + + Assert.NotNull(submitted); + // Must contain only the user's typed text, never the prompt prefix. + Assert.Equal("hello", submitted!.Text); + Assert.DoesNotContain("❯", submitted.Text, StringComparison.Ordinal); + } + + // ── 3.6 — First-row wrapping uses (width - promptWidth); continuation rows start at col 0 ── + + [Fact] + public async Task WrappingUsesPromptReducedWidthAndContinuationHasNoPrompt() + { + // Narrow terminal: 12 cols. Prompt "❯ " = 2 cols → first row capacity = 10 chars. + // Text "abcdefghijklmno" = 15 chars → wraps after 10, leaving 5 on row 2. + await using HeadlessTerminal harness = await HeadlessTerminal.StartAsync( + new HeadlessTerminalOptions { InitialColumns = 12, InitialRows = 24 }); + + harness.Terminal.Input.SetPrompt("❯ "); + harness.Terminal.Input.SetText("abcdefghijklmno"); + await harness.SettleAsync().WaitAsync(TimeSpan.FromSeconds(5)); + + FrameSnapshot snap = harness.Snapshot; + IReadOnlyList fixed_ = snap.FixedRegionRows; + + // Find the first editor row (prompt + start of text). + int firstEditorIdx = FindRow(fixed_, "abcdefghij"); + if (firstEditorIdx < 0) + { + // The 10 chars may be split at a slightly different boundary; find by prompt. + firstEditorIdx = fixed_ + .Select((r, i) => (r, i)) + .Where(x => x.r.Segments.Any(s => s.Text == "❯ ")) + .Select(x => x.i) + .FirstOrDefault(-1); + } + + Assert.True(firstEditorIdx >= 0, "First editor row (with prompt '❯ ') not found in FixedRegionRows"); + + Line firstRow = fixed_[firstEditorIdx]; + Assert.Equal("❯ ", firstRow.Segments[0].Text); + + // There must be at least one continuation row after the first editor row. + Assert.True(fixed_.Count > firstEditorIdx + 1, + $"Expected a continuation row after firstEditorIdx={firstEditorIdx} but FixedRegionRows.Count={fixed_.Count}"); + + // The continuation row must NOT start with the prompt. + Line continuationRow = fixed_[firstEditorIdx + 1]; + Assert.False(continuationRow.Segments.Count > 0 && continuationRow.Segments[0].Text == "❯ ", + "Continuation row must not start with the prompt prefix"); + + // Continuation row should contain the remaining text ("klmno"). + Assert.True( + continuationRow.Segments.Any(s => s.Text.Contains("klmno", StringComparison.Ordinal)), + $"Continuation row should contain 'klmno' but segments were: [{string.Join(", ", continuationRow.Segments.Select(s => s.Text))}]"); + } +} diff --git a/tests/Dcli.Tests/TextBufferTests.cs b/tests/Dcli.Tests/TextBufferTests.cs index 3f1f098..690a5fd 100644 --- a/tests/Dcli.Tests/TextBufferTests.cs +++ b/tests/Dcli.Tests/TextBufferTests.cs @@ -771,4 +771,159 @@ public void RenderOnlyWideCharsEachOnOwnRow() RenderResult r = buf.Render(2); Assert.Equal(2, r.VisibleRows.Count); } + + // ── Prompt prefix ───────────────────────────────────────────────────────────────────────── + + [Fact] + public void SetPromptEmptyLineProducesNoOffset() + { + // Empty prompt → behaviour identical to no prompt (regression guard). + TextBuffer buf = Buffer("hello"); + buf.SetPrompt(new Line([])); + RenderResult r = buf.Render(80); + Assert.Single(r.VisibleRows); + Assert.Equal(5, r.CaretPosition.Col); // caret at end, col = len("hello") + } + + [Fact] + public void SetPromptAddsSegmentToFirstVisualRow() + { + // Prompt "> " (2 cols) prepended to row 0 line. + TextBuffer buf = Buffer("hello"); + buf.SetPrompt(Line.FromText("> ")); + RenderResult r = buf.Render(80); + // First row segments: prompt + text. + IReadOnlyList segs = r.VisibleRows[0].Segments; + // Prompt segment first, then the text segment. + Assert.True(segs.Count >= 2); + Assert.Equal("> ", segs[0].Text); + Assert.Equal("hello", segs[1].Text); + } + + [Fact] + public void SetPromptOffsetsCaret() + { + // Prompt "> " = 2 cols. Caret at start of "hello" should be col 2. + TextBuffer buf = Buffer("hello"); + buf.SetPrompt(Line.FromText("> ")); + buf.SetCaretIndexForTest(0); + RenderResult r = buf.Render(80); + Assert.Equal(2, r.CaretPosition.Col); + } + + [Fact] + public void SetPromptOffsetsCaretAtEndOfText() + { + // Prompt ">>> " = 4 cols. Caret at end of "hi" should be col 4 + 2 = 6. + TextBuffer buf = Buffer("hi"); + buf.SetPrompt(Line.FromText(">>> ")); + RenderResult r = buf.Render(80); + Assert.Equal(6, r.CaretPosition.Col); + } + + [Fact] + public void SetPromptReducesAvailableWidthForWrapping() + { + // Width 10, prompt ">>> " = 4 cols → first row content width = 6. + // Text "abcdefghij" (10 chars) should wrap at 6 chars on row 0. + TextBuffer buf = Buffer("abcdefghij"); + buf.SetPrompt(Line.FromText(">>> ")); + RenderResult r = buf.Render(10); + // Row 0 wraps at 6 chars; remaining 4 chars on row 1. + Assert.True(r.VisibleRows.Count >= 2); + // Row 0: prompt ">>> " + 6 chars of text. + IReadOnlyList row0segs = r.VisibleRows[0].Segments; + Assert.Equal(">>> ", row0segs[0].Text); + Assert.Equal("abcdef", row0segs[1].Text); + // Row 1: remainder, no prompt. + Assert.Equal("ghij", r.VisibleRows[1].Segments[0].Text); + } + + [Fact] + public void SetPromptDoesNotAffectRow1AndBeyond() + { + // Prompt ">> " = 3 cols. Width 10. Multiline text. + // Row 1 (second logical line) must not have prompt prepended. + TextBuffer buf = Buffer("abc\ndef"); + buf.SetPrompt(Line.FromText(">> ")); + RenderResult r = buf.Render(80); + Assert.Equal(2, r.VisibleRows.Count); + // Row 0: has prompt + "abc" + Assert.True(r.VisibleRows[0].Segments.Count >= 2); + Assert.Equal(">> ", r.VisibleRows[0].Segments[0].Text); + // Row 1: only "def", no prompt prefix. + Assert.Equal("def", r.VisibleRows[1].Segments[0].Text); + Assert.Single(r.VisibleRows[1].Segments); + } + + [Fact] + public void SetPromptTextDoesNotAppearInTextProperty() + { + // Prompt must never bleed into the editable text. + TextBuffer buf = Buffer("hello"); + buf.SetPrompt(Line.FromText("> ")); + Assert.Equal("hello", buf.Text); + } + + [Fact] + public void SetPromptClearingLeavesNoOffset() + { + // Set a prompt, then clear it with an empty line. Caret should return to col 0. + TextBuffer buf = Buffer("hi"); + buf.SetPrompt(Line.FromText("> ")); + buf.SetPrompt(new Line([])); // clear + buf.SetCaretIndexForTest(0); + RenderResult r = buf.Render(80); + Assert.Equal(0, r.CaretPosition.Col); + } + + [Fact] + public void SetPromptEmptyTextProducesNoPrefix() + { + // Second regression guard: same behaviour as SetPromptEmptyLineProducesNoOffset. + TextBuffer buf = Buffer("hello"); + buf.SetPrompt(new Line([])); + RenderResult r = buf.Render(80); + // Only one segment (the "hello" text), no prompt segment prepended. + Assert.Equal("hello", r.VisibleRows[0].Segments[0].Text); + } + + [Fact] + public void SetPromptOnEmptyBufferProducesPromptOnlyRow() + { + // Prompt "$ " with empty buffer → first row has only prompt segments. + TextBuffer buf = new(); + buf.SetPrompt(Line.FromText("$ ")); + RenderResult r = buf.Render(80); + Assert.Single(r.VisibleRows); + Assert.Equal("$ ", r.VisibleRows[0].Segments[0].Text); + // Caret at col 2 (after prompt). + Assert.Equal(2, r.CaretPosition.Col); + } + + [Fact] + public void SetPromptDoesNotClearOnSetText() + { + // SetText should not reset the prompt. + TextBuffer buf = new(); + buf.SetPrompt(Line.FromText("> ")); + buf.SetText("new text"); + RenderResult r = buf.Render(80); + Assert.Equal("> ", r.VisibleRows[0].Segments[0].Text); + } + + [Fact] + public void SetPromptDoesNotClearOnClear() + { + // Clear() clears the buffer but must not clear the prompt. + TextBuffer buf = new(); + buf.SetPrompt(Line.FromText("> ")); + buf.SetText("some text"); + buf.Clear(); + RenderResult r = buf.Render(80); + // After Clear(), buffer is empty but prompt should still be visible. + Assert.Equal("> ", r.VisibleRows[0].Segments[0].Text); + // Caret is after prompt (col 2), not at col 0. + Assert.Equal(2, r.CaretPosition.Col); + } }