Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions HARNESS_CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion web/src/components/agent-chat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pre> 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 <span> 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
});

Expand Down
68 changes: 51 additions & 17 deletions web/src/components/agent-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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],
);

Expand Down Expand Up @@ -741,12 +743,44 @@ export function AgentChat({
</button>
)}

{/* 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 && (
<div className="truncate border-t border-border/40 px-3 py-1 font-mono text-[11px] leading-tight text-muted-foreground">
{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 && (
<div
className={cn(
"border-t border-border/40 px-3 py-1 font-mono text-[11px] leading-tight",
// The strip carries the agent's OWN terminal colour, so it renders in the mirror's
// dark space and inverts in light with it (ADR 0002) — a bright statusline colour is
// chosen against a near-black background and is illegible re-themed onto app chrome.
// It also makes the strip read as the bottom of the pane it was cut from, which is
// where the TUI drew it.
MIRROR_SPACE,
MIRROR_INVERT,
)}
>
{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.
<div key={i} className="truncate">
{row.segments.map((s, si) => (
// Text nodes only — colour and weight come from the ANSI parse, never markup.
// Same XSS boundary as the mirror.
<span key={si} style={styleFor(s)}>
{s.text}
</span>
))}
</div>
))}
</div>
)}

Expand Down
34 changes: 6 additions & 28 deletions web/src/components/ansi-output.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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 <pre> 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
Expand Down Expand Up @@ -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 ? (
<PromptSelectBlock
Expand Down
42 changes: 42 additions & 0 deletions web/src/components/mirror-space.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// The mirror's colour space, shared by every surface that renders terminal segments verbatim.
//
// Terminal colour is DARK-space colour: an agent picks bright yellow because it will sit on a near
// black background. So these surfaces are authored in dark space under every theme and the light
// theme inverts them wholesale, rather than re-theming the palette. 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.
//
// TWO surfaces need this now — the pane mirror's <pre> 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;
}
Loading
Loading