Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 `<a>` 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

<!-- release:start -->
Expand Down
6 changes: 6 additions & 0 deletions apps/docs/src/app/api-reference/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ Both the React `<Terminal>` component and the vanilla `WTerm` constructor accept
<td><code>false</code></td>
<td>Enable debug mode. Exposes a <code>DebugAdapter</code> on the <code>WTerm</code> instance (<code>wt.debug</code>) for inspecting escape sequences, cell data, render performance, and unhandled CSI sequences.</td>
</tr>
<tr>
<td><code>linkify</code></td>
<td><code>boolean | &#123; pattern?: RegExp; onClick?: (url, event) =&gt; void &#125;</code></td>
<td><code>false</code></td>
<td>Render URLs in output as clickable <code>&lt;a&gt;</code> anchors (<code>target="_blank"</code>, <code>rel="noopener noreferrer"</code>). 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.</td>
</tr>
<tr>
<td><code>onData</code></td>
<td><code>(data: string) =&gt; void</code></td>
Expand Down
24 changes: 24 additions & 0 deletions apps/docs/src/app/vanilla/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<a>` 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.
32 changes: 32 additions & 0 deletions packages/@wterm/dom/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<a>` 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 |
Expand Down Expand Up @@ -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 `<a>` 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");
// → <a class="term-link" href="https://example.com/" target="_blank" rel="noopener noreferrer">
```

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
197 changes: 197 additions & 0 deletions packages/@wterm/dom/src/__tests__/linkify.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Loading