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..74c7a20 100644 --- a/web/src/components/agent-chat.test.tsx +++ b/web/src/components/agent-chat.test.tsx @@ -345,10 +345,18 @@ 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();
+    // 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 35bc7c0..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";
@@ -158,18 +160,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 +743,44 @@ 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.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 553e124..de6dc79 100644
--- a/web/src/lib/harness/claude/chrome.test.ts
+++ b/web/src/lib/harness/claude/chrome.test.ts
@@ -4,9 +4,15 @@ 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";
 
+/** 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");
 
@@ -162,47 +168,91 @@ 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 = 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");
+    // 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 = 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 status = extractStatusLine(fixtureLines("claude--done.txt"));
-    expect(status).not.toBeNull();
-    expect(status).toContain("tokens");
+    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 = 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
+    expect(rows.join("\n")).not.toContain("● main");
+  });
+
+  // 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(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(statusText(lines)).toEqual(["[Opus 4.8] · ctx:3% · main · 32k 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("returns [] for a box with no statusline under it at all", () => {
+    expect(extractStatusLines(boxBuffer("❯\u00A0draft"))).toEqual([]);
   });
 
-  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();
+  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 null for a plain buffer with no input box", () => {
-    expect(extractStatusLine(splitLines(parseAnsi("just some output\nmore output")))).toBeNull();
+  it("returns [] for a plain buffer with no input box", () => {
+    expect(extractStatusLines(splitLines(parseAnsi("just some output\nmore output")))).toEqual([]);
   });
 });
 
@@ -303,14 +353,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(statusText(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 +391,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 +412,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 +444,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 +487,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..c0b588a 100644
--- a/web/src/lib/harness/claude/chrome.ts
+++ b/web/src/lib/harness/claude/chrome.ts
@@ -61,30 +61,42 @@ 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 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 extractStatusLine(lines: StyledLine[]): string | null {
+export function extractStatusLines(lines: StyledLine[]): StyledLine[] {
   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 t = texts[j]!.trim();
-    if (t.length > 0) return t;
+  const rows: StyledLine[] = [];
+  for (let j = box.bottomBorder + 1; j < box.statusEnd; j++) {
+    if (!isBlank(texts[j]!)) rows.push(lines[j]!);
   }
-  return null;
+  return rows;
 }
 
 /**
@@ -128,8 +140,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 +187,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 +223,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..fe4f2c6 100644
--- a/web/src/lib/harness/types.ts
+++ b/web/src/lib/harness/types.ts
@@ -17,9 +17,14 @@ 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. 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;