From 61db7a5d41adcfe340ce257a1ef18ff9622b8245 Mon Sep 17 00:00:00 2001 From: Altan Sarisin Date: Fri, 31 Jul 2026 00:11:58 +0200 Subject: [PATCH 1/2] feat(harness): surface the whole statusline run, not just its first row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code paints a multi-row statusline under the input box — on a real host three rows: ctx/cache/limits, then [model] + cwd + git branch, then the permission mode and agent count. stripChrome correctly peels the whole run off the mirror, but extractStatusLine re-surfaced only the FIRST non-blank row, so rows 2 and 3 were rendered nowhere at all: the model, working directory, branch and permission mode were invisible in Collie. Those are exactly the fields you check before typing a reply from a phone. The adapter contract now returns the run, not a line: extractStatusLine(lines): string | null extractStatusLines(lines): string[] // [] = no box at the tail One honest shape rather than a parallel method — a harness whose statusline is genuinely one line returns a one-element array. locateInputBox now hands out `statusEnd`, the exclusive end of the run it already walked, so the background-agents footer below the blank separator still can't leak in. The strip stacks the rows, each truncated, instead of joining them: joined, ~150 chars would compete for a strip that fits ~55 on a phone and would truncate away the very fields this exists to surface. Height stays bounded by MAX_STATUS_LINES upstream — no second cap. Refs #56 Co-Authored-By: Claude Opus 5 (1M context) --- HARNESS_CONTRIBUTING.md | 4 +- web/src/components/agent-chat.test.tsx | 6 +- web/src/components/agent-chat.tsx | 49 ++++--- web/src/lib/harness/claude/chrome.test.ts | 161 +++++++++++++--------- web/src/lib/harness/claude/chrome.ts | 48 ++++--- web/src/lib/harness/claude/index.ts | 8 +- web/src/lib/harness/types.ts | 9 +- 7 files changed, 175 insertions(+), 110 deletions(-) diff --git a/HARNESS_CONTRIBUTING.md b/HARNESS_CONTRIBUTING.md index 06bab39..01ff131 100644 --- a/HARNESS_CONTRIBUTING.md +++ b/HARNESS_CONTRIBUTING.md @@ -12,7 +12,7 @@ Read first: [`ARCHITECTURE.md`](./ARCHITECTURE.md) (the interaction loop + secur ## Architecture in one paragraph An adapter is a [`HarnessAdapter`](./web/src/lib/harness/types.ts) — -`{ agent, buildBlocks, extractStatusLine, extractInputDraft }` — registered by its Herdr `agent` +`{ agent, buildBlocks, extractStatusLines, extractInputDraft }` — registered by its Herdr `agent` string in [`web/src/lib/harness/registry.ts`](./web/src/lib/harness/registry.ts). The registry is the single decision site for "which agents get grammars"; every agent absent from it keeps the universal raw terminal mirror. Claude is the reference adapter, under @@ -50,7 +50,7 @@ An adapter earns capability incrementally. Ship a lower tier first; each is inde - **Tier 0 — raw mirror.** Every agent gets this for free: the colored terminal mirror + slash palette + special-keys pad. No adapter needed. It already works. -- **Tier 1 — read-only lift.** Chrome/status/draft extraction (`extractStatusLine`, +- **Tier 1 — read-only lift.** Chrome/status/draft extraction (`extractStatusLines`, `extractInputDraft`) plus **detection of a NEW, not-yet-wired block kind** — recognised and drawn, but with no keystroke recipe behind it, so taps send **no keystrokes**. Mergeable **from fixtures alone**: a mis-parse only costs cosmetics because there is no send path to fire into a terminal. diff --git a/web/src/components/agent-chat.test.tsx b/web/src/components/agent-chat.test.tsx index 58db070..13295eb 100644 --- a/web/src/components/agent-chat.test.tsx +++ b/web/src/components/agent-chat.test.tsx @@ -345,10 +345,14 @@ describe("AgentChat — block-grammar scoping (Claude-only)", () => { expect(screen.getByText(/1\. Yes/)).toBeInTheDocument(); }); - it("re-surfaces the Claude input-box statusline as an app strip above the composer", () => { + it("re-surfaces EVERY row of the Claude input-box statusline as an app strip above the composer", () => { renderChat({ text: STATUS_TEXT }); // default claude agent const strip = screen.getByText("[Opus 4.8] ~/webapp · main"); expect(strip.closest("pre")).toBeNull(); // the strip is app chrome, not
 mirror text
+    // Row 2 of the run: it used to be stripped off the mirror and rendered nowhere at all.
+    const second = screen.getByText("← for agents");
+    expect(second.closest("pre")).toBeNull();
+    expect(second.parentElement).toBe(strip.parentElement); // stacked in the one strip
     expect(screen.queryByText(/❯/)).toBeNull(); // the input box was stripped off the mirror
   });
 
