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 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..c2fff7d --- /dev/null +++ b/packages/@wterm/dom/src/__tests__/linkify.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect } from "vitest"; +import { + DEFAULT_URL_PATTERN, + findUrls, + findUrlsAcrossRows, + trimTrailing, +} 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"); + }); +}); + +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 678ea63..81ceef8 100644 --- a/packages/@wterm/dom/src/__tests__/renderer.test.ts +++ b/packages/@wterm/dom/src/__tests__/renderer.test.ts @@ -122,4 +122,238 @@ 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/"); + }); + + 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); + }); + + 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[] = []; + 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/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..68b44fd --- /dev/null +++ b/packages/@wterm/dom/src/linkify.ts @@ -0,0 +1,150 @@ +// 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; +} + +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 5f6ce51..242f7e6 100644 --- a/packages/@wterm/dom/src/renderer.ts +++ b/packages/@wterm/dom/src/renderer.ts @@ -1,4 +1,11 @@ -import type { WasmBridge } from "@wterm/core"; +import type { CellData, WasmBridge } from "@wterm/core"; +import { + DEFAULT_URL_PATTERN, + findUrlsAcrossRows, + type NormalizedLinkify, + type RowInput, + type UrlRange, +} from "./linkify.js"; const DEFAULT_COLOR = 256; const FLAG_BOLD = 0x01; @@ -60,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, @@ -165,12 +179,23 @@ 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; - constructor(container: HTMLElement) { + private linkify: NormalizedLinkify; + + constructor(container: HTMLElement, options: { linkify?: NormalizedLinkify } = {}) { this.container = container; + this.linkify = options.linkify ?? { + enabled: false, + pattern: DEFAULT_URL_PATTERN, + onClick: null, + }; } setup(cols: number, rows: number): void { @@ -179,6 +204,7 @@ export class Renderer { this.container.innerHTML = ""; this.rowEls = []; this.prevRowBg = []; + this.prevRowUrlSig = []; this._scrollbackRowEls = []; this._renderedScrollbackCount = 0; @@ -194,44 +220,104 @@ export class Renderer { this.prevCursorCol = -1; } + // 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, + ): string { + 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 || cp > 0xffff) { + rowText += " "; + } else { + rowText += String.fromCodePoint(cp); + } + } + return rowText; + } + 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, + urlRanges: UrlRange[], ): void { rowEl.textContent = ""; + 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"; + const onClick = this.linkify.onClick; + if (onClick) { + a.addEventListener("click", (ev) => { + onClick(urlRanges[urlIdx].url, ev); + }); + } + 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); } }; @@ -241,30 +327,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; } @@ -273,8 +365,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); @@ -298,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(); @@ -326,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); } @@ -362,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], @@ -375,7 +537,9 @@ export class Renderer { this.cols, cCol, r, + urlRangesByRow[r], ); + if (runUrlPass) this.prevRowUrlSig[r] = sig; } } 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; +} 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(