diff --git a/CHANGELOG.md b/CHANGELOG.md index b37a5cb..d461436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/herdr-plugin.toml b/herdr-plugin.toml index 0b322d5..200739f 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -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"] diff --git a/package.json b/package.json index 70e37b2..192c75b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/web/package.json b/web/package.json index 85af38f..2313446 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "collie-web", - "version": "0.19.0", + "version": "0.20.0", "private": true, "license": "MIT", "type": "module", diff --git a/web/src/components/ansi-output.test.tsx b/web/src/components/ansi-output.test.tsx new file mode 100644 index 0000000..6041841 --- /dev/null +++ b/web/src/components/ansi-output.test.tsx @@ -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> & { text: string }) { + const { container } = render(); + 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,\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\]/); + }); +}); diff --git a/web/src/components/ansi-output.tsx b/web/src/components/ansi-output.tsx index f3abd99..bd1a56c 100644 --- a/web/src/components/ansi-output.tsx +++ b/web/src/components/ansi-output.tsx @@ -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"; @@ -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"; @@ -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
 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]",
@@ -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,
@@ -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]);
@@ -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 && (
-          
-            {rawBlocks.map((block, bi) => (
-              
-                {bi > 0 ? "\n" : null}
-                {block.lines.map((line, li) => (
-                  
-                    {li > 0 ? "\n" : null}
-                    {line.segments.map((s, si) => (
-                      
-                        {s.text}
-                      
-                    ))}
-                  
-                ))}
-              
-            ))}
-          
- )} - {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 ( + + {p.text} + + ); + }); + }; + + // 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 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 {renderFind(p.text, pieceStart)}; + return ( + + {renderFind(p.text, pieceStart)} + + ); + }); + }; + const renderBlock = (block: RawBlock, bi: number) => { if (bi > 0) offset += 1; // the "\n" separating this block from the previous return ( @@ -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 ( - {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 ( - - {p.text} - - ); - })} + {renderSegment(s.text, segStart)} ); }); diff --git a/web/src/lib/links.test.ts b/web/src/lib/links.test.ts new file mode 100644 index 0000000..1d02155 --- /dev/null +++ b/web/src/lib/links.test.ts @@ -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://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", + ]); + }); +}); diff --git a/web/src/lib/links.ts b/web/src/lib/links.ts new file mode 100644 index 0000000..82ab5bb --- /dev/null +++ b/web/src/lib/links.ts @@ -0,0 +1,101 @@ +// URL autolinking for the pane mirror. Terminal output has no markup — a URL is just characters — +// so "clickable links" means *finding* them in the visible text and wrapping those ranges in +// anchors. This module only computes offsets and hrefs; no HTML is built here and the renderer +// still puts every character into a React text node (CLAUDE.md → "Security posture"). +// +// Offsets index the same visible string `find.ts` searches (segments' text concatenated, "\n" +// between lines), so the renderer can thread ONE running offset through blocks → lines → segments +// and split by both link ranges and find matches in the same coordinate space. + +export interface LinkMatch { + /** Start offset into the visible text. */ + start: number; + /** End offset (exclusive). */ + end: number; + /** The href to navigate to — always `http(s)://…`, by construction of the scanner. */ + href: string; +} + +// Explicit schemes only. `www.foo.com`-style bare hosts are deliberately NOT matched: terminal +// output is dense with dotted tokens (file names, module paths, versions, IPs) and a host-shaped +// heuristic turns them into links you can't select as text. A scheme is an unambiguous signal, and +// it is also the whole XSS story — `javascript:` and `data:` are unmatchable, not filtered. +// +// The character class is a stop-set rather than an allow-set (RFC 3986 permits a lot): whitespace, +// the quote/bracket characters that conventionally *delimit* a URL in prose, and the backslash. +// Control bytes are cut afterwards (`cutControls`), trailing prose punctuation too (`trimTrailing`). +const URL_SCAN = /https?:\/\/[^\s<>"'`\\{}|^[\]]+/gi; + +// Sentence punctuation that is almost never the last character of a real URL. +const TRAILING_PUNCT = ".,;:!?*_~'\"’”"; + +const CLOSERS: Record = { ")": "(", "]": "[", "}": "{" }; + +function count(s: string, ch: string): number { + let n = 0; + for (const c of s) if (c === ch) n++; + return n; +} + +/** Truncate at the first control byte — one can survive the SGR parse and must not enter an href. */ +function cutControls(url: string): string { + for (let i = 0; i < url.length; i++) { + const c = url.charCodeAt(i); + if (c < 0x20 || c === 0x7f) return url.slice(0, i); + } + return url; +} + +/** + * Trim punctuation that belongs to the surrounding prose, not the URL. + * + * `See https://x.dev/a.` → drop the full stop. `(https://x.dev/a)` → drop the paren, because the + * URL contains no matching `(`. But `https://x.dev/a_(b)` keeps its `)`, since the closer is + * balanced inside the URL itself — Wikipedia-shaped links stay whole. + */ +function trimTrailing(url: string): string { + let end = url.length; + for (;;) { + const ch = url[end - 1]; + if (ch === undefined) break; + if (TRAILING_PUNCT.includes(ch)) { + end--; + continue; + } + const opener = CLOSERS[ch]; + if (opener) { + const slice = url.slice(0, end); + if (count(slice, opener) < count(slice, ch)) { + end--; + continue; + } + } + break; + } + return url.slice(0, end); +} + +// After trimming there must still be a plausible host: at least one alphanumeric right after the +// `//`. Guards against `https://` on its own, and against a match that trimmed back to bare scheme. +const HAS_HOST = /^https?:\/\/[a-z0-9]/i; + +/** + * Find every http(s) URL in `text`, as sorted, non-overlapping [start, end) ranges. + * + * A URL the terminal hard-wrapped across two lines is found only as its first fragment: the scan + * stops at the newline, and stitching wrapped lines back together would mean knowing the pane's + * column width and guessing which breaks were soft. A half-URL that opens the right host beats a + * wrong URL assembled from two unrelated lines. + */ +export function findLinks(text: string): LinkMatch[] { + const links: LinkMatch[] = []; + URL_SCAN.lastIndex = 0; + for (;;) { + const m = URL_SCAN.exec(text); + if (!m) break; + const href = trimTrailing(cutControls(m[0])); + if (!HAS_HOST.test(href)) continue; + links.push({ start: m.index, end: m.index + href.length, href }); + } + return links; +}