From 874b30b7cf218f6114dea14a60c8178a2b38ae2d Mon Sep 17 00:00:00 2001 From: Dennnis Date: Wed, 22 Apr 2026 21:05:30 +0000 Subject: [PATCH 01/11] feat(dom): add opt-in linkify option wiring (no-op renderer) --- packages/@wterm/dom/src/index.ts | 2 + packages/@wterm/dom/src/linkify.ts | 91 +++++++++++++++++++++++++++++ packages/@wterm/dom/src/renderer.ts | 10 +++- packages/@wterm/dom/src/wterm.ts | 11 +++- 4 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 packages/@wterm/dom/src/linkify.ts diff --git a/packages/@wterm/dom/src/index.ts b/packages/@wterm/dom/src/index.ts index 1ab81ec..c14ba35 100644 --- a/packages/@wterm/dom/src/index.ts +++ b/packages/@wterm/dom/src/index.ts @@ -10,4 +10,6 @@ export type { PerfStats, UnhandledEntry, } from "./debug.js"; +export type { LinkifyOption, LinkifyConfig, UrlRange } from "./linkify.js"; +export { DEFAULT_URL_PATTERN, findUrls, trimTrailing } from "./linkify.js"; export * from "@wterm/core"; diff --git a/packages/@wterm/dom/src/linkify.ts b/packages/@wterm/dom/src/linkify.ts new file mode 100644 index 0000000..2edc3e5 --- /dev/null +++ b/packages/@wterm/dom/src/linkify.ts @@ -0,0 +1,91 @@ +// Default regex for identifying URLs in terminal output. Conservative: +// requires an explicit http:// or https:// scheme, excludes whitespace and +// characters that routinely surround URLs in prose/brackets. Trailing +// punctuation that's almost always grammar (not part of the URL) is stripped +// post-match by trimTrailing() below. +export const DEFAULT_URL_PATTERN = /\bhttps?:\/\/[^\s<>"'`]+/g; + +// Characters to strip from the end of a match. Matches iTerm2 / kitty +// heuristics: a URL that ends with ')' is kept only if its unmatched +// parenthesis count is zero (Wikipedia-style URLs contain '(' and ')'). +const TRAILING_PUNCT = /[.,;:!?>\]}"'`]$/; + +export function trimTrailing(url: string): string { + let out = url; + while (out.length > 0) { + if (TRAILING_PUNCT.test(out)) { + out = out.slice(0, -1); + continue; + } + // Balance trailing ')': strip if there are more ')' than '(' + if (out.endsWith(")")) { + const opens = (out.match(/\(/g) || []).length; + const closes = (out.match(/\)/g) || []).length; + if (closes > opens) { + out = out.slice(0, -1); + continue; + } + } + break; + } + return out; +} + +export interface UrlRange { + /** inclusive start column (0-based) */ + start: number; + /** exclusive end column */ + end: number; + /** the matched URL, with trailing punctuation stripped */ + url: string; +} + +export interface LinkifyConfig { + /** Regex used to identify URLs. Must be a /g regex. */ + pattern?: RegExp; + /** + * Optional click handler. If provided, fires before the browser's default + * navigation. Call `event.preventDefault()` to suppress the default open. + */ + onClick?: (url: string, event: MouseEvent) => void; +} + +export type LinkifyOption = boolean | LinkifyConfig; + +export interface NormalizedLinkify { + enabled: boolean; + pattern: RegExp; + onClick: ((url: string, event: MouseEvent) => void) | null; +} + +export function normalizeLinkify(option: LinkifyOption | undefined): NormalizedLinkify { + if (!option) return { enabled: false, pattern: DEFAULT_URL_PATTERN, onClick: null }; + if (option === true) return { enabled: true, pattern: DEFAULT_URL_PATTERN, onClick: null }; + return { + enabled: true, + pattern: option.pattern ?? DEFAULT_URL_PATTERN, + onClick: option.onClick ?? null, + }; +} + +// Find URL ranges in a single row's text. Each match is returned with the +// columns of the URL WITHOUT trailing punctuation. The regex is executed with +// a fresh lastIndex every call (safe for global regexes). +export function findUrls(rowText: string, pattern: RegExp = DEFAULT_URL_PATTERN): UrlRange[] { + if (!pattern.global) { + throw new Error("linkify pattern must be a global (/g) regex"); + } + pattern.lastIndex = 0; + const ranges: UrlRange[] = []; + let m: RegExpExecArray | null; + while ((m = pattern.exec(rowText)) !== null) { + const rawUrl = m[0]; + const url = trimTrailing(rawUrl); + if (!url) continue; + ranges.push({ start: m.index, end: m.index + url.length, url }); + // Guard against zero-width matches (mis-specified custom regex) to avoid + // infinite loops. Force forward progress. + if (m.index === pattern.lastIndex) pattern.lastIndex++; + } + return ranges; +} diff --git a/packages/@wterm/dom/src/renderer.ts b/packages/@wterm/dom/src/renderer.ts index 5f6ce51..a8480d0 100644 --- a/packages/@wterm/dom/src/renderer.ts +++ b/packages/@wterm/dom/src/renderer.ts @@ -1,4 +1,5 @@ import type { WasmBridge } from "@wterm/core"; +import type { NormalizedLinkify } from "./linkify.js"; const DEFAULT_COLOR = 256; const FLAG_BOLD = 0x01; @@ -169,8 +170,15 @@ export class Renderer { private _scrollbackRowEls: HTMLDivElement[] = []; private _renderedScrollbackCount = 0; - constructor(container: HTMLElement) { + private linkify: NormalizedLinkify; + + constructor(container: HTMLElement, options: { linkify?: NormalizedLinkify } = {}) { this.container = container; + this.linkify = options.linkify ?? { + enabled: false, + pattern: /\bhttps?:\/\/[^\s<>"'`]+/g, + onClick: null, + }; } setup(cols: number, rows: number): void { diff --git a/packages/@wterm/dom/src/wterm.ts b/packages/@wterm/dom/src/wterm.ts index 5552297..2ff340e 100644 --- a/packages/@wterm/dom/src/wterm.ts +++ b/packages/@wterm/dom/src/wterm.ts @@ -2,6 +2,7 @@ import { WasmBridge } from "@wterm/core"; import { Renderer } from "./renderer.js"; import { InputHandler } from "./input.js"; import { DebugAdapter } from "./debug.js"; +import { normalizeLinkify, type LinkifyOption, type NormalizedLinkify } from "./linkify.js"; export interface WTermOptions { cols?: number; @@ -10,6 +11,12 @@ export interface WTermOptions { autoResize?: boolean; cursorBlink?: boolean; debug?: boolean; + /** + * Enable clickable URL anchors in rendered output. `true` uses the default + * regex; pass an object to customize. Default: disabled. + * Limitation (v1): URLs that wrap across terminal lines are not joined. + */ + linkify?: LinkifyOption; onData?: (data: string) => void; onTitle?: (title: string) => void; onResize?: (cols: number, rows: number) => void; @@ -25,6 +32,7 @@ export class WTerm { private wasmUrl: string | undefined; private _debugEnabled: boolean; + private _linkify: NormalizedLinkify; private renderer: Renderer | null = null; private input: InputHandler | null = null; private rafId: number | null = null; @@ -47,6 +55,7 @@ export class WTerm { this.rows = options.rows || 24; this.autoResize = options.autoResize !== false; this._debugEnabled = options.debug ?? false; + this._linkify = normalizeLinkify(options.linkify); this.onData = options.onData || null; this.onTitle = options.onTitle || null; @@ -79,7 +88,7 @@ export class WTerm { this._setRowHeight(); - this.renderer = new Renderer(this._container); + this.renderer = new Renderer(this._container, { linkify: this._linkify }); this.renderer.setup(this.cols, this.rows); this.input = new InputHandler( From 1dc8b60b79794e1e26a37ea9c7c288c52190a5d7 Mon Sep 17 00:00:00 2001 From: Dennnis Date: Wed, 22 Apr 2026 21:12:18 +0000 Subject: [PATCH 02/11] refactor(dom): import DEFAULT_URL_PATTERN in Renderer instead of inlining --- packages/@wterm/dom/src/renderer.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/@wterm/dom/src/renderer.ts b/packages/@wterm/dom/src/renderer.ts index a8480d0..dafac86 100644 --- a/packages/@wterm/dom/src/renderer.ts +++ b/packages/@wterm/dom/src/renderer.ts @@ -1,5 +1,5 @@ import type { WasmBridge } from "@wterm/core"; -import type { NormalizedLinkify } from "./linkify.js"; +import { DEFAULT_URL_PATTERN, type NormalizedLinkify } from "./linkify.js"; const DEFAULT_COLOR = 256; const FLAG_BOLD = 0x01; @@ -176,7 +176,7 @@ export class Renderer { this.container = container; this.linkify = options.linkify ?? { enabled: false, - pattern: /\bhttps?:\/\/[^\s<>"'`]+/g, + pattern: DEFAULT_URL_PATTERN, onClick: null, }; } From 02393962cb3fad26c7384bdef6ff0d84a39b5ac1 Mon Sep 17 00:00:00 2001 From: Dennnis Date: Wed, 22 Apr 2026 21:13:56 +0000 Subject: [PATCH 03/11] test(dom): unit tests for linkify helpers --- .../@wterm/dom/src/__tests__/linkify.test.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 packages/@wterm/dom/src/__tests__/linkify.test.ts diff --git a/packages/@wterm/dom/src/__tests__/linkify.test.ts b/packages/@wterm/dom/src/__tests__/linkify.test.ts new file mode 100644 index 0000000..f1061c5 --- /dev/null +++ b/packages/@wterm/dom/src/__tests__/linkify.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { findUrls, trimTrailing, DEFAULT_URL_PATTERN } from "../linkify.js"; + +describe("trimTrailing", () => { + it("leaves clean URLs intact", () => { + expect(trimTrailing("https://example.com/path")).toBe("https://example.com/path"); + }); + + it("strips trailing period", () => { + expect(trimTrailing("https://example.com.")).toBe("https://example.com"); + }); + + it("strips multiple trailing punctuation chars", () => { + expect(trimTrailing("https://x.com/a.")).toBe("https://x.com/a"); + expect(trimTrailing("https://x.com/a,")).toBe("https://x.com/a"); + expect(trimTrailing("https://x.com/a?!")).toBe("https://x.com/a"); + }); + + it("keeps paired parentheses in Wikipedia-style URLs", () => { + expect(trimTrailing("https://en.wikipedia.org/wiki/Foo_(bar)")).toBe( + "https://en.wikipedia.org/wiki/Foo_(bar)", + ); + }); + + it("strips trailing ')' when unbalanced", () => { + expect(trimTrailing("https://example.com/a)")).toBe("https://example.com/a"); + }); +}); + +describe("findUrls", () => { + it("finds a single URL", () => { + const ranges = findUrls("go to https://example.com/ now"); + expect(ranges).toHaveLength(1); + expect(ranges[0]).toEqual({ + start: 6, + end: 6 + "https://example.com/".length, + url: "https://example.com/", + }); + }); + + it("finds multiple URLs on one line", () => { + const text = "see http://a.com and https://b.com/x end"; + const ranges = findUrls(text); + expect(ranges).toHaveLength(2); + expect(ranges[0].url).toBe("http://a.com"); + expect(ranges[1].url).toBe("https://b.com/x"); + }); + + it("strips trailing punctuation from ranges", () => { + const text = "visit https://example.com."; + const ranges = findUrls(text); + expect(ranges).toHaveLength(1); + expect(ranges[0].url).toBe("https://example.com"); + // end must reflect stripped length + expect(ranges[0].end).toBe(text.indexOf("https") + "https://example.com".length); + }); + + it("ignores non-URL text", () => { + expect(findUrls("nothing interesting here")).toHaveLength(0); + expect(findUrls("www.example.com (no scheme)")).toHaveLength(0); + }); + + it("handles empty input", () => { + expect(findUrls("")).toHaveLength(0); + }); + + it("accepts a custom /g pattern", () => { + const custom = /\bJIRA-\d+\b/g; + const ranges = findUrls("see JIRA-123 and JIRA-456", custom); + expect(ranges.map((r) => r.url)).toEqual(["JIRA-123", "JIRA-456"]); + }); + + it("rejects a non-global regex", () => { + expect(() => findUrls("x", /https?:\/\/x/)).toThrow(/global/); + }); + + it("is safe to call repeatedly (lastIndex reset)", () => { + const pattern = DEFAULT_URL_PATTERN; + const r1 = findUrls("https://a.com", pattern); + const r2 = findUrls("https://b.com", pattern); + expect(r1[0].url).toBe("https://a.com"); + expect(r2[0].url).toBe("https://b.com"); + }); +}); From 276b76d053bc41167bbba9b909f80dd734b970eb Mon Sep 17 00:00:00 2001 From: Dennnis Date: Wed, 22 Apr 2026 21:17:43 +0000 Subject: [PATCH 04/11] feat(dom): render URLs as clickable anchors when linkify is enabled --- .../@wterm/dom/src/__tests__/renderer.test.ts | 33 ++++++ packages/@wterm/dom/src/renderer.ts | 104 +++++++++++++----- 2 files changed, 111 insertions(+), 26 deletions(-) diff --git a/packages/@wterm/dom/src/__tests__/renderer.test.ts b/packages/@wterm/dom/src/__tests__/renderer.test.ts index 678ea63..21b9007 100644 --- a/packages/@wterm/dom/src/__tests__/renderer.test.ts +++ b/packages/@wterm/dom/src/__tests__/renderer.test.ts @@ -122,4 +122,37 @@ describe("Renderer", () => { expect(span?.getAttribute("style")).toMatch(/font-weight:\s*bold/); }); }); + + describe("linkify", () => { + function makeLinkifyBridge(rowText: string, cols?: number) { + const width = cols ?? rowText.length; + const grid: CellData[][] = [ + Array.from({ length: width }, (_, i) => + i < rowText.length ? makeCell(rowText[i]) : { char: 0, fg: 256, bg: 256, flags: 0 }, + ), + ]; + const bridge = createMockBridge(width, 1, grid); + bridge.getCursor = () => ({ row: 0, col: -1, visible: false }); + return bridge; + } + + it("renders a URL as an anchor when linkify is enabled", () => { + const bridge = makeLinkifyBridge("go https://example.com/ now"); + const renderer = new Renderer(container, { + linkify: { + enabled: true, + pattern: /\bhttps?:\/\/[^\s<>"'`]+/g, + onClick: null, + }, + }); + renderer.render(bridge as any); + + const anchor = container.querySelector("a.term-link"); + expect(anchor).not.toBeNull(); + expect(anchor?.getAttribute("href")).toBe("https://example.com/"); + expect(anchor?.getAttribute("target")).toBe("_blank"); + expect(anchor?.getAttribute("rel")).toBe("noopener noreferrer"); + expect(anchor?.textContent).toBe("https://example.com/"); + }); + }); }); diff --git a/packages/@wterm/dom/src/renderer.ts b/packages/@wterm/dom/src/renderer.ts index dafac86..ae5454d 100644 --- a/packages/@wterm/dom/src/renderer.ts +++ b/packages/@wterm/dom/src/renderer.ts @@ -1,5 +1,5 @@ -import type { WasmBridge } from "@wterm/core"; -import { DEFAULT_URL_PATTERN, type NormalizedLinkify } from "./linkify.js"; +import type { CellData, WasmBridge } from "@wterm/core"; +import { DEFAULT_URL_PATTERN, findUrls, type NormalizedLinkify } from "./linkify.js"; const DEFAULT_COLOR = 256; const FLAG_BOLD = 0x01; @@ -204,42 +204,90 @@ export class Renderer { private _buildRowContent( rowEl: HTMLDivElement, - getCell: (col: number) => { - char: number; - fg: number; - bg: number; - flags: number; - }, + getCell: (col: number) => CellData, lineLen: number, cursorCol: number, rowIndex: number, ): void { rowEl.textContent = ""; + // Pre-pass 1: collect the plain text of the row so the linkify regex can + // run against it. Cells outside lineLen or with non-printable codepoints + // become spaces (matching the row-fill behavior below). + let rowText = ""; + for (let col = 0; col < this.cols; col++) { + const cell = getCell(col); + const inBounds = col < lineLen; + const cp = inBounds ? cell.char : 0; + const isBlock = inBounds && cp >= 0x2580 && cp <= 0x259f; + if (isBlock) { + // Block glyphs break URL runs — treat them as a non-URL character. + rowText += ""; + } else { + rowText += inBounds && cp >= 32 ? String.fromCodePoint(cp) : " "; + } + } + + // Pre-pass 2: find URL ranges (empty when linkify disabled). + const urlRanges = this.linkify.enabled ? findUrls(rowText, this.linkify.pattern) : []; + + function urlIdxAt(col: number): number { + for (let i = 0; i < urlRanges.length; i++) { + const r = urlRanges[i]; + if (col >= r.start && col < r.end) return i; + } + return -1; + } + + // Render state. let runStyle = ""; let runText = ""; let runStart = 0; + let runUrlIdx = -1; + + // If the current run is inside a URL, `currentAnchor` is the open + // that should receive the span(s). When we leave the URL or change to a + // different URL, we close (null out) the anchor. + let currentAnchor: HTMLAnchorElement | null = null; + + const openAnchor = (urlIdx: number): HTMLAnchorElement => { + const a = document.createElement("a"); + a.className = "term-link"; + a.href = urlRanges[urlIdx].url; + a.target = "_blank"; + a.rel = "noopener noreferrer"; + rowEl.appendChild(a); + return a; + }; + + const appendInto = (parent: HTMLElement, text: string, style: string): void => { + const span = document.createElement("span"); + if (style) span.style.cssText = style; + span.textContent = text; + parent.appendChild(span); + }; - const flushRun = (endCol: number) => { + const flushRun = (endCol: number): void => { if (!runText) return; + const target: HTMLElement = + runUrlIdx !== -1 + ? (currentAnchor ?? (currentAnchor = openAnchor(runUrlIdx))) + : rowEl; if (cursorCol >= runStart && cursorCol < endCol) { const offset = cursorCol - runStart; const before = runText.slice(0, offset); const cursorChar = runText[offset]; const after = runText.slice(offset + 1); - - if (before) appendRun(rowEl, before, runStyle); - + if (before) appendInto(target, before, runStyle); const cursorSpan = document.createElement("span"); cursorSpan.className = "term-cursor"; if (runStyle) cursorSpan.style.cssText = runStyle; cursorSpan.textContent = cursorChar; - rowEl.appendChild(cursorSpan); - - if (after) appendRun(rowEl, after, runStyle); + target.appendChild(cursorSpan); + if (after) appendInto(target, after, runStyle); } else { - appendRun(rowEl, runText, runStyle); + appendInto(target, runText, runStyle); } }; @@ -249,30 +297,36 @@ export class Renderer { const cp = inBounds ? cell.char : 0; if (inBounds && cp >= 0x2580 && cp <= 0x259f) { + // Block glyph — always flushes the current run (same as before), and + // block glyphs are never inside anchors (pre-pass 1 filtered them). flushRun(col); - + if (currentAnchor) currentAnchor = null; const colors = resolveColors(cell.fg, cell.bg, cell.flags); const span = document.createElement("span"); - span.className = - col === cursorCol ? "term-block term-cursor" : "term-block"; + span.className = col === cursorCol ? "term-block term-cursor" : "term-block"; span.style.background = getBlockBackground(cp, colors.fg, colors.bg); if (cell.flags & FLAG_DIM) span.style.opacity = "0.5"; rowEl.appendChild(span); - runStyle = ""; runText = ""; runStart = col + 1; + runUrlIdx = -1; } else { const ch = inBounds && cp >= 32 ? String.fromCodePoint(cp) : " "; - const style = inBounds - ? buildCellStyle(cell.fg, cell.bg, cell.flags) - : ""; + const style = inBounds ? buildCellStyle(cell.fg, cell.bg, cell.flags) : ""; + const urlIdx = urlIdxAt(col); - if (style !== runStyle) { + if (style !== runStyle || urlIdx !== runUrlIdx) { flushRun(col); + if (urlIdx !== runUrlIdx) { + // Leaving old URL scope (if any) — close the anchor so the next + // URL-internal run opens a fresh one. + currentAnchor = null; + } runStyle = style; runText = ch; runStart = col; + runUrlIdx = urlIdx; } else { runText += ch; } @@ -281,8 +335,6 @@ export class Renderer { flushRun(this.cols); // Extend the row background when the line fills the full width. - // When lineLen < cols, bgCss stays "" which clears any stale bg - // via the prevRowBg comparison below. let bgCss = ""; if (lineLen >= this.cols && this.cols > 0) { const lastCell = getCell(this.cols - 1); From 297a0e8f5556a3b34ba6b981c9dbcc15d31833af Mon Sep 17 00:00:00 2001 From: Dennnis Date: Wed, 22 Apr 2026 21:19:20 +0000 Subject: [PATCH 05/11] fix(dom): keep rowText aligned with grid cols for block glyphs Block glyphs wrote an empty string into the linkify pre-pass rowText, causing findUrls to return ranges off by one per preceding block, so URLs following a block glyph rendered with the wrong boundaries. Emit a space placeholder so rowText is always exactly this.cols long. --- packages/@wterm/dom/src/renderer.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/@wterm/dom/src/renderer.ts b/packages/@wterm/dom/src/renderer.ts index ae5454d..d6305ff 100644 --- a/packages/@wterm/dom/src/renderer.ts +++ b/packages/@wterm/dom/src/renderer.ts @@ -212,19 +212,20 @@ export class Renderer { rowEl.textContent = ""; // Pre-pass 1: collect the plain text of the row so the linkify regex can - // run against it. Cells outside lineLen or with non-printable codepoints - // become spaces (matching the row-fill behavior below). + // run against it. One character per column so that URL ranges returned + // by findUrls line up 1:1 with grid columns. Block glyphs and non- + // printables become a space — a URL-breaking character that also + // preserves col→rowText alignment. let rowText = ""; for (let col = 0; col < this.cols; col++) { const cell = getCell(col); const inBounds = col < lineLen; const cp = inBounds ? cell.char : 0; const isBlock = inBounds && cp >= 0x2580 && cp <= 0x259f; - if (isBlock) { - // Block glyphs break URL runs — treat them as a non-URL character. - rowText += ""; + if (isBlock || !inBounds || cp < 32) { + rowText += " "; } else { - rowText += inBounds && cp >= 32 ? String.fromCodePoint(cp) : " "; + rowText += String.fromCodePoint(cp); } } From 846ebc63758465e3ee1cbe71847a1032f09c667f Mon Sep 17 00:00:00 2001 From: Dennnis Date: Wed, 22 Apr 2026 21:21:07 +0000 Subject: [PATCH 06/11] test(dom): linkify edge cases (multi URL, styles, scrollback, cursor, block-glyph alignment) --- .../@wterm/dom/src/__tests__/renderer.test.ts | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/packages/@wterm/dom/src/__tests__/renderer.test.ts b/packages/@wterm/dom/src/__tests__/renderer.test.ts index 21b9007..fd903fc 100644 --- a/packages/@wterm/dom/src/__tests__/renderer.test.ts +++ b/packages/@wterm/dom/src/__tests__/renderer.test.ts @@ -154,5 +154,128 @@ describe("Renderer", () => { expect(anchor?.getAttribute("rel")).toBe("noopener noreferrer"); expect(anchor?.textContent).toBe("https://example.com/"); }); + + it("renders multiple URLs on one row as separate anchors", () => { + const bridge = makeLinkifyBridge("see http://a.com and https://b.com/x"); + const renderer = new Renderer(container, { + linkify: { enabled: true, pattern: /\bhttps?:\/\/[^\s<>"'`]+/g, onClick: null }, + }); + renderer.render(bridge as any); + const anchors = container.querySelectorAll("a.term-link"); + expect(anchors).toHaveLength(2); + expect(anchors[0].getAttribute("href")).toBe("http://a.com"); + expect(anchors[1].getAttribute("href")).toBe("https://b.com/x"); + }); + + it("wraps a styled URL span in a single anchor", () => { + const FLAG_BOLD = 0x01; + const text = "https://example.com"; + const cells = Array.from(text).map((ch, i) => + // Make the last 7 chars (".com") bold to force a style split inside + // the URL. Cols 12..18 bold. + i >= 12 ? makeCell(ch, 256, 256, FLAG_BOLD) : makeCell(ch), + ); + const bridge = createMockBridge(text.length, 1, [cells]); + bridge.getCursor = () => ({ row: 0, col: -1, visible: false }); + const renderer = new Renderer(container, { + linkify: { enabled: true, pattern: /\bhttps?:\/\/[^\s<>"'`]+/g, onClick: null }, + }); + renderer.render(bridge as any); + const anchors = container.querySelectorAll("a.term-link"); + expect(anchors).toHaveLength(1); + expect(anchors[0].getAttribute("href")).toBe(text); + // Both styled and unstyled spans must live inside the anchor. + expect(anchors[0].querySelectorAll("span").length).toBeGreaterThanOrEqual(2); + expect(anchors[0].textContent).toBe(text); + }); + + it("strips trailing punctuation from href", () => { + const bridge = makeLinkifyBridge("see https://example.com."); + const renderer = new Renderer(container, { + linkify: { enabled: true, pattern: /\bhttps?:\/\/[^\s<>"'`]+/g, onClick: null }, + }); + renderer.render(bridge as any); + const anchor = container.querySelector("a.term-link"); + expect(anchor?.getAttribute("href")).toBe("https://example.com"); + // The trailing '.' must still be rendered (outside the anchor) as plain text. + expect(container.textContent).toContain("https://example.com."); + }); + + it("emits no anchors when linkify is disabled (default)", () => { + const bridge = makeLinkifyBridge("see https://example.com"); + const renderer = new Renderer(container); // no linkify passed + renderer.render(bridge as any); + expect(container.querySelector("a.term-link")).toBeNull(); + }); + + it("accepts a custom /g regex pattern", () => { + const bridge = makeLinkifyBridge("see JIRA-42 please"); + const renderer = new Renderer(container, { + linkify: { enabled: true, pattern: /\bJIRA-\d+\b/g, onClick: null }, + }); + renderer.render(bridge as any); + const anchor = container.querySelector("a.term-link"); + // Default href is the raw match — users can provide onClick to override nav. + expect(anchor?.getAttribute("href")).toBe("JIRA-42"); + }); + + it("renders the cursor inside a URL anchor correctly", () => { + const text = "https://example.com"; + const cells = Array.from(text).map((ch) => makeCell(ch)); + const bridge = createMockBridge(text.length, 1, [cells]); + // Cursor on 'x' at col 9 + bridge.getCursor = () => ({ row: 0, col: 9, visible: true }); + const renderer = new Renderer(container, { + linkify: { enabled: true, pattern: /\bhttps?:\/\/[^\s<>"'`]+/g, onClick: null }, + }); + renderer.render(bridge as any); + const anchor = container.querySelector("a.term-link"); + const cursor = anchor?.querySelector(".term-cursor"); + expect(cursor).not.toBeNull(); + expect(cursor?.textContent).toBe("x"); + }); + + it("renders URLs inside scrollback rows as anchors", () => { + const text = "see https://scroll.example"; + const sbLen = text.length; + const bridge = createMockBridge(sbLen, 1, []); + bridge.getScrollbackCount = () => 1; + bridge.getScrollbackLineLen = () => sbLen; + bridge.getScrollbackCell = (_o: number, col: number): CellData => + col < text.length + ? makeCell(text[col]) + : { char: 0, fg: 256, bg: 256, flags: 0 }; + + const renderer = new Renderer(container, { + linkify: { enabled: true, pattern: /\bhttps?:\/\/[^\s<>"'`]+/g, onClick: null }, + }); + renderer.render(bridge as any); + + const scrollbackRow = container.querySelector(".term-scrollback-row"); + expect(scrollbackRow).not.toBeNull(); + const anchor = scrollbackRow!.querySelector("a.term-link"); + expect(anchor?.getAttribute("href")).toBe("https://scroll.example"); + }); + + it("keeps URL ranges aligned when a block glyph precedes the URL", () => { + // Regression test for the pre-pass: block glyphs must emit a space + // placeholder into rowText so findUrls ranges line up with grid cols. + const text = "https://a.com"; + const blockCp = 0x2588; // FULL BLOCK + const cells = [ + { char: blockCp, fg: 256, bg: 256, flags: 0 }, + makeCell(" "), + ...Array.from(text).map((ch) => makeCell(ch)), + ]; + const bridge = createMockBridge(cells.length, 1, [cells]); + bridge.getCursor = () => ({ row: 0, col: -1, visible: false }); + const renderer = new Renderer(container, { + linkify: { enabled: true, pattern: /\bhttps?:\/\/[^\s<>"'`]+/g, onClick: null }, + }); + renderer.render(bridge as any); + const anchor = container.querySelector("a.term-link"); + expect(anchor?.getAttribute("href")).toBe(text); + expect(anchor?.textContent).toBe(text); + }); }); }); From f874472b935d7522b10c65ca52c3081c11ad1cfb Mon Sep 17 00:00:00 2001 From: Dennnis Date: Wed, 22 Apr 2026 21:23:02 +0000 Subject: [PATCH 07/11] feat(dom): forward linkify clicks to optional onClick handler --- .../@wterm/dom/src/__tests__/renderer.test.ts | 21 +++++++++++++++++++ packages/@wterm/dom/src/renderer.ts | 6 ++++++ 2 files changed, 27 insertions(+) diff --git a/packages/@wterm/dom/src/__tests__/renderer.test.ts b/packages/@wterm/dom/src/__tests__/renderer.test.ts index fd903fc..a36df49 100644 --- a/packages/@wterm/dom/src/__tests__/renderer.test.ts +++ b/packages/@wterm/dom/src/__tests__/renderer.test.ts @@ -277,5 +277,26 @@ describe("Renderer", () => { expect(anchor?.getAttribute("href")).toBe(text); expect(anchor?.textContent).toBe(text); }); + + it("invokes onClick before default navigation and respects preventDefault", () => { + const bridge = makeLinkifyBridge("go https://example.com/"); + const seen: string[] = []; + const renderer = new Renderer(container, { + linkify: { + enabled: true, + pattern: /\bhttps?:\/\/[^\s<>"'`]+/g, + onClick: (url, ev) => { + seen.push(url); + ev.preventDefault(); + }, + }, + }); + renderer.render(bridge as any); + const anchor = container.querySelector("a.term-link")!; + const clickEv = new MouseEvent("click", { bubbles: true, cancelable: true }); + anchor.dispatchEvent(clickEv); + expect(seen).toEqual(["https://example.com/"]); + expect(clickEv.defaultPrevented).toBe(true); + }); }); }); diff --git a/packages/@wterm/dom/src/renderer.ts b/packages/@wterm/dom/src/renderer.ts index d6305ff..5ea0841 100644 --- a/packages/@wterm/dom/src/renderer.ts +++ b/packages/@wterm/dom/src/renderer.ts @@ -257,6 +257,12 @@ export class Renderer { a.href = urlRanges[urlIdx].url; a.target = "_blank"; a.rel = "noopener noreferrer"; + const onClick = this.linkify.onClick; + if (onClick) { + a.addEventListener("click", (ev) => { + onClick(urlRanges[urlIdx].url, ev); + }); + } rowEl.appendChild(a); return a; }; From ce45931df25b4df866420c0de26e4aececb7521c Mon Sep 17 00:00:00 2001 From: Dennnis Date: Wed, 22 Apr 2026 21:23:50 +0000 Subject: [PATCH 08/11] style(dom): hover styles for linkify anchors --- packages/@wterm/dom/src/terminal.css | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/@wterm/dom/src/terminal.css b/packages/@wterm/dom/src/terminal.css index a88e475..fadef6f 100644 --- a/packages/@wterm/dom/src/terminal.css +++ b/packages/@wterm/dom/src/terminal.css @@ -164,3 +164,15 @@ --term-color-14: #0184bc; --term-color-15: #ffffff; } + +/* Clickable URL anchors rendered when the `linkify` option is enabled. */ +.wterm a.term-link { + color: inherit; + text-decoration: none; + cursor: pointer; +} +.wterm a.term-link:hover, +.wterm a.term-link:focus-visible { + text-decoration: underline dotted; + text-underline-offset: 2px; +} From 3cbd83e70b9f22de8d159a57fddac2f2f394cc57 Mon Sep 17 00:00:00 2001 From: Dennnis Date: Wed, 22 Apr 2026 21:25:41 +0000 Subject: [PATCH 09/11] docs: document linkify option across package README, API reference, vanilla guide, and CHANGELOG --- CHANGELOG.md | 6 +++++ apps/docs/src/app/api-reference/page.mdx | 6 +++++ apps/docs/src/app/vanilla/page.mdx | 24 ++++++++++++++++++ packages/@wterm/dom/README.md | 32 ++++++++++++++++++++++++ 4 files changed, 68 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dad5d95..d694494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### New Features + +- **Clickable links** — `@wterm/dom` now supports an opt-in `linkify` option that renders `http(s)://` URLs in terminal output as real `` anchors (`target="_blank"`, `rel="noopener noreferrer"`), with optional custom regex and click interception. Pure renderer-side change — no wasm or cell-model changes. + ## 0.1.9 diff --git a/apps/docs/src/app/api-reference/page.mdx b/apps/docs/src/app/api-reference/page.mdx index 8350c2d..528d8c2 100644 --- a/apps/docs/src/app/api-reference/page.mdx +++ b/apps/docs/src/app/api-reference/page.mdx @@ -52,6 +52,12 @@ Both the React `` component and the vanilla `WTerm` constructor accept false Enable debug mode. Exposes a DebugAdapter on the WTerm instance (wt.debug) for inspecting escape sequences, cell data, render performance, and unhandled CSI sequences. + + linkify + boolean | { pattern?: RegExp; onClick?: (url, event) => void } + false + Render URLs in output as clickable <a> anchors (target="_blank", rel="noopener noreferrer"). Pass an object to override the regex or intercept clicks before default navigation. Per-row only — URLs wrapped across terminal lines are not auto-joined. + onData (data: string) => void diff --git a/apps/docs/src/app/vanilla/page.mdx b/apps/docs/src/app/vanilla/page.mdx index f5dd152..a2a555a 100644 --- a/apps/docs/src/app/vanilla/page.mdx +++ b/apps/docs/src/app/vanilla/page.mdx @@ -83,3 +83,27 @@ term.onData = (data) => ws.send(data); ``` See the full [WebSocketTransport](/api-reference#websockettransport) reference for all options, methods, and properties. + +## Clickable Links + +Pass `linkify: true` to render `http(s)://` URLs as clickable `` anchors (new tab, `rel="noopener noreferrer"`): + +```js +const term = new WTerm(document.getElementById("terminal"), { linkify: true }); +``` + +Customize with an object to swap the regex or intercept clicks: + +```js +new WTerm(el, { + linkify: { + pattern: /\bJIRA-\d+\b/g, + onClick: (url, ev) => { + ev.preventDefault(); + router.push(`/issue/${url}`); + }, + }, +}); +``` + +Detection is per rendered row — URLs that wrap across terminal lines render as two broken anchors. See the `linkify` row under [Terminal Options](/api-reference#terminal-options) for the full option shape. diff --git a/packages/@wterm/dom/README.md b/packages/@wterm/dom/README.md index 22098d6..7b47bff 100644 --- a/packages/@wterm/dom/README.md +++ b/packages/@wterm/dom/README.md @@ -46,6 +46,7 @@ new WTerm(element: HTMLElement, options?: WTermOptions) | `autoResize` | `boolean` | `true` | Auto-resize based on container dimensions | | `cursorBlink` | `boolean` | `false` | Enable cursor blinking animation | | `debug` | `boolean` | `false` | Enable debug mode. Exposes a `DebugAdapter` on the instance (`wt.debug`) for inspecting escape sequences, cell data, render performance, and unhandled CSI sequences. | +| `linkify` | `boolean \| { pattern?: RegExp; onClick?: (url, event) => void }` | `false` | Render URLs as clickable `` anchors — see [Clickable links](#clickable-links). | | `onData` | `(data: string) => void` | — | Called when the terminal produces data (user input or host response). When omitted, input is echoed back automatically. | | `onTitle` | `(title: string) => void` | — | Called when the terminal title changes | | `onResize` | `(cols: number, rows: number) => void` | — | Called on resize | @@ -95,6 +96,37 @@ element.classList.add("theme-monokai"); All colors use CSS custom properties (`--term-fg`, `--term-bg`, `--term-color-0` through `--term-color-15`, etc.) so you can define your own theme with plain CSS. +## Clickable links + +Pass `linkify: true` to turn `http://…` and `https://…` URLs in the output into real `` anchors: + +```ts +import { WTerm } from "@wterm/dom"; + +const term = new WTerm(container, { linkify: true }); +await term.init(); +term.write("visit https://example.com/ for more\r\n"); +// → +``` + +Anchors open in a new tab with `rel="noopener noreferrer"`. Default styles (dotted underline on hover, inherit color) are in the stylesheet. + +Pass an object to customize: + +```ts +new WTerm(container, { + linkify: { + pattern: /\bJIRA-\d+\b/g, // any global regex + onClick: (url, ev) => { + ev.preventDefault(); // suppress default navigation + openInAppRoute(url); + }, + }, +}); +``` + +**Limitation:** URLs that wrap across terminal lines are treated as two separate (broken) URLs — detection is per rendered row. Either use a wider terminal or have the emitting program hard-break the URL itself. + ## License Apache-2.0 From 3345a4c6c69539cf58088e81ec8bc6eb31cc9773 Mon Sep 17 00:00:00 2001 From: Dennnis Date: Fri, 24 Apr 2026 21:23:21 +0000 Subject: [PATCH 10/11] fix(dom): treat supplementary-plane cells as URL-breaking space MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserves the col→rowText 1:1 mapping that urlIdxAt() relies on. Code points above U+FFFF (emoji, supplementary plane) encode as surrogate pairs in JS strings (2 UTF-16 code units), which would make a single cell contribute 2 string indices and shift every subsequent column's URL mapping. Per VADE review on vercel-labs/wterm#42. --- packages/@wterm/dom/src/renderer.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/@wterm/dom/src/renderer.ts b/packages/@wterm/dom/src/renderer.ts index 5ea0841..161875c 100644 --- a/packages/@wterm/dom/src/renderer.ts +++ b/packages/@wterm/dom/src/renderer.ts @@ -213,16 +213,18 @@ export class Renderer { // Pre-pass 1: collect the plain text of the row so the linkify regex can // run against it. One character per column so that URL ranges returned - // by findUrls line up 1:1 with grid columns. Block glyphs and non- - // printables become a space — a URL-breaking character that also - // preserves col→rowText alignment. + // by findUrls line up 1:1 with grid columns. Block glyphs, non- + // printables, and supplementary-plane characters (U+10000+, which + // produce surrogate pairs — 2 UTF-16 code units — from a single cell) + // become a space, a URL-breaking character that preserves col→rowText + // alignment. let rowText = ""; for (let col = 0; col < this.cols; col++) { const cell = getCell(col); const inBounds = col < lineLen; const cp = inBounds ? cell.char : 0; const isBlock = inBounds && cp >= 0x2580 && cp <= 0x259f; - if (isBlock || !inBounds || cp < 32) { + if (isBlock || !inBounds || cp < 32 || cp > 0xffff) { rowText += " "; } else { rowText += String.fromCodePoint(cp); From f91b5adb6a66e24f5d8a90f624df6b5423471b3b Mon Sep 17 00:00:00 2001 From: Dennnis Date: Sun, 26 Apr 2026 03:06:41 +0000 Subject: [PATCH 11/11] fix(dom): join URLs that wrap across rows into one href Linkify previously ran per-row, so a URL that hard-wrapped at the column boundary became two anchors with two truncated hrefs (or none, when the second row started without https://). Group consecutive rows where the last column was written into ('continuesNext' heuristic), run the URL regex once on the joined text, and emit one anchor per row segment all sharing the same full href. Repaint a row when its URL ranges change so edits on row N+1 propagate to row N's anchor. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../@wterm/dom/src/__tests__/linkify.test.ts | 115 +++++++++++- .../@wterm/dom/src/__tests__/renderer.test.ts | 57 ++++++ packages/@wterm/dom/src/linkify.ts | 59 ++++++ packages/@wterm/dom/src/renderer.ts | 169 ++++++++++++++---- 4 files changed, 362 insertions(+), 38 deletions(-) diff --git a/packages/@wterm/dom/src/__tests__/linkify.test.ts b/packages/@wterm/dom/src/__tests__/linkify.test.ts index f1061c5..c2fff7d 100644 --- a/packages/@wterm/dom/src/__tests__/linkify.test.ts +++ b/packages/@wterm/dom/src/__tests__/linkify.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from "vitest"; -import { findUrls, trimTrailing, DEFAULT_URL_PATTERN } from "../linkify.js"; +import { + DEFAULT_URL_PATTERN, + findUrls, + findUrlsAcrossRows, + trimTrailing, +} from "../linkify.js"; describe("trimTrailing", () => { it("leaves clean URLs intact", () => { @@ -82,3 +87,111 @@ describe("findUrls", () => { expect(r2[0].url).toBe("https://b.com"); }); }); + +describe("findUrlsAcrossRows", () => { + // Helper: pad a string to exactly cols chars. + function pad(s: string, cols: number): string { + return s.length >= cols ? s.slice(0, cols) : s + " ".repeat(cols - s.length); + } + + it("joins a URL split across two rows into one full href", () => { + const cols = 20; + // URL spans the row boundary: 'https://example.com/' + 'page' = 24 chars + const url = "https://example.com/page"; + const r0 = url.slice(0, cols); // "https://example.com/" + const r1 = pad(url.slice(cols), cols); // "page" + const ranges = findUrlsAcrossRows( + [ + { rowText: r0, continuesNext: true }, + { rowText: r1, continuesNext: false }, + ], + cols, + ); + expect(ranges).toHaveLength(2); + expect(ranges[0]).toEqual([{ start: 0, end: cols, url }]); + expect(ranges[1]).toEqual([{ start: 0, end: 4, url }]); + }); + + it("does not join when the row does not continue to the next", () => { + const cols = 20; + const r0 = pad("https://a.com", cols); // not full-width, doesn't continue + const r1 = pad("more text", cols); + const ranges = findUrlsAcrossRows( + [ + { rowText: r0, continuesNext: false }, + { rowText: r1, continuesNext: false }, + ], + cols, + ); + expect(ranges[0]).toHaveLength(1); + expect(ranges[0][0].url).toBe("https://a.com"); + expect(ranges[1]).toHaveLength(0); + }); + + it("joins across three rows when the chain continues", () => { + const cols = 10; + const url = "https://example.com/abc"; // 23 chars → spans cols 0..22 + const r0 = url.slice(0, 10); // "https://ex" + const r1 = url.slice(10, 20); // "ample.com/" + const r2 = pad(url.slice(20), cols); // "abc" + const ranges = findUrlsAcrossRows( + [ + { rowText: r0, continuesNext: true }, + { rowText: r1, continuesNext: true }, + { rowText: r2, continuesNext: false }, + ], + cols, + ); + expect(ranges[0][0]).toEqual({ start: 0, end: 10, url }); + expect(ranges[1][0]).toEqual({ start: 0, end: 10, url }); + expect(ranges[2][0]).toEqual({ start: 0, end: 3, url }); + }); + + it("strips trailing punctuation from a wrapped URL's href", () => { + const cols = 20; + // ".com/page." trailing dot should be trimmed; period rendered as text on r1. + const r0 = "https://example.com/"; // exactly cols + const r1 = pad("page.", cols); + const ranges = findUrlsAcrossRows( + [ + { rowText: r0, continuesNext: true }, + { rowText: r1, continuesNext: false }, + ], + cols, + ); + expect(ranges[0][0].url).toBe("https://example.com/page"); + expect(ranges[1][0].url).toBe("https://example.com/page"); + // The trailing '.' on r1 is excluded from the range (col 4 not in [0,4)) + expect(ranges[1][0].end).toBe(4); + }); + + it("returns empty arrays for empty input", () => { + expect(findUrlsAcrossRows([], 80)).toEqual([]); + }); + + it("handles a non-URL row that happens to fill its width", () => { + const cols = 10; + // A row of '=' filling the width is not a URL; joining is harmless. + const r0 = "=========="; + const r1 = pad("done", cols); + const ranges = findUrlsAcrossRows( + [ + { rowText: r0, continuesNext: true }, + { rowText: r1, continuesNext: false }, + ], + cols, + ); + expect(ranges[0]).toHaveLength(0); + expect(ranges[1]).toHaveLength(0); + }); + + it("rejects a non-global regex", () => { + expect(() => + findUrlsAcrossRows( + [{ rowText: "x".padEnd(80, " "), continuesNext: false }], + 80, + /https?:\/\/x/, + ), + ).toThrow(/global/); + }); +}); diff --git a/packages/@wterm/dom/src/__tests__/renderer.test.ts b/packages/@wterm/dom/src/__tests__/renderer.test.ts index a36df49..81ceef8 100644 --- a/packages/@wterm/dom/src/__tests__/renderer.test.ts +++ b/packages/@wterm/dom/src/__tests__/renderer.test.ts @@ -278,6 +278,63 @@ describe("Renderer", () => { expect(anchor?.textContent).toBe(text); }); + it("joins a URL wrapped across two rows into anchors with the same full href", () => { + // 20-col grid. Full URL: "https://example.com/page" (24 chars). + // Row 0: "https://example.com/" (cols 0..19) + // Row 1: "page" + padding + const cols = 20; + const url = "https://example.com/page"; + const row0Text = url.slice(0, cols); + const row1Text = url.slice(cols); + const row0Cells = Array.from(row0Text).map((ch) => makeCell(ch)); + const row1Cells = Array.from({ length: cols }, (_, i) => + i < row1Text.length + ? makeCell(row1Text[i]) + : { char: 0, fg: 256, bg: 256, flags: 0 }, + ); + const bridge = createMockBridge(cols, 2, [row0Cells, row1Cells]); + bridge.getCursor = () => ({ row: 0, col: -1, visible: false }); + + const renderer = new Renderer(container, { + linkify: { enabled: true, pattern: /\bhttps?:\/\/[^\s<>"'`]+/g, onClick: null }, + }); + renderer.render(bridge as any); + + const anchors = container.querySelectorAll("a.term-link"); + expect(anchors).toHaveLength(2); + expect(anchors[0].getAttribute("href")).toBe(url); + expect(anchors[1].getAttribute("href")).toBe(url); + // Visible text on each row matches its segment. + expect(anchors[0].textContent).toBe(row0Text); + expect(anchors[1].textContent).toBe(row1Text); + }); + + it("does not join across rows when the boundary row has trailing space", () => { + // Row 0 ends with a space (last cell empty), so it should not join. + const cols = 20; + const row0Text = "https://example.com "; // 20 chars, trailing space + const row1Text = "extra"; + const row0Cells = Array.from(row0Text).map((ch) => + ch === " " ? { char: 0, fg: 256, bg: 256, flags: 0 } : makeCell(ch), + ); + const row1Cells = Array.from({ length: cols }, (_, i) => + i < row1Text.length + ? makeCell(row1Text[i]) + : { char: 0, fg: 256, bg: 256, flags: 0 }, + ); + const bridge = createMockBridge(cols, 2, [row0Cells, row1Cells]); + bridge.getCursor = () => ({ row: 0, col: -1, visible: false }); + + const renderer = new Renderer(container, { + linkify: { enabled: true, pattern: /\bhttps?:\/\/[^\s<>"'`]+/g, onClick: null }, + }); + renderer.render(bridge as any); + + const anchors = container.querySelectorAll("a.term-link"); + expect(anchors).toHaveLength(1); + expect(anchors[0].getAttribute("href")).toBe("https://example.com"); + }); + it("invokes onClick before default navigation and respects preventDefault", () => { const bridge = makeLinkifyBridge("go https://example.com/"); const seen: string[] = []; diff --git a/packages/@wterm/dom/src/linkify.ts b/packages/@wterm/dom/src/linkify.ts index 2edc3e5..68b44fd 100644 --- a/packages/@wterm/dom/src/linkify.ts +++ b/packages/@wterm/dom/src/linkify.ts @@ -89,3 +89,62 @@ export function findUrls(rowText: string, pattern: RegExp = DEFAULT_URL_PATTERN) } return ranges; } + +export interface RowInput { + /** Exactly `cols` characters: one entry per terminal column (the renderer's + * pre-pass shape). Out-of-bounds and non-printable cells must already be + * spaces — the regex relies on whitespace to terminate matches. */ + rowText: string; + /** True when this row soft-wraps into the next: the URL regex will be run + * on the joined text of all consecutive wrap-eligible rows so a URL split + * across rows yields multiple anchors sharing the same full `url`. */ + continuesNext: boolean; +} + +// Group consecutive rows where `continuesNext === true`, run the URL regex +// once on the joined text of each group, and map matches back to per-row +// column ranges. Each anchor in a wrap group carries the SAME full `url`. +export function findUrlsAcrossRows( + rows: RowInput[], + cols: number, + pattern: RegExp = DEFAULT_URL_PATTERN, +): UrlRange[][] { + if (!pattern.global) { + throw new Error("linkify pattern must be a global (/g) regex"); + } + const out: UrlRange[][] = rows.map(() => []); + if (rows.length === 0 || cols <= 0) return out; + + let i = 0; + while (i < rows.length) { + const groupStart = i; + while (i < rows.length - 1 && rows[i].continuesNext) i++; + const groupEnd = i; + + let joined = ""; + for (let r = groupStart; r <= groupEnd; r++) joined += rows[r].rowText; + + pattern.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = pattern.exec(joined)) !== null) { + const url = trimTrailing(m[0]); + if (!url) { + if (m.index === pattern.lastIndex) pattern.lastIndex++; + continue; + } + const matchStart = m.index; + const matchEnd = matchStart + url.length; + const firstOff = Math.floor(matchStart / cols); + const lastOff = Math.floor((matchEnd - 1) / cols); + for (let off = firstOff; off <= lastOff; off++) { + const rowBase = off * cols; + const start = Math.max(0, matchStart - rowBase); + const end = Math.min(cols, matchEnd - rowBase); + if (end > start) out[groupStart + off].push({ start, end, url }); + } + if (m.index === pattern.lastIndex) pattern.lastIndex++; + } + i = groupEnd + 1; + } + return out; +} diff --git a/packages/@wterm/dom/src/renderer.ts b/packages/@wterm/dom/src/renderer.ts index 161875c..242f7e6 100644 --- a/packages/@wterm/dom/src/renderer.ts +++ b/packages/@wterm/dom/src/renderer.ts @@ -1,5 +1,11 @@ import type { CellData, WasmBridge } from "@wterm/core"; -import { DEFAULT_URL_PATTERN, findUrls, type NormalizedLinkify } from "./linkify.js"; +import { + DEFAULT_URL_PATTERN, + findUrlsAcrossRows, + type NormalizedLinkify, + type RowInput, + type UrlRange, +} from "./linkify.js"; const DEFAULT_COLOR = 256; const FLAG_BOLD = 0x01; @@ -61,6 +67,13 @@ function appendRun(parent: HTMLElement, text: string, style: string): void { parent.appendChild(span); } +function serializeUrlRanges(ranges: UrlRange[]): string { + if (ranges.length === 0) return ""; + let out = ""; + for (const r of ranges) out += `${r.start},${r.end},${r.url}\n`; + return out; +} + function resolveColors( fg: number, bg: number, @@ -166,6 +179,10 @@ export class Renderer { private prevCursorCol = -1; private prevContainerBg = ""; private prevRowBg: string[] = []; + // Signature of last-rendered URL ranges per grid row. A row is repainted + // when its URL ranges change even if the bridge didn't dirty it — needed + // because edits in row N+1 can extend a wrapped URL anchor on row N. + private prevRowUrlSig: string[] = []; private _scrollbackRowEls: HTMLDivElement[] = []; private _renderedScrollbackCount = 0; @@ -187,6 +204,7 @@ export class Renderer { this.container.innerHTML = ""; this.rowEls = []; this.prevRowBg = []; + this.prevRowUrlSig = []; this._scrollbackRowEls = []; this._renderedScrollbackCount = 0; @@ -202,22 +220,16 @@ export class Renderer { this.prevCursorCol = -1; } - private _buildRowContent( - rowEl: HTMLDivElement, + // Pre-pass: collect the plain text of a row so the linkify regex can run + // against it. One character per column so that URL ranges line up 1:1 with + // grid columns. Block glyphs, non-printables, out-of-bounds cells, and + // supplementary-plane characters (U+10000+, which produce surrogate pairs — + // 2 UTF-16 code units — from a single cell) become a space, a URL-breaking + // character that preserves col→rowText alignment. + private _buildRowText( getCell: (col: number) => CellData, lineLen: number, - cursorCol: number, - rowIndex: number, - ): void { - rowEl.textContent = ""; - - // Pre-pass 1: collect the plain text of the row so the linkify regex can - // run against it. One character per column so that URL ranges returned - // by findUrls line up 1:1 with grid columns. Block glyphs, non- - // printables, and supplementary-plane characters (U+10000+, which - // produce surrogate pairs — 2 UTF-16 code units — from a single cell) - // become a space, a URL-breaking character that preserves col→rowText - // alignment. + ): string { let rowText = ""; for (let col = 0; col < this.cols; col++) { const cell = getCell(col); @@ -230,9 +242,18 @@ export class Renderer { rowText += String.fromCodePoint(cp); } } + return rowText; + } - // Pre-pass 2: find URL ranges (empty when linkify disabled). - const urlRanges = this.linkify.enabled ? findUrls(rowText, this.linkify.pattern) : []; + private _buildRowContent( + rowEl: HTMLDivElement, + getCell: (col: number) => CellData, + lineLen: number, + cursorCol: number, + rowIndex: number, + urlRanges: UrlRange[], + ): void { + rowEl.textContent = ""; function urlIdxAt(col: number): number { for (let i = 0; i < urlRanges.length; i++) { @@ -367,24 +388,6 @@ export class Renderer { } } - private _buildScrollbackRowEl( - bridge: WasmBridge, - sbOffset: number, - ): HTMLDivElement { - const rowEl = document.createElement("div"); - rowEl.className = "term-row term-scrollback-row"; - const lineLen = bridge.getScrollbackLineLen(sbOffset); - - this._buildRowContent( - rowEl, - (col) => bridge.getScrollbackCell(sbOffset, col), - lineLen, - -1, - -1, - ); - return rowEl; - } - private syncScrollback(bridge: WasmBridge): void { const scrollbackCount = bridge.getScrollbackCount(); @@ -395,8 +398,49 @@ export class Renderer { const firstGridRow = this.rowEls[0] ?? null; const fragment = document.createDocumentFragment(); + // Render newest-to-oldest so they appear in order in the fragment, but + // build URL ranges in chronological order across the batch so a URL + // soft-wrapped across two scrolled-off rows shares one href. + const lineLens: number[] = []; + const rowInputs: RowInput[] = []; for (let i = newCount - 1; i >= 0; i--) { - const rowEl = this._buildScrollbackRowEl(bridge, i); + const lineLen = bridge.getScrollbackLineLen(i); + const rowText = this._buildRowText( + (col) => bridge.getScrollbackCell(i, col), + lineLen, + ); + const lastCp = + this.cols > 0 ? bridge.getScrollbackCell(i, this.cols - 1).char : 0; + lineLens.push(lineLen); + rowInputs.push({ + rowText, + continuesNext: lastCp !== 0 && lastCp !== 0x20, + }); + } + // The very last row of the batch is adjacent to the grid; we don't + // know if the grid's first row continues *from* it, so don't try to + // join across the scrollback↔grid boundary. (Same for boundaries with + // pre-existing scrollback rows; both are minor v1 gaps.) + if (rowInputs.length > 0) { + rowInputs[rowInputs.length - 1].continuesNext = false; + } + + const ranges = this.linkify.enabled + ? findUrlsAcrossRows(rowInputs, this.cols, this.linkify.pattern) + : rowInputs.map(() => []); + + for (let k = 0; k < rowInputs.length; k++) { + const sbOffset = newCount - 1 - k; + const rowEl = document.createElement("div"); + rowEl.className = "term-row term-scrollback-row"; + this._buildRowContent( + rowEl, + (col) => bridge.getScrollbackCell(sbOffset, col), + lineLens[k], + -1, + -1, + ranges[k], + ); fragment.appendChild(rowEl); this._scrollbackRowEls.push(rowEl); } @@ -431,12 +475,61 @@ export class Renderer { const needsCursorUpdate = cursor.row !== this.prevCursorRow || cursor.col !== this.prevCursorCol; + // Compute URL ranges across all grid rows in one pass so a URL soft- + // wrapped across rows shares one href. continuesNext uses a heuristic: + // a row that wrote into its last column (cell.char != 0 / not space) is + // treated as continuing into the next row. Skip the pass entirely on + // no-op frames (no resize, no dirty rows, no cursor movement) so cursor- + // blink renders stay free. + let anyRowDirty = resized; + if (!anyRowDirty) { + for (let r = 0; r < this.rows; r++) { + if (bridge.isDirtyRow(r)) { + anyRowDirty = true; + break; + } + } + } + const runUrlPass = this.linkify.enabled && (anyRowDirty || needsCursorUpdate); + + let urlRangesByRow: UrlRange[][] = []; + if (runUrlPass) { + const rowInputs: RowInput[] = []; + for (let r = 0; r < this.rows; r++) { + const rowText = this._buildRowText( + (col) => bridge.getCell(r, col), + this.cols, + ); + const lastCp = + this.cols > 0 ? bridge.getCell(r, this.cols - 1).char : 0; + rowInputs.push({ + rowText, + continuesNext: lastCp !== 0 && lastCp !== 0x20, + }); + } + urlRangesByRow = findUrlsAcrossRows( + rowInputs, + this.cols, + this.linkify.pattern, + ); + } else { + urlRangesByRow = new Array(this.rows).fill(null).map(() => []); + } + for (let r = 0; r < this.rows; r++) { const isDirty = resized || bridge.isDirtyRow(r); const hadCursor = r === this.prevCursorRow && needsCursorUpdate; const hasCursor = r === cursor.row; - if (isDirty || hadCursor || (hasCursor && needsCursorUpdate)) { + const sig = runUrlPass ? serializeUrlRanges(urlRangesByRow[r]) : ""; + const urlChanged = runUrlPass && sig !== (this.prevRowUrlSig[r] ?? ""); + + if ( + isDirty || + hadCursor || + (hasCursor && needsCursorUpdate) || + urlChanged + ) { const cCol = hasCursor && cursorVisible ? cursor.col : -1; this._buildRowContent( this.rowEls[r], @@ -444,7 +537,9 @@ export class Renderer { this.cols, cCol, r, + urlRangesByRow[r], ); + if (runUrlPass) this.prevRowUrlSig[r] = sig; } }