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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ All notable changes to Collie are recorded here. The format follows
`version` in `herdr-plugin.toml`, `package.json`, and `web/package.json` (enforced by
`scripts/check-version.sh`). See [`CLAUDE.md`](./CLAUDE.md) → *Versioning* for the bump policy.

## [0.20.0] - 2026-07-29

### Added
- **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
- Trailing prose punctuation is trimmed, paren balance respected — `Fetch(https://x.dev/a)` links the URL, not the paren
- A find hit inside a URL still highlights; a URL that changes colour mid-way stays one link

### Changed
- Link tap targets are `0.35em` taller than the text without moving the terminal grid — 14px is too small to hit on a phone

### Fixed
- Nothing but `http(s)` can become a link — `javascript:`/`data:` are unmatchable, not filtered, and a stray control byte can't reach an href

## [0.19.0] - 2026-07-29

### Added
Expand Down
2 changes: 1 addition & 1 deletion herdr-plugin.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
id = "herdr.collie"
name = "Collie"
version = "0.19.0"
version = "0.20.0"
min_herdr_version = "0.7.0"
description = "Mobile web UI to monitor and reply to your agent herd, served over Tailscale"
platforms = ["linux", "macos"]
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "collie",
"version": "0.19.0",
"version": "0.20.0",
"private": true,
"license": "MIT",
"description": "Collie — a mobile web UI to monitor and reply to your Herdr agent herd over Tailscale",
Expand Down
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "collie-web",
"version": "0.19.0",
"version": "0.20.0",
"private": true,
"license": "MIT",
"type": "module",
Expand Down
76 changes: 76 additions & 0 deletions web/src/components/ansi-output.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import type { ComponentProps } from "react";

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

const ESC = "\x1b";

// 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.
// Measured in Chrome at 9/12/16px: the padded box stays inside the 1.25 line-height at all three.
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\]/);
});
});
129 changes: 73 additions & 56 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 @@ -68,6 +69,19 @@ export interface AnsiOutputProps {
// (no needless effect re-runs / parent count updates while find is closed).
const NO_MATCHES: FindMatch[] = [];

// 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. The pad is invisible, so if it ever reached the neighbouring
// line's centre a tap on ordinary text would silently open a link; at 0.35em the box stays inside
// the 1.25 line-height at every font size the A+/A− control offers. Don't convert it to a px value.
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 @@ -98,9 +112,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 @@ -151,6 +170,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 @@ -194,41 +217,54 @@ 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]", isCurrent ? "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 @@ -239,28 +275,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]",
isCurrent ? "bg-yellow-400 text-black" : "bg-yellow-400/30",
)}
>
{p.text}
</span>
);
})}
{renderSegment(s.text, segStart)}
</span>
);
});
Expand Down
69 changes: 69 additions & 0 deletions web/src/lib/links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";

import { findLinks } from "./links";

describe("findLinks", () => {
const hrefs = (s: string) => findLinks(s).map((l) => l.href);

it("finds http(s) URLs and reports ranges that index the source text", () => {
const text = "see https://herdr.dev/docs for more";
const [link] = findLinks(text);
expect(link).toEqual({ start: 4, end: 26, href: "https://herdr.dev/docs" });
expect(text.slice(link!.start, link!.end)).toBe("https://herdr.dev/docs");
});

it("finds several per string, in order", () => {
expect(hrefs("http://a.dev x https://b.dev/y")).toEqual(["http://a.dev", "https://b.dev/y"]);
});

it("leaves scheme-less hosts alone — terminal output is full of dotted tokens", () => {
expect(hrefs("edit web/src/lib/links.ts, bump to v0.21.0, ping www.example.com")).toEqual([]);
});

// The whole XSS story: a dangerous scheme is unmatchable, not filtered out later.
it("never links a non-http scheme", () => {
expect(hrefs("javascript:alert(1) data:text/html,x file:///etc/passwd mailto:a@b.dev")).toEqual(
[],
);
});

it("drops trailing prose punctuation", () => {
expect(hrefs("Open https://a.dev/x.")).toEqual(["https://a.dev/x"]);
expect(hrefs("Open https://a.dev/x, then")).toEqual(["https://a.dev/x"]);
expect(hrefs("Really? https://a.dev/x!")).toEqual(["https://a.dev/x"]);
});

it("drops an unbalanced closing bracket but keeps a balanced one", () => {
expect(hrefs("(https://a.dev/x)")).toEqual(["https://a.dev/x"]);
expect(hrefs("https://a.dev/Foo_(bar)")).toEqual(["https://a.dev/Foo_(bar)"]);
});

it("stops at the characters that delimit a URL in prose", () => {
expect(hrefs('<https://a.dev/x> "https://b.dev/y" `https://c.dev/z`')).toEqual([
"https://a.dev/x",
"https://b.dev/y",
"https://c.dev/z",
]);
});

it("stops at a newline — a hard-wrapped URL yields only its first fragment", () => {
expect(hrefs("https://a.dev/very/long\n/tail")).toEqual(["https://a.dev/very/long"]);
});

// A stray BEL can survive the SGR parse (it terminates OSC, and lone ones do occur in the wild);
// it must never reach an href.
it("keeps control bytes out of the href", () => {
const bel = String.fromCharCode(7);
expect(hrefs(`https://a.dev/x${bel}y`)).toEqual(["https://a.dev/x"]);
});

it("ignores a scheme with no host", () => {
expect(hrefs("https:// https://.")).toEqual([]);
});

it("keeps query strings, fragments and ports whole", () => {
expect(hrefs("http://localhost:5173/a?b=c&d=e#frag next")).toEqual([
"http://localhost:5173/a?b=c&d=e#frag",
]);
});
});
Loading