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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ All notable changes to Collie are recorded here. The format follows
- Spaces are ordered by last used and filterable — 45 of them are now three keystrokes, not a scroll (da4f44c)
- The bridge keeps two timestamps per pane (`activeAt`, `seenAt`) in `activity.json`, because Herdr reports none (2f4d691)
- **Tab and space chips carry a status dot** — blocked / ready / working / idle, in the herd list's own palette. They only ever showed a dot for blocked before, so every other state read the same as every other (22d4a5f)
- **URLs in the pane mirror are tappable** — `http(s)://` text becomes a link that opens in a new tab, keeping the colour the agent printed and marked by an underline (cc38351)
- Trailing prose punctuation is trimmed with paren balance respected, so `Fetch(https://x.dev/a)` links the URL and not the paren; a find hit inside a URL still highlights, and a URL that changes colour mid-way stays one link (cc38351)

### Changed
- The pane mirror renders in dark space under every theme and light mode inverts it, because agents emit truecolor almost exclusively and no palette can re-theme an absolute colour — [ADR 0002](.adr/0002-invert-the-light-terminal-mirror.md) (78425bd)
Expand Down
2 changes: 2 additions & 0 deletions web/src/components/agent-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,8 @@ export function AgentChat({
// collapsing the selection and popping the keyboard.
function focusFromMirror(e: ReactMouseEvent<HTMLDivElement>) {
const target = e.target as Element | null;
// The `a` is what keeps a tap on an autolinked URL (components/ansi-output) from popping the
// keyboard on top of the page it just opened. Don't trim it out of this selector.
if (target?.closest?.("button, a, input, textarea, select, [role='textbox']")) return;
const sel = window.getSelection();
if (sel && !sel.isCollapsed) return;
Expand Down
72 changes: 72 additions & 0 deletions web/src/components/ansi-output.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import type { ComponentProps } from "react";

import { AnsiOutput } from "./ansi-output";

Expand Down Expand Up @@ -45,3 +46,74 @@ describe("terminal mirror colour space", () => {
expect(span!.style.color).toBe("var(--ansi-1)");
});
});

// URLs printed by an agent are plain characters — the mirror finds them and wraps those ranges in
// anchors. The invariants worth guarding are the ones a refactor would silently break: the text is
// still exactly what the terminal printed, and nothing but http(s) ever becomes an href.
describe("clickable links in the mirror", () => {
function mirror(props: Partial<ComponentProps<typeof AnsiOutput>> & { text: string }) {
const { container } = render(<AnsiOutput {...props} />);
return container.querySelector("pre")!;
}

it("links a bare URL without changing the rendered text", () => {
const pre = mirror({ text: "opened https://herdr.dev/docs ok\n" });
const a = pre.querySelector("a")!;
expect(a.getAttribute("href")).toBe("https://herdr.dev/docs");
expect(a.textContent).toBe("https://herdr.dev/docs");
// The mirror must stay a faithful copy — the anchor adds structure, never characters.
expect(pre.textContent).toBe("opened https://herdr.dev/docs ok\n");
});

it("opens in a new tab and severs the opener — these hrefs come from agent output", () => {
const a = mirror({ text: "https://herdr.dev\n" }).querySelector("a")!;
expect(a.getAttribute("target")).toBe("_blank");
expect(a.getAttribute("rel")).toBe("noopener noreferrer");
});

it("never links a dangerous scheme", () => {
const pre = mirror({ text: "javascript:alert(1) data:text/html,<script>x</script>\n" });
expect(pre.querySelector("a")).toBeNull();
expect(pre.querySelector("script")).toBeNull(); // text nodes only — the XSS boundary holds
});

// A URL that changes colour mid-way (an agent underlining just the path, say) is split across
// segments. Each slice gets its own anchor, so the whole run is tappable and carries one href.
it("links a URL that straddles an SGR change", () => {
const pre = mirror({ text: `${ESC}[34mhttps://herdr.dev${ESC}[32m/docs${ESC}[0m\n` });
const anchors = [...pre.querySelectorAll("a")];
expect(anchors.length).toBeGreaterThan(1);
expect(anchors.every((a) => a.getAttribute("href") === "https://herdr.dev/docs")).toBe(true);
expect(anchors.map((a) => a.textContent).join("")).toBe("https://herdr.dev/docs");
});

// Find and links split the same coordinate space; the order they nest in is the easy thing to get
// wrong, and getting it wrong drops one of them.
it("still highlights a find match inside a link", () => {
const pre = mirror({ text: "see https://herdr.dev/docs\n", query: "herdr" });
const a = pre.querySelector("a")!;
const hit = a.querySelector("[data-find-match]")!;
expect(hit.textContent).toBe("herdr");
expect(a.textContent).toBe("https://herdr.dev/docs");
});

// The underline inherits the agent's colour rather than pinning one, so it stays legible whatever
// the pane printed and whichever theme is up.
it("underlines in currentColor rather than a fixed colour", () => {
const a = mirror({ text: "https://herdr.dev\n" }).querySelector("a")!;
expect(a.className).toContain("underline");
expect(a.className).not.toMatch(/decoration-\[#/);
});

// The tap-target pad must scale with the font-size control. jsdom has no layout, so this can only
// guard the unit — but the unit is the whole point: a px pad tuned for 12px text reaches past the
// neighbouring line's centre at 9px (the A− floor), and a tap on ordinary output opens a link.
// The padded box deliberately OVERLAPS its neighbours (~22px against a 15px line advance); what
// must hold is that it never reaches the neighbouring line's centre, which only an em value keeps
// true across the A+/A- range. See the LINK_CLASS comment for the full argument.
it("sizes the link tap target in em, never px", () => {
const a = mirror({ text: "https://herdr.dev\n" }).querySelector("a")!;
expect(a.className).toContain("py-[0.35em]");
expect(a.className).not.toMatch(/\bpy-\[[\d.]+px\]/);
});
});
166 changes: 96 additions & 70 deletions web/src/components/ansi-output.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Fragment, memo, useEffect, useMemo, useRef } from "react";
import type { CSSProperties } from "react";
import type { CSSProperties, ReactNode } from "react";

import { cn } from "@/lib/utils";
import { parseAnsi, type AnsiSegment } from "@/lib/ansi";
Expand All @@ -15,6 +15,7 @@ import {
} from "@/lib/blocks";
import { lineText } from "@/lib/harness/claude/markers";
import { findMatches, splitSegment, type FindMatch } from "@/lib/find";
import { findLinks } from "@/lib/links";
import { PromptSelectBlock } from "@/components/prompt-select-block";
import { WizardBlock } from "@/components/wizard-block";
import { PreviewSelectBlock, type PreviewBlockAction } from "@/components/preview-select-block";
Expand Down Expand Up @@ -90,6 +91,26 @@ const NO_MATCHES: FindMatch[] = [];
const MIRROR_SPACE = "[color-scheme:dark] bg-[#0a0a0a] text-[#fafafa]";
const MIRROR_INVERT = "[filter:invert(1)_hue-rotate(180deg)] dark:[filter:none]";

// 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
// whatever the mirror's background is under either theme.
//
// `py-[0.35em]` is the tap target, and it is free: vertical padding on an INLINE box doesn't grow
// the line box, so the mirror's height and the terminal grid are identical with or without it
// (measured: same <pre> height either way) while the hit area goes from ~14px to ~22px on a phone.
// It must stay em-relative and small, and the reason is NOT that the padded box fits the line box —
// it doesn't: ~14px of content plus 2x4.2px of padding is ~22px against a 15px line advance, so it
// overlaps its neighbours by design. Two things make that safe, and both must hold:
// 1. The pad never reaches the neighbouring line's CENTRE at any size the A+/A- control offers.
// A px value tuned for 12px text would, at the 9px floor — and a tap on ordinary output would
// silently open a link. This is why it stays em-relative.
// 2. Where the overlap does cover the next line's text, that line's spans come later in DOM order
// and win inline hit-testing, so a tap on visible text goes to the text. Only empty space
// beside a link can bleed to the anchor.
// Don't convert it to a px value, and don't "fix" it to fit the line box — that would undo (1).
const LINK_CLASS =
"underline decoration-1 underline-offset-2 break-all cursor-pointer py-[0.35em]";

function preClass(wrap: boolean, className?: string): string {
return cn(
"m-0 font-mono leading-[1.25] tracking-normal text-foreground [font-variant-ligatures:none]",
Expand Down Expand Up @@ -122,9 +143,14 @@ function preClass(wrap: boolean, className?: string): string {
// blocks → lines → segments so each segment maps back to that same coordinate space. (A find query
// can't contain a newline, so no match straddles the inter-line separators.)
//
// Autolinked URLs (lib/links.ts) live in that SAME coordinate space and are applied over the raw
// blocks too, as anchors wrapping the find-highlighted runs. Only `http(s)://` text becomes a link,
// and the href is the matched text itself — no scheme can appear that wasn't printed by the agent.
//
// Performance: parseAnsi + block-building run once per unique `text` (and `agent`) value (useMemo),
// and React.memo prevents re-renders when props are unchanged — critical for the polling cadence on
// mobile. When not searching (`query` empty) the render skips splitSegment entirely.
// as does the link scan; React.memo prevents re-renders when props are unchanged — critical for the
// polling cadence on mobile. With no query and no links the render skips splitSegment entirely and
// emits the segment's own string, exactly as the pre-find flat renderer did.
export const AnsiOutput = memo(function AnsiOutput({
text,
className,
Expand Down Expand Up @@ -175,6 +201,10 @@ export const AnsiOutput = memo(function AnsiOutput({
return findMatches(haystack, query);
}, [haystack, query]);

// Autolinked URLs, in the SAME offset space as find matches — both are ranges over `haystack`, so
// one running offset serves both splits. Recomputed only when the mirror text changes.
const links = useMemo(() => findLinks(haystack), [haystack]);

useEffect(() => {
onMatchCount?.(matches.length);
}, [matches, onMatchCount]);
Expand Down Expand Up @@ -218,41 +248,70 @@ export const AnsiOutput = memo(function AnsiOutput({
/>
) : null;

// Fast path — not searching. No global offsets, no splitSegment: one plain span per segment, with
// "\n" text nodes between lines (and between raw blocks). Identical DOM text to the pre-blocks render.
if (matches.length === 0) {
return (
<>
{rawBlocks.length > 0 && (
<pre className={preClass(wrap, className)} style={{ fontSize: `${fontSize}px` }}>
{rawBlocks.map((block, bi) => (
<Fragment key={bi}>
{bi > 0 ? "\n" : null}
{block.lines.map((line, li) => (
<Fragment key={li}>
{li > 0 ? "\n" : null}
{line.segments.map((s, si) => (
<span key={si} style={styleFor(s)}>
{s.text}
</span>
))}
</Fragment>
))}
</Fragment>
))}
</pre>
)}
{prompt}
</>
);
}

// Highlight path. Thread a running global offset through raw blocks → lines → segments (advancing
// by 1 for each inter-line/inter-block "\n" separator) so splitSegment can tag each segment's
// slices with the global match index. `currentAssigned` refs only the first slice of the focused
// match (a match can span segments on a colour change) so scrollIntoView targets one stable node.
// Thread a running global offset through raw blocks → lines → segments (advancing by 1 for each
// inter-line/inter-block "\n" separator) so both splits below can map a segment's slices back to
// the haystack. With no query and no links this costs one addition per segment and allocates
// nothing beyond the spans — the polling path stays as cheap as the old flat render.
let offset = 0;
let currentAssigned = false;

// A run of plain text at global offset `start` → nodes, with find matches split out and
// highlighted. `currentAssigned` refs only the first slice of the focused match (a match can span
// segments on a colour change) so scrollIntoView targets one stable node.
const renderFind = (text: string, start: number): ReactNode => {
if (matches.length === 0) return text;
return splitSegment(text, start, matches).map((p, j) => {
if (p.matchIndex === null) return p.text;
const isCurrent = p.matchIndex === currentMatch;
const attach = isCurrent && !currentAssigned;
if (attach) currentAssigned = true;
return (
<span
key={j}
ref={attach ? currentRef : undefined}
data-find-match={isCurrent ? "current" : "other"}
className={cn(
"rounded-[2px]",
// Asymmetric on purpose, and the asymmetry is the whole subtlety.
//
// The CURRENT match re-applies the mirror's filter to cancel it, because otherwise
// yellow-400 comes out of invert+hue-rotate as a dark brown that reads as a redaction
// bar. It can do that safely only because `text-black` pins its text: black → inner
// invert → white → outer invert → black.
//
// A non-current match sets no text colour, so its text is INHERITED from the segment —
// dark-space, i.e. light. Double-inverting sends it light → dark → light and it lands
// light-on-light, erasing the very text you searched for. So the others take the plain
// single inversion, which renders them as a pale tan wash with the mapped text on top.
// See .adr/0002 — "cancel the filter only on an element that fully specifies both its
// foreground and its background".
isCurrent ? cn(MIRROR_INVERT, "bg-yellow-400 text-black") : "bg-yellow-400/30",
)}
>
{p.text}
</span>
);
});
};

// A segment's text → nodes: autolinked URLs as anchors, wrapping find-highlighted runs. Two
// splits over one coordinate space, links outermost, so a find hit *inside* a URL still lights up.
// A URL that straddles a colour change yields one <a> per segment slice, each with the same href.
const renderSegment = (text: string, start: number): ReactNode => {
if (links.length === 0) return renderFind(text, start);
let at = start;
return splitSegment(text, start, links).map((p, i) => {
const pieceStart = at;
at += p.text.length;
if (p.matchIndex === null) return <Fragment key={i}>{renderFind(p.text, pieceStart)}</Fragment>;
return (
<a key={i} href={links[p.matchIndex]!.href} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
{renderFind(p.text, pieceStart)}
</a>
);
});
};

const renderBlock = (block: RawBlock, bi: number) => {
if (bi > 0) offset += 1; // the "\n" separating this block from the previous
return (
Expand All @@ -263,42 +322,9 @@ export const AnsiOutput = memo(function AnsiOutput({
const segNodes = line.segments.map((s, si) => {
const segStart = offset;
offset += s.text.length;
const pieces = splitSegment(s.text, segStart, matches);
return (
<span key={si} style={styleFor(s)}>
{pieces.map((p, j) => {
if (p.matchIndex === null) return p.text;
const isCurrent = p.matchIndex === currentMatch;
const attach = isCurrent && !currentAssigned;
if (attach) currentAssigned = true;
return (
<span
key={j}
ref={attach ? currentRef : undefined}
data-find-match={isCurrent ? "current" : "other"}
className={cn(
"rounded-[2px]",
// Asymmetric on purpose, and the asymmetry is the whole subtlety.
//
// The CURRENT match re-applies the mirror's filter to cancel it, because
// otherwise yellow-400 comes out of invert+hue-rotate as a dark brown that
// reads as a redaction bar. It can do that safely only because `text-black`
// pins its text: black → inner invert → white → outer invert → black.
//
// A non-current match sets no text colour, so its text is INHERITED from
// the segment — dark-space, i.e. light. Double-inverting sends it
// light → dark → light and it lands light-on-light, erasing the very text
// you searched for. So the others take the plain single inversion, which
// renders them as a pale tan wash with the mapped text still on top.
isCurrent
? cn(MIRROR_INVERT, "bg-yellow-400 text-black")
: "bg-yellow-400/30",
)}
>
{p.text}
</span>
);
})}
{renderSegment(s.text, segStart)}
</span>
);
});
Expand Down
Loading
Loading