diff --git a/web/src/components/agent-chat.tsx b/web/src/components/agent-chat.tsx
index 35bc7c0..c751bb6 100644
--- a/web/src/components/agent-chat.tsx
+++ b/web/src/components/agent-chat.tsx
@@ -158,18 +158,18 @@ export function AgentChat({
   const display = shown.text;
   const hasNew = !following && display !== text;
 
-  // The agent's own statusline (model · ctx% · cwd · branch · tokens) is stripped off the mirror by
-  // stripChrome so it doesn't duplicate the composer — but it carries real context (the branch, most
-  // notably), so we re-surface that one line as app chrome just above the composer, where it sat in
-  // the TUI. Routed through the SAME adapter (adapterFor) whose buildBlocks strips the chrome, so the
-  // two can't drift; null when there's no adapter for the agent, a menu is up, or no box at the tail,
-  // in which case the strip is hidden. A second parse of `display`, but memoised on it, so it only
-  // recomputes when the buffer content changes — off the render hot path.
-  const statusLine = useMemo(
+  // The agent's own statusline (model · ctx% · cwd · branch · tokens · permission mode) is stripped
+  // off the mirror by stripChrome so it doesn't duplicate the composer — but it carries real context
+  // (the branch, most notably), so we re-surface it as app chrome just above the composer, where it
+  // sat in the TUI. ALL its rows: a configured statusline is routinely 2–3 rows tall, and we used to
+  // surface only the first, silently losing the rest. Routed through the SAME adapter (adapterFor)
+  // whose buildBlocks strips the chrome, so the two can't drift; empty when there's no adapter for
+  // the agent, a menu is up, or no box at the tail, in which case the strip is hidden. A second parse
+  // of `display`, but memoised on it, so it only recomputes when the buffer content changes — off the
+  // render hot path.
+  const statusLines = useMemo(
     () =>
-      grammarsOn
-        ? adapterFor(agent?.agent)?.extractStatusLine(splitLines(parseAnsi(display))) ?? null
-        : null,
+      grammarsOn ? adapterFor(agent?.agent)?.extractStatusLines(splitLines(parseAnsi(display))) ?? [] : [],
     [display, agent?.agent, grammarsOn],
   );
 
@@ -741,12 +741,27 @@ export function AgentChat({
             
           )}
 
-          {/* The agent's statusline, re-surfaced as app chrome (its branch/model/ctx would otherwise
-              vanish with the stripped input box). Sits directly above the composer, as it did in the
-              TUI. Verbatim text — a React text node, so no XSS surface. */}
-          {statusLine && (
-            
- {statusLine} + {/* The agent's statusline, re-surfaced as app chrome (its branch/model/ctx/permission mode + would otherwise vanish with the stripped input box). Sits directly above the composer, + as it did in the TUI. Verbatim text — React text nodes, so no XSS surface. + + STACKED, one row per line, each truncated — deliberately, over the two alternatives: + joining the rows with a separator would put ~150 chars on a strip that fits ~55 at this + size on a phone, truncating away exactly the fields (branch, permission mode) this + exists to surface; wrapping makes the strip's height depend on the pane width and turns + a column-aligned statusline into ragged prose. Stacking also preserves the shape the + user themselves configured in the TUI, so it reads as the same thing they know. + Height is bounded upstream (MAX_STATUS_LINES caps the run stripChrome will claim), so + there is no second cap here; the mirror is a flex child that shrinks, never pushed off. */} + {statusLines.length > 0 && ( +
+ {statusLines.map((row, i) => ( + // Index key: these rows are a positional snapshot of the pane tail, re-derived on + // every poll — there is no identity to preserve across renders. +
+ {row} +
+ ))}
)} diff --git a/web/src/lib/harness/claude/chrome.test.ts b/web/src/lib/harness/claude/chrome.test.ts index 553e124..9603a88 100644 --- a/web/src/lib/harness/claude/chrome.test.ts +++ b/web/src/lib/harness/claude/chrome.test.ts @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { parseAnsi } from "../../ansi"; import { splitLines, type StyledLine } from "../../blocks"; -import { extractInputDraft, extractStatusLine, stripChrome } from "./chrome"; +import { extractInputDraft, extractStatusLines, stripChrome } from "./chrome"; import { lineText } from "./markers"; // Anchored on this file's directory (see prompt-select.test.ts for why not `new URL(import.meta.url)`). @@ -162,47 +162,73 @@ describe("stripChrome — conservative: leaves non-chrome untouched", () => { }); }); -// extractStatusLine re-surfaces the one statusline stripChrome removes (the branch/model/ctx the -// user configured) so the app can render it above the composer — positional (first non-blank line -// below the input box's bottom border), never content-parsed. -describe("extractStatusLine — recovers the stripped statusline", () => { +// extractStatusLines re-surfaces the statusline RUN stripChrome removes (the branch/model/ctx/ +// permission mode the user configured) so the app can render it above the composer — positional +// (every non-blank line below the input box's bottom border, above the background-agents footer), +// never content-parsed. All rows, not just the first: rows 2+ used to be stripped and rendered +// nowhere. +describe("extractStatusLines — recovers the stripped statusline run", () => { it("working: returns the statusline including the branch (the field the field-report flagged)", () => { - const status = extractStatusLine(fixtureLines("claude--working.txt")); - expect(status).not.toBeNull(); - expect(status).toContain("feature/block-renderer"); // the branch survives - expect(status).toContain("151.5k tokens"); - expect(status).not.toContain("bypass permissions"); // the hint line below it is NOT returned + const rows = extractStatusLines(fixtureLines("claude--working.txt")); + expect(rows.length).toBeGreaterThan(0); + expect(rows[0]).toContain("feature/block-renderer"); // the branch survives + expect(rows[0]).toContain("151.5k tokens"); + // The hint row below it is its own entry now — it used to be dropped on the floor. + expect(rows.join("\n")).toContain("bypass permissions"); }); - it("fresh-idle: returns the statusline, not the hint line under it", () => { - const status = extractStatusLine(fixtureLines("claude--fresh-idle.txt")); - expect(status).not.toBeNull(); - expect(status).toContain("fixture-sandbox"); - expect(status).not.toContain("← for agents"); + it("fresh-idle: returns the statusline AND the hint row under it, in order", () => { + const rows = extractStatusLines(fixtureLines("claude--fresh-idle.txt")); + expect(rows.length).toBe(2); + expect(rows[0]).toContain("fixture-sandbox"); + expect(rows[1]).toContain("← for agents"); }); it("done: returns the statusline of a completed turn", () => { - const status = extractStatusLine(fixtureLines("claude--done.txt")); - expect(status).not.toBeNull(); - expect(status).toContain("tokens"); + const rows = extractStatusLines(fixtureLines("claude--done.txt")); + expect(rows.length).toBeGreaterThan(0); + expect(rows[0]).toContain("tokens"); }); - it("footer variant: returns the statusline, not the hint or the background-agents footer below it", () => { - const status = extractStatusLine(fixtureLines("claude--draft-footer-empty.txt")); - expect(status).not.toBeNull(); - expect(status).toContain("ctx:33%"); // the statusline itself - expect(status).not.toContain("bypass permissions"); // hint under it is NOT returned - expect(status).not.toContain("worker:scout"); // footer is NOT returned + it("footer variant: returns the statusline + hint, but NOT the background-agents footer", () => { + const rows = extractStatusLines(fixtureLines("claude--draft-footer-empty.txt")); + expect(rows[0]).toContain("ctx:33%"); // the statusline itself + expect(rows.join("\n")).toContain("bypass permissions"); // the hint row is part of the run + expect(rows.join("\n")).not.toContain("worker:scout"); // …the footer below the blank is not + expect(rows.join("\n")).not.toContain("● main"); }); - it("returns null when a menu is up (no input box at the tail)", () => { - expect(extractStatusLine(fixtureLines("claude--select-menu.txt"))).toBeNull(); - expect(extractStatusLine(fixtureLines("claude--trust-prompt.txt"))).toBeNull(); - expect(extractStatusLine(fixtureLines("claude--permission-bash.txt"))).toBeNull(); + // The shape that motivated this (verified on a real host): a 3-row statusline under the box. The + // model, cwd, branch and permission mode all live on rows 2 and 3 — surfacing only row 1 made + // them invisible everywhere, since stripChrome (correctly) peels the whole run off the mirror. + it("a real 3-row statusline surfaces every row, in TUI order", () => { + const REAL_ROWS = [ + " CTX:20% CACHE:100% LIMITS 5h:22%/1h:20m 7d:26%/5d:03h", + " [Opus·medium] ~/projects/workspace-sprqvntrs/argo-sprqvntrs on main*", + " ⏵⏵ bypass permissions on (shift+tab to cycle) · ← 4 agents", + ]; + // U+00A0 after the marker, as Claude renders it — never a plain space. + const lines = boxWithStatusRows("❯\u00A0", REAL_ROWS); + expect(extractStatusLines(lines)).toEqual(REAL_ROWS.map((r) => r.trim())); }); - it("returns null for a plain buffer with no input box", () => { - expect(extractStatusLine(splitLines(parseAnsi("just some output\nmore output")))).toBeNull(); + it("a single-row statusline is a one-element array (no visual change for those panes)", () => { + const lines = boxWithStatusRows("❯\u00A0fix the flaky test", ["[Opus 4.8] · ctx:3% · main · 32k tokens"]); + expect(extractStatusLines(lines)).toEqual(["[Opus 4.8] · ctx:3% · main · 32k tokens"]); + }); + + it("returns [] for a box with no statusline under it at all", () => { + expect(extractStatusLines(boxBuffer("❯\u00A0draft"))).toEqual([]); + }); + + it("returns [] when a menu is up (no input box at the tail)", () => { + expect(extractStatusLines(fixtureLines("claude--select-menu.txt"))).toEqual([]); + expect(extractStatusLines(fixtureLines("claude--trust-prompt.txt"))).toEqual([]); + expect(extractStatusLines(fixtureLines("claude--permission-bash.txt"))).toEqual([]); + }); + + it("returns [] for a plain buffer with no input box", () => { + expect(extractStatusLines(splitLines(parseAnsi("just some output\nmore output")))).toEqual([]); }); }); @@ -303,14 +329,14 @@ describe("the statusline run — as tall as a real statusline", () => { it.each([1, 2, 3, 4, 5, 6, 7, 8])("locates the box under %i statusline row(s)", (rows) => { const lines = boxWithStatusRows(`❯ ${DRAFT}`, statusRows(rows)); expect(extractInputDraft(lines)).toBe(DRAFT); - expect(extractStatusLine(lines)).toBe("status row 0"); + expect(extractStatusLines(lines)).toEqual(statusRows(rows)); // every row, however tall the run expect(stripChrome(lines)).not.toBe(lines); }); it.each([9, 10])("falls back to the raw mirror at %i rows, the deliberate ceiling", (rows) => { const lines = boxWithStatusRows(`❯ ${DRAFT}`, statusRows(rows)); expect(extractInputDraft(lines)).toBeNull(); - expect(extractStatusLine(lines)).toBeNull(); + expect(extractStatusLines(lines)).toEqual([]); expect(stripChrome(lines)).toBe(lines); }); }); @@ -341,7 +367,7 @@ describe("dialogs are refused by the border and blank checks — not by the row it.each(DIALOG_FIXTURES)("%s surfaces no box, so no chrome is stripped from it", (name) => { const lines = fixtureLines(name); - expect(extractStatusLine(lines)).toBeNull(); + expect(extractStatusLines(lines)).toEqual([]); expect(extractInputDraft(lines)).toBeNull(); expect(stripChrome(lines).length).toBe(keptAboveTail(lines)); }); @@ -362,7 +388,7 @@ describe("the row bound only catches a run taller than any plausible statusline" it("refuses a complete box above an 8-row blank-free run", () => { const lines = boxWithBlankFreeRunBelow(outputRows(8)); - expect(extractStatusLine(lines)).toBeNull(); + expect(extractStatusLines(lines)).toEqual([]); expect(extractInputDraft(lines)).toBeNull(); expect(stripChrome(lines)).toBe(lines); }); @@ -394,36 +420,39 @@ describe("scrollback echo — a known limitation, pinned on purpose", () => { }); describe("real corpus — pinned so any change to the walk shows up as a diff", () => { - const PINNED: { fixture: string; statusLineFound: boolean; draft: string | null; stripped: number }[] = [ - { fixture: "done", statusLineFound: true, draft: "cat hello.txt to verify", stripped: 28 }, - { fixture: "draft-footer-empty", statusLineFound: true, draft: null, stripped: 9 }, - { fixture: "draft-footer-single", statusLineFound: true, draft: "remember to update the changelo", stripped: 9 }, - { fixture: "draft-footer-wrapped", statusLineFound: true, draft: "this stranded draft is long eno", stripped: 11 }, - { fixture: "draft-wrapped", statusLineFound: true, draft: "this stranded draft is long eno", stripped: 10 }, - { fixture: "fresh-idle", statusLineFound: true, draft: null, stripped: 47 }, - { fixture: "permission-bash", statusLineFound: false, draft: null, stripped: 0 }, - { fixture: "permission-edit", statusLineFound: false, draft: null, stripped: 0 }, - { fixture: "plan-approval", statusLineFound: false, draft: null, stripped: 0 }, - { fixture: "plan-approval--numbered-body", statusLineFound: false, draft: null, stripped: 0 }, - { fixture: "rename-resolved", statusLineFound: true, draft: null, stripped: 6 }, - { fixture: "select-menu", statusLineFound: false, draft: null, stripped: 0 }, - { fixture: "select-multi", statusLineFound: false, draft: null, stripped: 0 }, - { fixture: "select-multiselect-checked", statusLineFound: false, draft: null, stripped: 0 }, - { fixture: "select-multiselect-review", statusLineFound: false, draft: null, stripped: 5 }, - { fixture: "select-multiselect-single", statusLineFound: false, draft: null, stripped: 0 }, - { fixture: "select-preview", statusLineFound: false, draft: null, stripped: 0 }, - { fixture: "select-preview-note-attached", statusLineFound: false, draft: null, stripped: 0 }, - { fixture: "select-preview-note-input", statusLineFound: false, draft: null, stripped: 0 }, - { fixture: "send-inflight", statusLineFound: true, draft: "/rename", stripped: 5 }, - { fixture: "trust-prompt", statusLineFound: false, draft: null, stripped: 0 }, - { fixture: "wizard-preview-note-attached", statusLineFound: false, draft: null, stripped: 0 }, - { fixture: "wizard-preview-q1", statusLineFound: false, draft: null, stripped: 0 }, - { fixture: "wizard-q1", statusLineFound: false, draft: null, stripped: 0 }, - { fixture: "wizard-q1-revisit", statusLineFound: false, draft: null, stripped: 0 }, - { fixture: "wizard-q2", statusLineFound: false, draft: null, stripped: 0 }, - { fixture: "wizard-submit", statusLineFound: false, draft: null, stripped: 0 }, - { fixture: "wizard-submit-unanswered", statusLineFound: false, draft: null, stripped: 3 }, - { fixture: "working", statusLineFound: true, draft: null, stripped: 6 }, + // `statusRows` is the HEIGHT of the re-surfaced statusline run, not a boolean: every real capture + // in this corpus is 2 rows (statusline + hint), and pinning the count is what would have caught the + // first-row-only truncation this table used to tolerate. + const PINNED: { fixture: string; statusRows: number; draft: string | null; stripped: number }[] = [ + { fixture: "done", statusRows: 2, draft: "cat hello.txt to verify", stripped: 28 }, + { fixture: "draft-footer-empty", statusRows: 2, draft: null, stripped: 9 }, + { fixture: "draft-footer-single", statusRows: 2, draft: "remember to update the changelo", stripped: 9 }, + { fixture: "draft-footer-wrapped", statusRows: 2, draft: "this stranded draft is long eno", stripped: 11 }, + { fixture: "draft-wrapped", statusRows: 2, draft: "this stranded draft is long eno", stripped: 10 }, + { fixture: "fresh-idle", statusRows: 2, draft: null, stripped: 47 }, + { fixture: "permission-bash", statusRows: 0, draft: null, stripped: 0 }, + { fixture: "permission-edit", statusRows: 0, draft: null, stripped: 0 }, + { fixture: "plan-approval", statusRows: 0, draft: null, stripped: 0 }, + { fixture: "plan-approval--numbered-body", statusRows: 0, draft: null, stripped: 0 }, + { fixture: "rename-resolved", statusRows: 2, draft: null, stripped: 6 }, + { fixture: "select-menu", statusRows: 0, draft: null, stripped: 0 }, + { fixture: "select-multi", statusRows: 0, draft: null, stripped: 0 }, + { fixture: "select-multiselect-checked", statusRows: 0, draft: null, stripped: 0 }, + { fixture: "select-multiselect-review", statusRows: 0, draft: null, stripped: 5 }, + { fixture: "select-multiselect-single", statusRows: 0, draft: null, stripped: 0 }, + { fixture: "select-preview", statusRows: 0, draft: null, stripped: 0 }, + { fixture: "select-preview-note-attached", statusRows: 0, draft: null, stripped: 0 }, + { fixture: "select-preview-note-input", statusRows: 0, draft: null, stripped: 0 }, + { fixture: "send-inflight", statusRows: 2, draft: "/rename", stripped: 5 }, + { fixture: "trust-prompt", statusRows: 0, draft: null, stripped: 0 }, + { fixture: "wizard-preview-note-attached", statusRows: 0, draft: null, stripped: 0 }, + { fixture: "wizard-preview-q1", statusRows: 0, draft: null, stripped: 0 }, + { fixture: "wizard-q1", statusRows: 0, draft: null, stripped: 0 }, + { fixture: "wizard-q1-revisit", statusRows: 0, draft: null, stripped: 0 }, + { fixture: "wizard-q2", statusRows: 0, draft: null, stripped: 0 }, + { fixture: "wizard-submit", statusRows: 0, draft: null, stripped: 0 }, + { fixture: "wizard-submit-unanswered", statusRows: 0, draft: null, stripped: 3 }, + { fixture: "working", statusRows: 2, draft: null, stripped: 6 }, ]; it("pins every claude fixture on disk, so a new capture can't slip past this table", () => { @@ -434,9 +463,9 @@ describe("real corpus — pinned so any change to the walk shows up as a diff", expect(onDisk).toEqual(PINNED.map((p) => p.fixture).sort()); }); - it.each(PINNED)("$fixture classifies identically", ({ fixture, statusLineFound, draft, stripped }) => { + it.each(PINNED)("$fixture classifies identically", ({ fixture, statusRows, draft, stripped }) => { const lines = fixtureLines(`claude--${fixture}.txt`); - expect(extractStatusLine(lines) !== null).toBe(statusLineFound); + expect(extractStatusLines(lines).length).toBe(statusRows); if (draft === null) { expect(extractInputDraft(lines)).toBeNull(); } else { diff --git a/web/src/lib/harness/claude/chrome.ts b/web/src/lib/harness/claude/chrome.ts index 2967023..4c173da 100644 --- a/web/src/lib/harness/claude/chrome.ts +++ b/web/src/lib/harness/claude/chrome.ts @@ -61,30 +61,37 @@ export function stripChrome(lines: StyledLine[]): StyledLine[] { } /** - * The statusline the agent draws just under its input box — model, ctx%, cwd, branch, tokens, - * whatever the user configured in their Claude Code statusline. We strip the box off the mirror - * (stripChrome), so this re-surfaces that one line as app chrome above the composer instead of - * losing it. + * The statusline RUN the agent draws just under its input box — model, ctx%, cwd, branch, tokens, + * permission mode, whatever the user configured, plus the TUI's own hint row(s). We strip the box + * off the mirror (stripChrome), so this re-surfaces those rows as app chrome above the composer + * instead of losing them. * - * POSITIONAL only: the first non-blank line strictly below the box's bottom border. Hint lines after - * it ("← for agents", "⏵⏵ bypass permissions") are ignored — only the first counts. Returns the - * trimmed text, or `null` when there's no input box at the tail (a menu is up, or a non-Claude / torn - * buffer). Never interprets the content — the caller renders it verbatim. + * ALL of them, not just the first: a statusline is an arbitrary user command's output and is + * routinely 2–3 rows (ctx/limits on one, model + cwd + branch on the next, permission mode on the + * third). Surfacing only the first row silently dropped everything after it — the very fields the + * mirror can no longer show. + * + * POSITIONAL only: every non-blank line strictly below the box's bottom border and above where the + * background-agents footer starts (locateInputBox draws that line, so the footer never leaks in + * here). Returns the rows trimmed, top to bottom, or `[]` when there's no input box at the tail (a + * menu is up, or a non-Claude / torn buffer). Never interprets the content — the caller renders it + * verbatim. */ -export function extractStatusLine(lines: StyledLine[]): string | null { +export function extractStatusLines(lines: StyledLine[]): string[] { const texts = lines.map(lineText); let end = lines.length; while (end > 0 && isBlank(texts[end - 1]!)) end--; - if (end === 0) return null; + if (end === 0) return []; const box = locateInputBox(texts, end); - if (box === null) return null; + if (box === null) return []; - for (let j = box.bottomBorder + 1; j < end; j++) { + const rows: string[] = []; + for (let j = box.bottomBorder + 1; j < box.statusEnd; j++) { const t = texts[j]!.trim(); - if (t.length > 0) return t; + if (t.length > 0) rows.push(t); } - return null; + return rows; } /** @@ -128,8 +135,13 @@ interface InputBox { top: number; /** Index of the "❯" prompt line, between the two borders — carries the draft (extractInputDraft). */ prompt: number; - /** Index of the BOTTOM border — the statusline, if any, is the first non-blank line after it. */ + /** Index of the BOTTOM border — the statusline run, if any, starts on the next line. */ bottomBorder: number; + /** EXCLUSIVE end of the statusline run: one past its last row, i.e. where the blank separator + + * background-agents footer begin (or the buffer's last non-blank line when there is no footer). + * `bottomBorder + 1` when the box has no statusline at all. Only the walk down there knows where + * the run stops, so it hands the bound out rather than letting extractStatusLines re-derive it. */ + statusEnd: number; } /** @@ -170,7 +182,9 @@ function locateInputBox(texts: string[], end: number): InputBox | null { } // (b) Up to MAX_STATUS_LINES status/hint lines directly above the bottom border: non-blank, - // non-border text. Stop as soon as a border is reached. + // non-border text. Stop as soon as a border is reached. `i` is now the last row of that run + // (the footer, if any, has been peeled off above), so it fixes the run's exclusive end. + const statusEnd = i + 1; let status = 0; while (i >= 0 && !isBoxBorder(texts[i]!) && !isBlank(texts[i]!) && status < MAX_STATUS_LINES) { status++; @@ -204,5 +218,5 @@ function locateInputBox(texts: string[], end: number): InputBox | null { // (e) top border if (i < 0 || !isBoxBorder(texts[i]!)) return null; - return { top: i, prompt, bottomBorder }; + return { top: i, prompt, bottomBorder, statusEnd }; } diff --git a/web/src/lib/harness/claude/index.ts b/web/src/lib/harness/claude/index.ts index ed07f2b..6f251d9 100644 --- a/web/src/lib/harness/claude/index.ts +++ b/web/src/lib/harness/claude/index.ts @@ -2,7 +2,7 @@ // stepper header) against the fixture corpus in web/src/fixtures/panes/*.txt. Its detectors // (prompt-select, wizard, preview-select, chrome, markers) live alongside this file; this module // wires them into the two HarnessAdapter surfaces: the block pipeline (claudeBuildBlocks) and the -// chrome re-surfacing probes (extractStatusLine / extractInputDraft, re-exported from ./chrome). +// chrome re-surfacing probes (extractStatusLines / extractInputDraft, re-exported from ./chrome). // // Every OTHER agent (codex, opencode, pi, a bare shell, or an unknown/absent agent) has an unverified // TUI shape, so it has no adapter and keeps the plain raw terminal mirror — running Claude's matchers @@ -14,7 +14,7 @@ import { detectPreviewSelectRegion } from "./preview-select"; import { detectWizardRegion } from "./wizard"; import { detectMultiSelectRegion } from "./multi-select"; import { detectPromptSelectRegion } from "./prompt-select"; -import { stripChrome, extractStatusLine, extractInputDraft } from "./chrome"; +import { stripChrome, extractStatusLines, extractInputDraft } from "./chrome"; /** * Claude's block pipeline: detect a tail dialog (preview / wizard / prompt-select), replacing it with @@ -75,11 +75,11 @@ export function claudeBuildBlocks(lines: StyledLine[]): Block[] { return [{ kind: "raw", lines: stripChrome(lines) }]; } -export { extractStatusLine, extractInputDraft }; +export { extractStatusLines, extractInputDraft }; export const claudeAdapter: HarnessAdapter = { agent: "claude", buildBlocks: claudeBuildBlocks, - extractStatusLine, + extractStatusLines, extractInputDraft, }; diff --git a/web/src/lib/harness/types.ts b/web/src/lib/harness/types.ts index 78f8844..224f841 100644 --- a/web/src/lib/harness/types.ts +++ b/web/src/lib/harness/types.ts @@ -17,9 +17,12 @@ export interface HarnessAdapter { /** The adapter's OWN full block pipeline over the pane's styled lines — for Claude that is the * raw-or-dialog result (dialog lift + chrome strip, else a single raw block). */ buildBlocks(lines: StyledLine[]): Block[]; - /** Re-surface the statusline this agent's chrome-stripping peeled off the mirror tail (null = no - * box at the tail, so nothing to surface). */ - extractStatusLine(lines: StyledLine[]): string | null; + /** Re-surface the statusline RUN this agent's chrome-stripping peeled off the mirror tail, one + * entry per row, top to bottom. A statusline is an arbitrary user command's output and is + * routinely several rows tall (model/cwd/branch on one, permission mode on another), so the + * contract is a list — a single-row harness returns a one-element array. Empty = no box at the + * tail (a menu is up, or a foreign/torn buffer), so nothing to surface. */ + extractStatusLines(lines: StyledLine[]): string[]; /** Re-surface a user draft stranded on the input box's prompt line (null = no box / empty / a * known placeholder). */ extractInputDraft(lines: StyledLine[]): string | null; From ac3c62de93813b4a31cd8aacaf1d0e149dea540b Mon Sep 17 00:00:00 2001 From: Altan Sarisin Date: Fri, 31 Jul 2026 00:28:44 +0200 Subject: [PATCH 2/2] feat(web): the statusline strip keeps the agent's own colour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A statusline separates its fields by colour before anyone reads them — the context meter, the model, the branch. extractStatusLines flattened the styled lines to text one call before the strip that renders them, so the strip was uniformly muted grey. Rows now come back as StyledLine, and the strip renders segment spans in the MIRROR's colour space rather than as app chrome: terminal colour is dark-space colour, so a bright statusline colour re-themed onto a light background is the illegibility ADR 0002 exists to prevent. The space and its invert rule move to mirror-space.ts, since two surfaces now share them and a second spelling would drift. Refs #56 Co-Authored-By: Claude Opus 5 (1M context) --- web/src/components/agent-chat.test.tsx | 6 +++- web/src/components/agent-chat.tsx | 23 +++++++++++-- web/src/components/ansi-output.tsx | 34 ++++-------------- web/src/components/mirror-space.ts | 42 +++++++++++++++++++++++ web/src/lib/harness/claude/chrome.test.ts | 38 ++++++++++++++++---- web/src/lib/harness/claude/chrome.ts | 15 +++++--- web/src/lib/harness/types.ts | 8 +++-- 7 files changed, 120 insertions(+), 46 deletions(-) create mode 100644 web/src/components/mirror-space.ts diff --git a/web/src/components/agent-chat.test.tsx b/web/src/components/agent-chat.test.tsx index 13295eb..74c7a20 100644 --- a/web/src/components/agent-chat.test.tsx +++ b/web/src/components/agent-chat.test.tsx @@ -352,7 +352,11 @@ describe("AgentChat — block-grammar scoping (Claude-only)", () => { // Row 2 of the run: it used to be stripped off the mirror and rendered nowhere at all. const second = screen.getByText("← for agents"); expect(second.closest("pre")).toBeNull(); - expect(second.parentElement).toBe(strip.parentElement); // stacked in the one strip + // Stacked in the one strip. Compared at the ROW level: each row renders one per ANSI + // segment (colour is carried through now), so the text node's own parent is a span, not the row. + const row = (el: HTMLElement) => el.closest("div.truncate"); + expect(row(second)).not.toBe(row(strip)); + expect(row(second)?.parentElement).toBe(row(strip)?.parentElement); expect(screen.queryByText(/❯/)).toBeNull(); // the input box was stripped off the mirror }); diff --git a/web/src/components/agent-chat.tsx b/web/src/components/agent-chat.tsx index c751bb6..be14532 100644 --- a/web/src/components/agent-chat.tsx +++ b/web/src/components/agent-chat.tsx @@ -13,6 +13,8 @@ import { ChatMessageList, type ChatMessageListHandle } from "@/components/ui/cha import { BottomSheet } from "@/components/ui/sheet"; import { AppHeader } from "@/components/app-header"; import { AnsiOutput } from "@/components/ansi-output"; +import { MIRROR_SPACE, MIRROR_INVERT, styleFor } from "@/components/mirror-space"; +import { cn } from "@/lib/utils"; import { parseAnsi } from "@/lib/ansi"; import { splitLines } from "@/lib/blocks"; import { adapterFor } from "@/lib/harness"; @@ -754,12 +756,29 @@ export function AgentChat({ Height is bounded upstream (MAX_STATUS_LINES caps the run stripChrome will claim), so there is no second cap here; the mirror is a flex child that shrinks, never pushed off. */} {statusLines.length > 0 && ( -
+
{statusLines.map((row, i) => ( // Index key: these rows are a positional snapshot of the pane tail, re-derived on // every poll — there is no identity to preserve across renders.
- {row} + {row.segments.map((s, si) => ( + // Text nodes only — colour and weight come from the ANSI parse, never markup. + // Same XSS boundary as the mirror. + + {s.text} + + ))}
))}
diff --git a/web/src/components/ansi-output.tsx b/web/src/components/ansi-output.tsx index 83dde2a..14c4ec2 100644 --- a/web/src/components/ansi-output.tsx +++ b/web/src/components/ansi-output.tsx @@ -1,8 +1,8 @@ import { Fragment, memo, useEffect, useMemo, useRef } from "react"; -import type { CSSProperties, ReactNode } from "react"; +import type { ReactNode } from "react"; import { cn } from "@/lib/utils"; -import { parseAnsi, type AnsiSegment } from "@/lib/ansi"; +import { parseAnsi } from "@/lib/ansi"; import { buildBlocks } from "@/lib/harness"; import { splitLines, @@ -14,6 +14,7 @@ import { type WizardModel, } from "@/lib/blocks"; import { lineText } from "@/lib/harness/claude/markers"; +import { MIRROR_SPACE, MIRROR_INVERT, styleFor } from "@/components/mirror-space"; import { findMatches, splitSegment, type FindMatch } from "@/lib/find"; import { findLinks } from "@/lib/links"; import { PromptSelectBlock } from "@/components/prompt-select-block"; @@ -72,27 +73,8 @@ export interface AnsiOutputProps { // (no needless effect re-runs / parent count updates while find is closed). const NO_MATCHES: FindMatch[] = []; -// The mirror is always authored in DARK space, and light mode inverts it. See -// .adr/0002-invert-the-light-terminal-mirror.md — in short, three of the four harnesses emit -// overwhelmingly truecolor (opencode 100%, pi 89%, claude 79%), and truecolor names an absolute -// colour no palette can re-theme. Rendering it unchanged on white leaves most of an agent's output -// under 2:1. -// -// The colours here are LITERAL dark-space values rather than theme tokens. That is a CONVENTION, -// not a constraint: `color-scheme: dark` on this element DOES flip an inherited light-dark() token -// (resolution is element-scoped, per spec), and these literals are byte-exact matches for -// --background / --foreground / --muted-foreground's dark halves, so either spelling renders the -// same pixels. Literals win because they sit beside the truecolor an agent emits — which nothing -// can re-theme — and say at the point of use that the value is deliberately theme-independent. -// What matters is that the mirror never mixes the two (ADR 0002, rule 2). -// -// `color-scheme: dark` still earns its place for native UI inside the pre (the x-overflow -// scrollbar, selection), which the filter then maps to light along with everything else. -// -// Scoped to the
 deliberately: the interactive blocks (prompt/wizard/preview/multi-select) are
-// siblings, not children, so they keep normal app theming and never invert.
-const MIRROR_SPACE = "[color-scheme:dark] bg-[#0a0a0a] text-[#fafafa]";
-const MIRROR_INVERT = "[filter:invert(1)_hue-rotate(180deg)] dark:[filter:none]";
+// The mirror's dark colour space and its light-theme inversion live in mirror-space.ts — the
+// statusline strip renders the same terminal segments and the two must not drift.
 
 // An autolinked URL keeps the colour the agent printed — recolouring it would lie about the
 // terminal's own output — and is marked by an underline in `currentColor`, which is legible against
@@ -219,11 +201,7 @@ export const AnsiOutput = memo(function AnsiOutput({
   }, [currentMatch, matches]);
 
   // Muted = box-drawing / rule glyphs. Drop ANSI dim opacity so table borders stay visible —
-  // var(--border) + dim made them nearly invisible on mobile. #a1a1a1 is --muted-foreground's dark
-  // half, written literally to match MIRROR_SPACE above — everything inside the pre is dark-space,
-  // and the mirror keeps one spelling throughout (ADR 0002, rule 2).
-  const styleFor = (s: AnsiSegment): CSSProperties =>
-    s.muted ? { ...s.style, color: "#a1a1a1", fontWeight: 400, opacity: 1 } : s.style;
+  // var(--border) + dim made them nearly invisible on mobile. See styleFor in mirror-space.ts.
 
   const prompt = promptBlock ? (
      and the statusline strip above the composer —
+// which is why it lives here instead of being spelled twice and drifting, the silent-failure class
+// the ADR is about. The interactive blocks (prompt/wizard/preview/multi-select) are siblings of the
+// mirror, not children, so they keep normal app theming and never invert.
+//
+// The colours are LITERAL dark-space values rather than theme tokens. That is a CONVENTION, not a
+// constraint: `color-scheme: dark` on the element DOES flip an inherited light-dark() token
+// (resolution is element-scoped, per spec), and these literals are byte-exact matches for
+// --background / --foreground / --muted-foreground's dark halves, so either spelling renders the
+// same pixels. Literals win because they sit beside the truecolor an agent emits — which nothing can
+// re-theme — and say at the point of use that the value is deliberately theme-independent. What
+// matters is that a mirror surface never mixes the two (ADR 0002, rule 2).
+//
+// `color-scheme: dark` still earns its place for native UI inside these surfaces (the x-overflow
+// scrollbar, selection), which the filter then maps to light along with everything else.
+//
+// NEVER add a `dark:` variant inside one: it tracks the ROOT theme, which is backwards in an element
+// that is dark under every theme and inverts in light.
+import type { CSSProperties } from "react";
+
+import type { AnsiSegment } from "@/lib/ansi";
+
+export const MIRROR_SPACE = "[color-scheme:dark] bg-[#0a0a0a] text-[#fafafa]";
+export const MIRROR_INVERT = "[filter:invert(1)_hue-rotate(180deg)] dark:[filter:none]";
+
+/** A segment's inline style. `muted` is the parser's own "this is TUI chrome" mark rather than an
+ *  ANSI colour: drop the ANSI dim opacity so box-drawing and rule glyphs stay visible (var(--border)
+ *  + dim was nearly invisible on mobile) and resolve it to #a1a1a1 — --muted-foreground's dark half,
+ *  written literally to match MIRROR_SPACE, since everything on these surfaces is dark-space. */
+export function styleFor(s: AnsiSegment): CSSProperties {
+  return s.muted ? { ...s.style, color: "#a1a1a1", fontWeight: 400, opacity: 1 } : s.style;
+}
diff --git a/web/src/lib/harness/claude/chrome.test.ts b/web/src/lib/harness/claude/chrome.test.ts
index 9603a88..de6dc79 100644
--- a/web/src/lib/harness/claude/chrome.test.ts
+++ b/web/src/lib/harness/claude/chrome.test.ts
@@ -7,6 +7,12 @@ import { splitLines, type StyledLine } from "../../blocks";
 import { extractInputDraft, extractStatusLines, stripChrome } from "./chrome";
 import { lineText } from "./markers";
 
+/** The statusline run as plain text. extractStatusLines returns STYLED lines — a statusline tells
+ *  its fields apart by colour, so the strip needs the segments — but most assertions here are about
+ *  WHICH rows come back, not how they look, and read better against text. */
+const statusText = (lines: StyledLine[]): string[] =>
+  extractStatusLines(lines).map((l) => lineText(l).trim());
+
 // Anchored on this file's directory (see prompt-select.test.ts for why not `new URL(import.meta.url)`).
 const PANES_DIR = join(import.meta.dirname, "..", "..", "..", "fixtures", "panes");
 
@@ -169,7 +175,7 @@ describe("stripChrome — conservative: leaves non-chrome untouched", () => {
 // nowhere.
 describe("extractStatusLines — recovers the stripped statusline run", () => {
   it("working: returns the statusline including the branch (the field the field-report flagged)", () => {
-    const rows = extractStatusLines(fixtureLines("claude--working.txt"));
+    const rows = statusText(fixtureLines("claude--working.txt"));
     expect(rows.length).toBeGreaterThan(0);
     expect(rows[0]).toContain("feature/block-renderer"); // the branch survives
     expect(rows[0]).toContain("151.5k tokens");
@@ -178,20 +184,20 @@ describe("extractStatusLines — recovers the stripped statusline run", () => {
   });
 
   it("fresh-idle: returns the statusline AND the hint row under it, in order", () => {
-    const rows = extractStatusLines(fixtureLines("claude--fresh-idle.txt"));
+    const rows = statusText(fixtureLines("claude--fresh-idle.txt"));
     expect(rows.length).toBe(2);
     expect(rows[0]).toContain("fixture-sandbox");
     expect(rows[1]).toContain("← for agents");
   });
 
   it("done: returns the statusline of a completed turn", () => {
-    const rows = extractStatusLines(fixtureLines("claude--done.txt"));
+    const rows = statusText(fixtureLines("claude--done.txt"));
     expect(rows.length).toBeGreaterThan(0);
     expect(rows[0]).toContain("tokens");
   });
 
   it("footer variant: returns the statusline + hint, but NOT the background-agents footer", () => {
-    const rows = extractStatusLines(fixtureLines("claude--draft-footer-empty.txt"));
+    const rows = statusText(fixtureLines("claude--draft-footer-empty.txt"));
     expect(rows[0]).toContain("ctx:33%"); // the statusline itself
     expect(rows.join("\n")).toContain("bypass permissions"); // the hint row is part of the run
     expect(rows.join("\n")).not.toContain("worker:scout"); // …the footer below the blank is not
@@ -209,12 +215,30 @@ describe("extractStatusLines — recovers the stripped statusline run", () => {
     ];
     // U+00A0 after the marker, as Claude renders it — never a plain space.
     const lines = boxWithStatusRows("❯\u00A0", REAL_ROWS);
-    expect(extractStatusLines(lines)).toEqual(REAL_ROWS.map((r) => r.trim()));
+    expect(statusText(lines)).toEqual(REAL_ROWS.map((r) => r.trim()));
+  });
+
+  // The rows come back STYLED, which is the whole reason the strip can show a statusline the way its
+  // author meant it: fields are told apart by colour before they are read. Flattening to text here
+  // (what this used to do) threw the colour away one call before the surface that renders it.
+  it("keeps each row's colour, not just its text", () => {
+    const ESC = "\x1b";
+    const lines = boxWithStatusRows("❯ ", [
+      `${ESC}[32mCTX:20%${ESC}[0m ${ESC}[33mmain*${ESC}[0m`,
+    ]);
+    const rows = extractStatusLines(lines);
+    expect(rows.length).toBe(1);
+    // Two differently-coloured runs, both carrying a colour — a flattened row would be one bare
+    // segment with no style at all.
+    const coloured = rows[0]!.segments.filter((s) => s.style.color !== undefined);
+    expect(coloured.length).toBeGreaterThanOrEqual(2);
+    expect(coloured[0]!.style.color).not.toBe(coloured[1]!.style.color);
+    expect(lineText(rows[0]!)).toContain("CTX:20%");
   });
 
   it("a single-row statusline is a one-element array (no visual change for those panes)", () => {
     const lines = boxWithStatusRows("❯\u00A0fix the flaky test", ["[Opus 4.8] · ctx:3% · main · 32k tokens"]);
-    expect(extractStatusLines(lines)).toEqual(["[Opus 4.8] · ctx:3% · main · 32k tokens"]);
+    expect(statusText(lines)).toEqual(["[Opus 4.8] · ctx:3% · main · 32k tokens"]);
   });
 
   it("returns [] for a box with no statusline under it at all", () => {
@@ -329,7 +353,7 @@ describe("the statusline run — as tall as a real statusline", () => {
   it.each([1, 2, 3, 4, 5, 6, 7, 8])("locates the box under %i statusline row(s)", (rows) => {
     const lines = boxWithStatusRows(`❯ ${DRAFT}`, statusRows(rows));
     expect(extractInputDraft(lines)).toBe(DRAFT);
-    expect(extractStatusLines(lines)).toEqual(statusRows(rows)); // every row, however tall the run
+    expect(statusText(lines)).toEqual(statusRows(rows)); // every row, however tall the run
     expect(stripChrome(lines)).not.toBe(lines);
   });
 
diff --git a/web/src/lib/harness/claude/chrome.ts b/web/src/lib/harness/claude/chrome.ts
index 4c173da..c0b588a 100644
--- a/web/src/lib/harness/claude/chrome.ts
+++ b/web/src/lib/harness/claude/chrome.ts
@@ -73,11 +73,17 @@ export function stripChrome(lines: StyledLine[]): StyledLine[] {
  *
  * POSITIONAL only: every non-blank line strictly below the box's bottom border and above where the
  * background-agents footer starts (locateInputBox draws that line, so the footer never leaks in
- * here). Returns the rows trimmed, top to bottom, or `[]` when there's no input box at the tail (a
+ * here). Returns the rows STYLED, top to bottom, or `[]` when there's no input box at the tail (a
  * menu is up, or a non-Claude / torn buffer). Never interprets the content — the caller renders it
  * verbatim.
+ *
+ * Styled, not flattened, because a statusline is colour-carrying by design: the model, the context
+ * meter and the git branch are told apart by colour before they're read. Flattening to text here
+ * threw that away one call before the strip that renders it. The caller draws these in the mirror's
+ * dark space (see mirror-space.ts) — terminal colour only means what it means against a dark
+ * background.
  */
-export function extractStatusLines(lines: StyledLine[]): string[] {
+export function extractStatusLines(lines: StyledLine[]): StyledLine[] {
   const texts = lines.map(lineText);
   let end = lines.length;
   while (end > 0 && isBlank(texts[end - 1]!)) end--;
@@ -86,10 +92,9 @@ export function extractStatusLines(lines: StyledLine[]): string[] {
   const box = locateInputBox(texts, end);
   if (box === null) return [];
 
-  const rows: string[] = [];
+  const rows: StyledLine[] = [];
   for (let j = box.bottomBorder + 1; j < box.statusEnd; j++) {
-    const t = texts[j]!.trim();
-    if (t.length > 0) rows.push(t);
+    if (!isBlank(texts[j]!)) rows.push(lines[j]!);
   }
   return rows;
 }
diff --git a/web/src/lib/harness/types.ts b/web/src/lib/harness/types.ts
index 224f841..fe4f2c6 100644
--- a/web/src/lib/harness/types.ts
+++ b/web/src/lib/harness/types.ts
@@ -20,9 +20,11 @@ export interface HarnessAdapter {
   /** Re-surface the statusline RUN this agent's chrome-stripping peeled off the mirror tail, one
    *  entry per row, top to bottom. A statusline is an arbitrary user command's output and is
    *  routinely several rows tall (model/cwd/branch on one, permission mode on another), so the
-   *  contract is a list — a single-row harness returns a one-element array. Empty = no box at the
-   *  tail (a menu is up, or a foreign/torn buffer), so nothing to surface. */
-  extractStatusLines(lines: StyledLine[]): string[];
+   *  contract is a list — a single-row harness returns a one-element array. Rows stay STYLED: a
+   *  statusline separates its fields by colour, so flattening them to text loses what makes it
+   *  readable at a glance. Empty = no box at the tail (a menu is up, or a foreign/torn buffer), so
+   *  nothing to surface. */
+  extractStatusLines(lines: StyledLine[]): StyledLine[];
   /** Re-surface a user draft stranded on the input box's prompt line (null = no box / empty / a
    *  known placeholder). */
   extractInputDraft(lines: StyledLine[]): string | null;