Merge pull request #686: feat(terminal): link URLs and paths that wrap across rows or sit in table cells

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-27 02:11:29 +01:00
commit 3d0abbf21c
10 files changed
+1591 -89

No files matched your search

+5 -6
View File
@@ -17,6 +17,7 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
import vm from 'node:vm';
const publicFile = (name: string) => readFileSync(join(__dirname, '..', 'src', 'web', 'public', name), 'utf-8');
@@ -99,8 +100,9 @@ describe('terminal link-provider regexes (shipped source)', () => {
// Excluding `&` truncated every real query string: a WordPress edit link
// resolved to `?post=1479` and opened the wrong page, and Claude Code's OAuth
// login URL (many `&` params) was not clickable at all.
const url = shippedPattern('urlPattern');
const strip = (u: string) => u.replace(/[.,;:!?)&]+$/, '');
const context = vm.createContext({ console, window: undefined });
vm.runInContext(`${CONSTANTS_SOURCE}\nglobalThis.findUrls = findTerminalUrls;`, context);
const findUrls = context.findUrls as (text: string) => Array<{ url: string }>;
const cases: Array<[string, string]> = [
[
'updated in place: https://bio-hacking.blog/wp-admin/post.php?post=1479&action=edit',
@@ -115,10 +117,7 @@ describe('terminal link-provider regexes (shipped source)', () => {
['trailing https://x.com/a?b=1& next', 'https://x.com/a?b=1'],
];
for (const [line, want] of cases) {
url.lastIndex = 0;
const m = url.exec(line);
expect(m, line).not.toBeNull();
expect(strip(m![0]), line).toBe(want);
expect(findUrls(line).map((match) => match.url), line).toEqual([want]);
}
});
+319
View File
@@ -0,0 +1,319 @@
// Port: none (pure functions + the link provider in a vm context — no browser, no server).
//
// Absolute file paths codex word-wrapped inside a markdown table cell were not
// clickable: the first fragment has no extension and the continuation has no
// absolute root, so FILE_PATH_LINK_PATTERN matched neither. In a two-column
// table both continuations sit on the same next row, so each path has to be
// followed down its OWN cell (terminalCellWrappedPaths in constants.js).
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const CONSTANTS = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
const TERMINAL_UI = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
type Row = { text: string; isWrapped?: boolean };
type Hit = { path: string; segments: Array<{ row: number; startCol: number; endCol: number }> };
type Link = {
text: string;
range: { start: { x: number; y: number }; end: { x: number; y: number } };
activate: (event: unknown, text: string) => void;
};
const COLS = 200;
function fakeBuffer(rows: Row[], cols: number) {
const lines = rows.map((row) => ({
isWrapped: row.isWrapped ?? false,
translateToString(trimRight?: boolean) {
const padded = row.text.padEnd(cols, ' ');
return trimRight ? padded.trimEnd() : padded;
},
getCell(index: number) {
const padded = row.text.padEnd(cols, ' ');
if (index >= padded.length) return undefined;
return { getChars: () => (padded[index] === ' ' ? '' : padded[index]), getWidth: () => 1 };
},
}));
return { length: lines.length, getLine: (i: number) => lines[i] };
}
function loadPure() {
const context = vm.createContext({ console, window: undefined });
vm.runInContext(`${CONSTANTS}\nglobalThis.__cell = terminalCellWrappedPaths;`, context, {
filename: 'constants.js',
});
return (context as unknown as { __cell: (buffer: unknown, row: number, maxRows?: number) => Hit[] }).__cell;
}
// Shape copied from a real codex pane (synthetic paths).
const ROW1 =
' Before screenshot (/tmp/demo-check/docs/review/ After screenshot (/tmp/demo-check/docs/review/BLDMESH-';
const ROW2 = ' BLDMESH-243/before-overview.png) 243/after-overview.png)';
const TABLE: Row[] = [
{ text: ' No Vision selected Reprocessed' },
{ text: ROW1 },
{ text: ROW2 },
{ text: '' },
];
const LISTINGS = [
['/tmp/x', '/tmp/x/dir', '/tmp/x/dir/a.png'],
[' ⎿ /home/u/proj/src', ' /home/u/proj/src/a.ts'],
['4.0K /tmp/foo/bar/', '8.0K /tmp/foo/baz.png'],
['/home/u/proj/docs:', 'guide.md notes.md'],
['Created /tmp/out', 'report.md written next to it'],
];
// A directory that is the whole value of its cell, over the next record's file
// name in the same column. A wrapper breaks a path after `/` or `-`, never inside
// a name, so none of these is one path: each cell keeps its own link.
const WHOLE_VALUE_SHAPES: Array<{ rows: string[]; dir: string; file: string }> = [
{ rows: [' Repo /home/u/proj', ' Config config.yaml'], dir: '/home/u/proj', file: 'config.yaml' },
{
rows: [' Workspace /home/u/proj/app', ' Entry main.py'],
dir: '/home/u/proj/app',
file: 'main.py',
},
{
rows: ['│ Workspace │ /home/u/proj/app │', '│ Entry │ main.py │'],
dir: '/home/u/proj/app',
file: 'main.py',
},
{ rows: [' Output dir /tmp/build/out', ' Log build.log'], dir: '/tmp/build/out', file: 'build.log' },
{
rows: [' Path Purpose', ' /home/u/proj/src sources', ' index.ts entry'],
dir: '/home/u/proj/src',
file: 'index.ts',
},
];
// The same records where the directory DOES end on a break character: the label
// on the next row (`Entry`, `│ Helper │`) says it is a new record, since a wrapper
// wraps every column of one table row together and leaves the label cell blank.
const LABELLED_RECORD_SHAPES: Array<{ rows: string[]; dir: string; file: string }> = [
{
rows: [' Workspace /home/u/proj/app/', ' Entry main.py'],
dir: '/home/u/proj/app/',
file: 'main.py',
},
{
rows: ['│ Source │ /home/u/proj/src/ │', '│ Helper │ lib/util.ts │'],
dir: '/home/u/proj/src/',
file: 'lib/util.ts',
},
{ rows: [' Prefix /tmp/run-', ' Suffix log.txt'], dir: '/tmp/run-', file: 'log.txt' },
];
const SEPARATE_RECORD_SHAPES = [...WHOLE_VALUE_SHAPES, ...LABELLED_RECORD_SHAPES];
// Known trade-off: with no label to tell them apart, a directory written WITH its
// trailing slash over a relative path in the same column reads exactly like a
// wrap at `/`, so it still joins.
const TRAILING_SLASH_TRADE_OFF = [' /home/u/proj/src/ sources', ' lib/util.ts helpers'];
const BEFORE = '/tmp/demo-check/docs/review/BLDMESH-243/before-overview.png';
const AFTER = '/tmp/demo-check/docs/review/BLDMESH-243/after-overview.png';
describe('terminalCellWrappedPaths', () => {
const find = loadPure();
it('joins each column’s path with its own continuation, from either row', () => {
for (const row of [1, 2]) {
const hits = find(fakeBuffer(TABLE, COLS), row);
expect(hits.map((h) => h.path).sort()).toEqual([AFTER, BEFORE]);
}
});
it.each(LISTINGS.map((lines) => [lines]))('does not join independent listing rows: %j', (lines) => {
const rows = lines.map((text) => ({ text }));
for (let row = 0; row < rows.length; row++) expect(find(fakeBuffer(rows, COLS), row)).toEqual([]);
});
it.each(SEPARATE_RECORD_SHAPES.map((shape) => [shape.rows, shape]))(
'does not join a directory cell onto the next record: %j',
(_rows, shape) => {
const rows = shape.rows.map((text) => ({ text }));
for (let row = 0; row < rows.length; row++) expect(find(fakeBuffer(rows, COLS), row)).toEqual([]);
}
);
it('does not join a fragment that ends on `_` (codex wraps cells only after `/` or `-`)', () => {
const rows = [{ text: ' shot (/tmp/demo/docs/review_' }, { text: ' before.png)' }];
expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]);
expect(find(fakeBuffer(rows, COLS), 1)).toEqual([]);
});
it('still joins an unlabelled trailing-slash directory over a relative path in its column (known trade-off)', () => {
const rows = TRAILING_SLASH_TRADE_OFF.map((text) => ({ text }));
expect(find(fakeBuffer(rows, COLS), 1).map((h) => h.path)).toEqual(['/home/u/proj/src/lib/util.ts']);
});
it('places the segments on the path cells only', () => {
const hits = find(fakeBuffer(TABLE, COLS), 1);
const before = hits.find((h) => h.path === BEFORE)!;
expect(before.segments).toEqual([
{ row: 1, startCol: ROW1.indexOf('/tmp/'), endCol: ROW1.indexOf('/review/') + '/review/'.length },
{ row: 2, startCol: 3, endCol: 3 + 'BLDMESH-243/before-overview.png'.length },
]);
const after = hits.find((h) => h.path === AFTER)!;
const afterCell = ROW1.indexOf('After');
expect(after.segments[0]).toEqual({ row: 1, startCol: ROW1.lastIndexOf('/tmp/'), endCol: ROW1.length });
expect(after.segments[1]).toEqual({
row: 2,
startCol: afterCell,
endCol: afterCell + '243/after-overview.png'.length,
});
});
it('ignores a path already complete on its row', () => {
const rows = [{ text: ' see /tmp/demo/report.md' }, { text: ' next.md' }];
expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]);
});
it('does not glue unrelated text onto a path that is not followed by a matching continuation', () => {
const rows = [{ text: ' saved under /tmp/demo/docs/' }, { text: ' then run the build' }];
expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]);
expect(find(fakeBuffer(rows, COLS), 1)).toEqual([]);
});
it('does not join a continuation that is not aligned to the cell', () => {
const rows = [{ text: ' shot (/tmp/demo/docs/review/' }, { text: ' before.png)' }];
expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]);
});
it('does not join across a soft wrap (the logical line owns that)', () => {
const rows = [{ text: ' shot (/tmp/demo/docs/review/' }, { text: ' before.png', isWrapped: true }];
expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]);
});
it('follows a path across three rows of one cell', () => {
const rows = [{ text: ' a (/tmp/demo/very/' }, { text: ' long/nested/' }, { text: ' file.png)' }];
const hits = find(fakeBuffer(rows, COLS), 1);
expect(hits.map((h) => h.path)).toEqual(['/tmp/demo/very/long/nested/file.png']);
expect(hits[0].segments.map((s) => s.row)).toEqual([0, 1, 2]);
});
it('follows a path down a box-drawn table cell (Claude Code renders tables with │ borders)', () => {
const rows = [
{ text: '│ Before │ /tmp/demo/docs/review/ │ After │ /tmp/demo/docs/review/BLD- │' },
{ text: '│ │ before-overview.png │ │ 243/after-overview.png │' },
];
const hits = find(fakeBuffer(rows, COLS), 1);
expect(hits.map((h) => h.path).sort()).toEqual([
'/tmp/demo/docs/review/BLD-243/after-overview.png',
'/tmp/demo/docs/review/before-overview.png',
]);
const before = hits.find((h) => h.path.endsWith('before-overview.png'))!;
expect(before.segments).toEqual([
{ row: 0, startCol: rows[0].text.indexOf('/tmp/'), endCol: rows[0].text.indexOf('/review/') + 8 },
{ row: 1, startCol: rows[1].text.indexOf('before'), endCol: rows[1].text.indexOf('.png') + 4 },
]);
});
it('does not take a box border or the next cell into a path', () => {
const rows = [{ text: '│ /tmp/demo/docs/ │ x │' }, { text: '│ │ notes.md │ y │' }];
expect(find(fakeBuffer(rows, COLS), 0)).toEqual([]);
});
});
describe('terminal link provider with cell-wrapped paths', () => {
function harness(rows: Row[]) {
const context = vm.createContext({
console: { ...console, debug: vi.fn(), log: vi.fn() },
CodemanApp: class CodemanApp {},
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
document: { addEventListener: vi.fn(), getElementById: vi.fn(), documentElement: { dataset: {} } },
window: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
open: vi.fn(),
document: { documentElement: { dataset: {} } },
},
});
vm.runInContext(`${CONSTANTS}\n${TERMINAL_UI}\nglobalThis.__proto = CodemanApp.prototype;`, context);
const proto = (context as unknown as { __proto: Record<string, (...args: unknown[]) => unknown> }).__proto;
let provider: { provideLinks: (line: number, cb: (links?: Link[]) => void) => void } | undefined;
const app = {
terminal: {
cols: COLS,
rows: 24,
registerLinkProvider(value: typeof provider) {
provider = value;
},
buffer: { active: { viewportY: 0, ...fakeBuffer(rows, COLS) } },
},
activeSessionId: 's1',
sessions: new Map([['s1', { workingDir: '/workspace/project' }]]),
openFilePreview: vi.fn(),
openLogViewerWindow: vi.fn(),
_openTerminalUrl: proto._openTerminalUrl,
_resolveLinkedFilePath: proto._resolveLinkedFilePath,
_isExternalPreviewPath: () => false,
registerFilePathLinkProvider: proto.registerFilePathLinkProvider,
};
app.registerFilePathLinkProvider.call(app);
const provide = (line: number): Link[] => {
let links: Link[] | undefined;
provider!.provideLinks(line, (value) => {
links = value;
});
return links ?? [];
};
return { provide, app };
}
it.each(LISTINGS.map((lines) => [lines]))('preserves ordinary listing links: %j', (lines) => {
const { provide } = harness(lines.map((text) => ({ text })));
const expected = [
['/tmp/x', '/tmp/x/dir', '/tmp/x/dir/a.png'],
['/home/u/proj/src', '/home/u/proj/src/a.ts'],
['/tmp/foo/bar/', '/tmp/foo/baz.png'],
['/home/u/proj/docs', 'guide.md'],
['/tmp/out', 'report.md'],
][LISTINGS.indexOf(lines)];
for (let row = 0; row < lines.length; row++) {
expect(provide(row + 1).map((link) => link.text)).toContain(expected[row]);
}
});
it.each(SEPARATE_RECORD_SHAPES.map((shape) => [shape.rows, shape]))(
'keeps the folder link and the file link of the next record: %j',
(_rows, shape) => {
const { provide, app } = harness(shape.rows.map((text) => ({ text })));
const dirLine = shape.rows.findIndex((text) => text.includes(shape.dir)) + 1;
const fileLine = shape.rows.findIndex((text) => text.includes(shape.file)) + 1;
const all = [...provide(dirLine), ...provide(fileLine)].map((link) => link.text);
expect(all.some((text) => text.includes(shape.dir + shape.file))).toBe(false);
const dirLink = provide(dirLine).find((link) => link.text === shape.dir);
expect(dirLink, `${shape.dir} on row ${dirLine}`).toBeDefined();
expect(dirLink!.range.start.y).toBe(dirLine);
const fileLink = provide(fileLine).find((link) => link.text === shape.file);
expect(fileLink, `${shape.file} on row ${fileLine}`).toBeDefined();
expect(fileLink!.range.start.y).toBe(fileLine);
fileLink!.activate(null, fileLink!.text);
expect(app.openFilePreview).not.toHaveBeenCalledWith(shape.dir + shape.file, 's1');
}
);
it('makes both screenshot paths clickable from either row and opens the whole path', () => {
const { provide, app } = harness(TABLE);
for (const line of [2, 3]) {
const links = provide(line);
for (const target of [BEFORE, AFTER]) {
const onRow = links.find((l) => l.text === target && l.range.start.y === line);
expect(onRow, `${target} on row ${line}`).toBeDefined();
onRow!.activate(null, onRow!.text);
expect(app.openFilePreview).toHaveBeenLastCalledWith(target, 's1');
}
}
});
it('leaves no fragment link that would open a partial path', () => {
const { provide } = harness(TABLE);
for (const line of [2, 3]) {
for (const link of provide(line)) expect([BEFORE, AFTER]).toContain(link.text);
}
});
});
+6
View File
@@ -64,6 +64,12 @@ describe('terminalLogicalLine', () => {
const ROW1 = ' ackage/thing';
const HARD = [{ text: ROW0 }, { text: ROW1 }];
it('keeps full-width dividers out of link and touch-selection text', () => {
const rows = buffer([{ text: '─'.repeat(COLS) }, { text: 'https://example.com/docs' }]);
expect(terminalLogicalLine(rows, 0, COLS)).toMatchObject({ startRow: 0, endRow: 0, text: '─'.repeat(COLS) });
expect(terminalLogicalLine(rows, 1, COLS)).toMatchObject({ startRow: 1, endRow: 1, text: 'https://example.com/docs' });
});
it('stitches a hard wrap and drops the indent the continuation carries', () => {
const line = terminalLogicalLine(buffer(HARD), 0, COLS)!;
+698
View File
@@ -0,0 +1,698 @@
// Port: none (pure functions + the link provider in a vm context — no browser, no server).
//
// Terminal URL links that stopped short.
//
// 1. `)` ended every URL, so a path like `…/Team-Space(QA)Archive/…` linked only as
// far as `(QA`. The grammar now keeps a `)` that closes a `(` inside the URL and
// still leaves out the closing paren of `(https://…)` / `[text](https://…)`.
// 2. A CLI that word-wraps inside a table cell (codex) breaks a long URL at `/`, `?`,
// `-` well short of the right edge and indents the rest to the cell's column.
// Nothing is `isWrapped` and no row is full width, so each row linked a fragment.
//
// All URLs here are synthetic (example.com); none come from real output.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const CONSTANTS = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
const TERMINAL_UI = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');
type UrlRef = { url: string; startIndex: number; endIndex: number; rawEnd: number };
type Extent = { startRow: number; endRow: number } | null;
type Row = { text: string; isWrapped?: boolean };
type Link = {
text: string;
range: { start: { x: number; y: number }; end: { x: number; y: number } };
activate: (event: unknown, text: string) => void;
};
function loadPure() {
const context = vm.createContext({ console, window: undefined });
vm.runInContext(
`${CONSTANTS}\nglobalThis.__urls = findTerminalUrls; globalThis.__extent = terminalUrlContinuationExtent; globalThis.__logical = terminalLogicalLine;`,
context,
{ filename: 'constants.js' }
);
const ctx = context as unknown as {
__urls: (text: string) => UrlRef[];
__extent: (buffer: unknown, row: number, cols: number, maxRows?: number) => Extent;
__logical: (buffer: unknown, row: number, cols: number, maxRows?: number) => Extent;
};
return {
findTerminalUrls: ctx.__urls,
terminalUrlContinuationExtent: ctx.__extent,
terminalLogicalLine: ctx.__logical,
};
}
function fakeBuffer(rows: Row[], cols: number) {
const lines = rows.map((row) => ({
isWrapped: row.isWrapped ?? false,
translateToString(trimRight?: boolean) {
const padded = row.text.padEnd(cols, ' ');
return trimRight ? padded.trimEnd() : padded;
},
getCell(index: number) {
const padded = row.text.padEnd(cols, ' ');
if (index >= padded.length) return undefined;
return { getChars: () => (padded[index] === ' ' ? '' : padded[index]), getWidth: () => 1 };
},
}));
return { length: lines.length, getLine: (i: number) => lines[i] };
}
// A codex-style markdown table row: number, reason, then a `name (url)` cell whose
// URL the CLI wrapped across three rows, each continuation indented to the cell.
const URL =
'https://docs.example.com/sites/Team-Space(QA)Archive/_layouts/15/Doc.aspx?' +
'sourcedoc=%7B11111111-2222-3333-4444-555555555555%7D&file=Quarterly%20Plan%20(Draft).xlsx&action=default&' +
'mobileredirect=true';
const CELL = 26;
const TABLE: Row[] = [
{ text: ' 11 Unrelated row plain text only' },
{
text: ' 12 New to trending Quarterly Plan.xlsx (https://docs.example.com/sites/Team-Space(QA)Archive/_layouts/15/Doc.aspx?',
},
{
text:
' '.repeat(CELL) +
'sourcedoc=%7B11111111-2222-3333-4444-555555555555%7D&file=Quarterly%20Plan%20(Draft).xlsx&action=default&',
},
{ text: ' '.repeat(CELL) + 'mobileredirect=true)' },
{ text: ' 13 Next row another cell' },
];
const COLS = 160;
const TABLE_CELL_START = TABLE[1].text.indexOf('Quarterly');
const TABLE_WITH_TRAILING_COLUMNS: Row[] = [
TABLE[0],
{
text: TABLE[1].text + ' 2026-09-16 18:00:54 Document Owner',
},
{ text: ' '.repeat(TABLE_CELL_START) + TABLE[2].text.slice(CELL) },
{ text: ' '.repeat(TABLE_CELL_START) + TABLE[3].text.slice(CELL) },
TABLE[4],
];
const TABLE_COLS = 220;
const PROSE_ROWS = [
[
'⏺ The guide lives at https://example.com/guide/',
' self-hosted runners need a token first.',
'https://example.com/guide/',
],
['Docs: https://example.com/guide/', 'Self-hosting is covered in chapter 3.', 'https://example.com/guide/'],
['Open http://localhost:3000/', 'e.g. to check the dashboard', 'http://localhost:3000/'],
['Endpoint https://api.example.com/v1/', 'user_id is required', 'https://api.example.com/v1/'],
['Repo: https://github.com/org/repo/', 'well-known issues are tracked', 'https://github.com/org/repo/'],
['Callback https://example.com/cb?code=', 'Re-run the login', 'https://example.com/cb?code='],
['Branch https://github.com/o/r/tree/feat-', 'x.y happened', 'https://github.com/o/r/tree/feat-'],
];
// Rows that carry URL punctuation but are never the rest of the URL above them:
// a shell prompt, an HTTP status line, a home path, and column-0 prose. Each
// third element is the URL, the fourth the path link expected on the second row.
const NOT_CONTINUATION_ROWS: [string, string, string, string | null][] = [
['https://registry.npmjs.org/', 'arkon@box:~/proj$', 'https://registry.npmjs.org/', '~/proj'],
['https://pypi.org/simple/', 'dev@laptop:/srv/app$', 'https://pypi.org/simple/', '/srv/app'],
['$ curl -sI https://example.com/', 'HTTP/2 200', 'https://example.com/', null],
['$ curl -i http://localhost:3000/api/', 'HTTP/1.1 200 OK', 'http://localhost:3000/api/', null],
['Docs: https://example.com/guide/', '~/notes.md has more', 'https://example.com/guide/', '~/notes.md'],
['fetch https://api.example.com/items/', 'status=200 took=12ms', 'https://api.example.com/items/', null],
['Status page: https://status.example.com/', '24/7 monitoring is on.', 'https://status.example.com/', null],
['Did you mean https://example.com/search?q=', 'really? no.', 'https://example.com/search?q=', null],
];
// The last column of a space-aligned table: the next row opens the next record
// in an earlier column, so its first cell is never the rest of the URL. Each
// row is indented two spaces. The URL on each row, then any path link expected
// on the second row.
const TABLE_LAST_COLUMN_ROWS: [string, string, string, string, string | null][] = [
[
' auth-api https://auth.example.com/',
' billing-api https://billing.example.com/',
'https://auth.example.com/',
'https://billing.example.com/',
null,
],
[
' docs.example.com https://docs.example.com/',
' api.example.com https://api.example.com/',
'https://docs.example.com/',
'https://api.example.com/',
null,
],
[
' Homepage: https://example.com/',
' Bug-reports: https://github.com/org/repo/issues/',
'https://example.com/',
'https://github.com/org/repo/issues/',
null,
],
[
' user_service https://users.example.com/v1/',
' order_service https://orders.example.com/v1/',
'https://users.example.com/v1/',
'https://orders.example.com/v1/',
null,
],
[
' README.md https://example.com/docs/',
' CHANGELOG.md https://example.com/changes/',
'https://example.com/docs/',
'https://example.com/changes/',
'CHANGELOG.md',
],
[
' my-service- https://svc.example.com/api/',
' long-name https://svc2.example.com/',
'https://svc.example.com/api/',
'https://svc2.example.com/',
null,
],
];
// Known trade-offs: a row end counts as a cell end, so a short indented word
// with URL punctuation still continues the URL. Pinned so a change is deliberate.
const TRADE_OFF_ROWS: [string, string, string][] = [
['⏺ Docs live at https://example.com/guide/', ' self-hosted', 'https://example.com/guide/self-hosted'],
[
'⏺ The docs are at https://example.com/guide/',
' and/or the API reference covers it.',
'https://example.com/guide/and/or',
],
];
// Indented rows under a URL ending in `/` that carry URL punctuation but are
// code, a label, a `name=value` field, a measurement or a clock time, or a word
// ending a sentence. Each third element is the URL on the first row.
const FIELD_AND_CODE_ROWS: [string, string, string][] = [
[' └ Listening on http://127.0.0.1:8080/', ' ready.', 'http://127.0.0.1:8080/'],
['⏺ Deployed to https://app.example.com/', ' Done.', 'https://app.example.com/'],
['⏺ Docs live at https://example.com/guide/', ' self-hosted.', 'https://example.com/guide/'],
[' Fetching https://example.com/files/', ' done...', 'https://example.com/files/'],
[
' ● fetch › handles https://api.example.com/v1/',
' expect(received).toBe(expected)',
'https://api.example.com/v1/',
],
[' See https://example.com/api/', ' client.get()', 'https://example.com/api/'],
[
'/home/u/.venv/lib/urllib3/__init__.py:35: Warning: see https://github.com/urllib3/urllib3/issues/',
' warnings.warn(',
'https://github.com/urllib3/urllib3/issues/',
],
[
' 1) Client fetches https://api.example.com/v1/',
' Failure/Error: expect(res.code).to eq(200)',
'https://api.example.com/v1/',
],
[
'Annotations: link.argocd.argoproj.io/external-link: https://grafana.example.com/d/',
' prometheus.io/scrape: true',
'https://grafana.example.com/d/',
],
[' BASE_URL=https://api.example.com/', ' API_KEY=your-key-here', 'https://api.example.com/'],
[' AUTH_URL=https://auth.example.com/', ' LOG_LEVEL=debug', 'https://auth.example.com/'],
[
' level=info msg="request" url=https://api.example.com/v1/',
' level=info msg="done" status=200',
'https://api.example.com/v1/',
],
[' url=https://api.example.com/v1/', ' request_id=abc-123', 'https://api.example.com/v1/'],
[
' Callback: https://example.com/oauth/callback/',
' state=abc123 must match',
'https://example.com/oauth/callback/',
],
[
' Downloading https://huggingface.co/org/model/resolve/main/',
' 45%|████▌ | 450M/1.00G [00:10<00:12, 45.0MB/s]',
'https://huggingface.co/org/model/resolve/main/',
],
[' Downloading https://example.com/files/', ' 12.3MB/45.0MB', 'https://example.com/files/'],
[' GET https://example.com/api/', ' 12.3ms', 'https://example.com/api/'],
[' https://example.com/blog/', ' 10:00:00.123', 'https://example.com/blog/'],
];
// Continuations at the edge of those rules that are still the rest of the URL:
// a wiki title with parens, a file name before a sentence period, a query
// parameter after `?`, `&` or `_`, and percent-encoding that starts with digits.
const STILL_CONTINUES_ROWS: [string, string, string][] = [
[' https://en.wikipedia.org/wiki/', ' Mercury_(planet)', 'https://en.wikipedia.org/wiki/Mercury_(planet)'],
['⏺ The guide is at https://example.com/docs/', ' install.html.', 'https://example.com/docs/install.html'],
[' https://example.com/oauth?client_', ' id=abc&scope=read', 'https://example.com/oauth?client_id=abc&scope=read'],
[' https://example.com/cb?code=abc&', ' state=xyz', 'https://example.com/cb?code=abc&state=xyz'],
[' Docs: https://example.com/guide?', ' x=1&y=2 more text', 'https://example.com/guide?x=1&y=2'],
[' https://example.com/files/', ' 2026%20Plan.xlsx', 'https://example.com/files/2026%20Plan.xlsx'],
];
// A URL row that soft-wraps at 41 columns with a table column on its wrapped
// part, over an indented row: the column and that row are separate links, never
// one glued `/tmp/report.txtccc/x.html`.
const SOFT_WRAP_COLS = 41;
const SOFT_WRAPPED_URL_ROWS: Row[] = [
{ text: ' Doc https://example.com/aaaaaaaaaaaaaa' },
{ text: 'bbb/ /tmp/report.txt', isWrapped: true },
{ text: ' ccc/x.html' },
];
describe('findTerminalUrls (grammar)', () => {
const { findTerminalUrls } = loadPure();
const urls = (text: string) => findTerminalUrls(text).map((u) => u.url);
it('keeps a paren pair that is part of the URL', () => {
expect(urls('see https://example.com/wiki/Widget_(tool) now')).toEqual(['https://example.com/wiki/Widget_(tool)']);
expect(urls('https://example.com/Team-Space(QA)Archive/x')).toEqual([
'https://example.com/Team-Space(QA)Archive/x',
]);
});
it('leaves out the paren that wraps the URL', () => {
expect(urls('(https://example.com/a(b)c)')).toEqual(['https://example.com/a(b)c']);
expect(urls('file (https://example.com/doc?x=1).')).toEqual(['https://example.com/doc?x=1']);
});
it('leaves out the closing paren of a markdown link', () => {
expect(urls('[the docs](https://example.com/docs?a=1&b=2) for details')).toEqual([
'https://example.com/docs?a=1&b=2',
]);
});
it('still keeps query ampersands and stops at the shell &&', () => {
expect(urls('open https://example.com/p?post=1&action=edit&& echo done')).toEqual([
'https://example.com/p?post=1&action=edit',
]);
});
it('trims trailing sentence punctuation', () => {
expect(urls('Go to https://example.com/path.')).toEqual(['https://example.com/path']);
expect(urls('Is it https://example.com/q?')).toEqual(['https://example.com/q']);
});
it('reports where the raw match ended before trimming', () => {
const [ref] = findTerminalUrls('x https://example.com/a) y');
expect(ref.url).toBe('https://example.com/a');
expect(ref.rawEnd).toBe('x https://example.com/a)'.length);
});
});
describe('terminalUrlContinuationExtent (wrap joining)', () => {
const { terminalUrlContinuationExtent, terminalLogicalLine } = loadPure();
it('spans all three rows of a URL wrapped inside a table cell, from any of them', () => {
const buffer = fakeBuffer(TABLE, COLS);
for (const row of [1, 2, 3]) {
expect(terminalUrlContinuationExtent(buffer, row, COLS, 12)).toEqual({ startRow: 1, endRow: 3 });
}
});
it('spans a table URL even when date and owner columns follow its first fragment', () => {
const buffer = fakeBuffer(TABLE_WITH_TRAILING_COLUMNS, TABLE_COLS);
for (const row of [1, 2, 3]) {
expect(terminalUrlContinuationExtent(buffer, row, TABLE_COLS, 12)).toMatchObject({ startRow: 1, endRow: 3 });
}
});
it('starts at the URL row when a full-width table divider precedes it', () => {
const rows = [{ text: '─'.repeat(TABLE_COLS) }, ...TABLE_WITH_TRAILING_COLUMNS.slice(1, 4)];
const buffer = fakeBuffer(rows, TABLE_COLS);
for (const row of [1, 2, 3]) {
expect(terminalUrlContinuationExtent(buffer, row, TABLE_COLS, 12)).toMatchObject({ startRow: 1, endRow: 3 });
}
});
it('does not treat the next numbered table row as a URL continuation', () => {
const rows = [TABLE_WITH_TRAILING_COLUMNS[1], { text: ' 13 New to trending Another document' }];
const buffer = fakeBuffer(rows, TABLE_COLS);
expect(terminalUrlContinuationExtent(buffer, 0, TABLE_COLS, 12)).toBeNull();
expect(terminalUrlContinuationExtent(buffer, 1, TABLE_COLS, 12)).toBeNull();
});
it('leaves the neighbouring table rows alone', () => {
const buffer = fakeBuffer(TABLE, COLS);
expect(terminalUrlContinuationExtent(buffer, 0, COLS, 12)).toBeNull();
expect(terminalUrlContinuationExtent(buffer, 4, COLS, 12)).toBeNull();
});
it.each([
['1. https://example.com/one/', '2. https://example.com/two/'],
['Docs: https://docs.example.com/guide/', '1. Install the package'],
['Fetching https://registry.example.com/pkg/', 'v1.2.3 is the latest'],
['GET http://localhost:3000/api/', '200 OK in 12ms'],
])('keeps list, version and status rows separate after %s', (first, next) => {
const buffer = fakeBuffer([{ text: first }, { text: next }], 100);
for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull();
});
it.each(PROSE_ROWS)('does not join prose after %s', (first, next) => {
const buffer = fakeBuffer([{ text: first }, { text: next }], 100);
for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull();
});
it.each(NOT_CONTINUATION_ROWS)('does not join a prompt, status line, home path or prose after %s', (first, next) => {
const buffer = fakeBuffer([{ text: first }, { text: next }], 100);
for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull();
});
// Indented, so only the prompt / home path / status-line rule can refuse them.
it.each(['arkon@box:~/proj$', 'dev@laptop:/srv/app$', '~/notes.md', 'HTTP/2 200', 'HTTP/1.1 200 OK'])(
'does not join an indented %s either',
(next) => {
const buffer = fakeBuffer([{ text: 'Docs: https://example.com/guide/' }, { text: ' ' + next }], 100);
for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull();
}
);
it.each(TABLE_LAST_COLUMN_ROWS)('does not join the next table record after %s', (first, next) => {
const buffer = fakeBuffer([{ text: first }, { text: next }], 100);
for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull();
});
it.each(FIELD_AND_CODE_ROWS)(
'does not join code, a field, a measurement or a sentence end after %s',
(first, next) => {
const buffer = fakeBuffer([{ text: first }, { text: next }], 100);
for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull();
}
);
it.each(STILL_CONTINUES_ROWS)('still joins the continuation after %s', (first, next) => {
const buffer = fakeBuffer([{ text: first }, { text: next }], 100);
for (const row of [0, 1])
expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 });
});
it('does not join an indented email address', () => {
const buffer = fakeBuffer([{ text: ' https://example.com/' }, { text: ' user@example.com' }], 100);
for (const row of [0, 1]) expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toBeNull();
});
it.each(TRADE_OFF_ROWS)('still joins the known trade-off after %s', (first, next) => {
const buffer = fakeBuffer([{ text: first }, { text: next }], 100);
for (const row of [0, 1])
expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 });
});
it('does not let a URL at the right edge absorb a full-width divider row below it', () => {
const cols = 40;
const url = 'https://example.com/' + 'a'.repeat(cols - 'https://example.com/'.length);
for (const divider of ['─'.repeat(cols), '-'.repeat(cols)]) {
const buffer = fakeBuffer([{ text: url }, { text: divider }, { text: 'next' }], cols);
expect(terminalLogicalLine(buffer, 0, cols, 12)).toMatchObject({ startRow: 0, endRow: 0 });
expect(terminalLogicalLine(buffer, 1, cols, 12)).toMatchObject({ startRow: 1 });
}
});
// Real continuations are indented (codex cells, Claude's two-space paragraph
// indent); a column-0 row is refused, so these fixtures are indented.
it.each(['my-post-title', 'user_id', 'x.html', 'my-post-title ', 'my-post-title Other column'])(
'keeps a weak-punctuation continuation that ends its cell: %s',
(next) => {
const buffer = fakeBuffer([{ text: 'Docs: https://example.com/guide/' }, { text: ' ' + next }], 100);
for (const row of [0, 1])
expect(terminalUrlContinuationExtent(buffer, row, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 });
}
);
it.each(['part%20two', 'path/slug', 'query?x=1'])('keeps stronger URL punctuation followed by prose: %s', (token) => {
const buffer = fakeBuffer(
[{ text: 'Docs: https://example.com/guide/' }, { text: ' ' + token + ' more text' }],
100
);
expect(terminalUrlContinuationExtent(buffer, 0, 100, 12)).toMatchObject({ startRow: 0, endRow: 1 });
});
it('does not glue a following prose line onto a URL that ends a line', () => {
const buffer = fakeBuffer(
[{ text: 'Download from https://example.com/files/' }, { text: 'then run the installer' }],
80
);
expect(terminalUrlContinuationExtent(buffer, 0, 80, 12)).toBeNull();
expect(terminalUrlContinuationExtent(buffer, 1, 80, 12)).toBeNull();
});
it('does not join a second URL on the next row', () => {
const buffer = fakeBuffer([{ text: '- https://example.com/one/' }, { text: ' https://example.com/two' }], 80);
expect(terminalUrlContinuationExtent(buffer, 0, 80, 12)).toBeNull();
});
it('does not join a row indented to the right of where the URL started', () => {
const buffer = fakeBuffer([{ text: 'x https://example.com/a/b?' }, { text: ' q=1&r=2' }], 80);
expect(terminalUrlContinuationExtent(buffer, 0, 80, 12)).toBeNull();
});
it('does not join when the URL line ends on a character a wrapper would not break after', () => {
const buffer = fakeBuffer([{ text: 'see https://example.com/page' }, { text: 'v2-notes.txt follows' }], 80);
expect(terminalUrlContinuationExtent(buffer, 0, 80, 12)).toBeNull();
});
it('stays within the row bound', () => {
const rows: Row[] = [{ text: 'u https://example.com/' }];
for (let i = 0; i < 20; i++) rows.push({ text: ` seg${i}-` });
const extent = terminalUrlContinuationExtent(fakeBuffer(rows, 80), 0, 80, 5);
expect(extent).not.toBeNull();
expect(extent!.endRow - extent!.startRow + 1).toBeLessThanOrEqual(5);
});
it('does not continue a URL whose row soft-wraps with a column on the wrapped part', () => {
const buffer = fakeBuffer(SOFT_WRAPPED_URL_ROWS, SOFT_WRAP_COLS);
for (const row of [0, 1, 2]) {
expect(terminalUrlContinuationExtent(buffer, row, SOFT_WRAP_COLS, 12)).toBeNull();
}
});
});
describe('native OSC 8 terminal URLs', () => {
it('opens complete HTTP links through the terminal opener and rejects unsafe schemes', () => {
const open = vi.fn();
const confirm = vi.fn(() => true);
const context = vm.createContext({
console,
CodemanApp: class CodemanApp {},
document: { addEventListener: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn(), confirm },
});
vm.runInContext(`${CONSTANTS}\n${TERMINAL_UI}\nglobalThis.__proto = CodemanApp.prototype;`, context);
const proto = (
context as unknown as {
__proto: { _terminalOsc8LinkHandler: () => { activate: (event: unknown, url: string) => void } };
}
).__proto;
const handler = proto._terminalOsc8LinkHandler.call({ _openTerminalUrl: open });
const url = 'https://docs.example.com/Doc.aspx?file=Q3%20Plan&action=default&mobileredirect=true';
handler.activate(null, url);
expect(open).toHaveBeenCalledExactlyOnceWith(url);
expect(confirm).toHaveBeenCalledExactlyOnceWith(`Open this link?\n\n${url}`);
confirm.mockReturnValue(false);
handler.activate(null, 'http://localhost:3000/private');
expect(confirm).toHaveBeenLastCalledWith('Open this link?\n\nhttp://localhost:3000/private');
expect(open).toHaveBeenCalledTimes(1);
for (const unsafe of ['javascript:alert(1)', 'data:text/html,hello', 'file:///etc/passwd']) {
handler.activate(null, unsafe);
}
expect(open).toHaveBeenCalledTimes(1);
expect(confirm).toHaveBeenCalledTimes(2);
});
});
describe('terminal link provider with wrapped table URLs', () => {
function harness(rows: Row[], cols: number) {
const openSpy = vi.fn();
const context = vm.createContext({
console: { ...console, debug: vi.fn(), log: vi.fn() },
CodemanApp: class CodemanApp {},
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
document: { addEventListener: vi.fn(), getElementById: vi.fn(), documentElement: { dataset: {} } },
window: {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
open: openSpy,
document: { documentElement: { dataset: {} } },
},
});
vm.runInContext(`${CONSTANTS}\n${TERMINAL_UI}\nglobalThis.__proto = CodemanApp.prototype;`, context);
const proto = (context as unknown as { __proto: Record<string, (...args: unknown[]) => unknown> }).__proto;
let provider: { provideLinks: (line: number, cb: (links?: Link[]) => void) => void } | undefined;
const app = {
terminal: {
cols,
rows: 24,
registerLinkProvider(value: typeof provider) {
provider = value;
},
buffer: { active: { viewportY: 0, ...fakeBuffer(rows, cols) } },
},
activeSessionId: 's1',
sessions: new Map([['s1', { workingDir: '/workspace/project' }]]),
openFilePreview: vi.fn(),
openLogViewerWindow: vi.fn(),
_openTerminalUrl: proto._openTerminalUrl,
_resolveLinkedFilePath: proto._resolveLinkedFilePath,
_isExternalPreviewPath: () => false,
registerFilePathLinkProvider: proto.registerFilePathLinkProvider,
};
app.registerFilePathLinkProvider.call(app);
const provide = (line: number): Link[] => {
let links: Link[] | undefined;
provider!.provideLinks(line, (value) => {
links = value;
});
return links ?? [];
};
return { provide, openSpy };
}
it.each(PROSE_ROWS)('preserves the original clickable URL before prose: %s', (first, next, url) => {
const { provide, openSpy } = harness([{ text: first }, { text: next }], 100);
const links = provide(1).filter((link) => /^https?:/.test(link.text));
expect(links.map((link) => link.text)).toEqual([url]);
links[0].activate(null, links[0].text);
expect(openSpy).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer');
expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]);
});
it.each(NOT_CONTINUATION_ROWS)('keeps the URL and the second row separate: %s', (first, next, url, pathOnNext) => {
const { provide, openSpy } = harness([{ text: first }, { text: next }], 100);
const links = provide(1).filter((link) => /^https?:/.test(link.text));
expect(links.map((link) => link.text)).toEqual([url]);
links[0].activate(null, links[0].text);
expect(openSpy).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer');
const second = provide(2);
expect(second.filter((link) => /^https?:/.test(link.text))).toEqual([]);
if (pathOnNext) expect(second.map((link) => link.text)).toContain(pathOnNext);
});
it.each(TABLE_LAST_COLUMN_ROWS)(
'keeps each last-column URL on its own row: %s',
(first, next, url, nextUrl, pathOnNext) => {
const { provide, openSpy } = harness([{ text: first }, { text: next }], 100);
const links = provide(1).filter((link) => /^https?:/.test(link.text));
expect(links.map((link) => link.text)).toEqual([url]);
links[0].activate(null, links[0].text);
expect(openSpy).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer');
const second = provide(2);
expect(second.filter((link) => /^https?:/.test(link.text)).map((link) => link.text)).toEqual([nextUrl]);
if (pathOnNext) expect(second.map((link) => link.text)).toContain(pathOnNext);
}
);
it.each(FIELD_AND_CODE_ROWS)(
'keeps code, a field, a measurement or a sentence end separate: %s',
(first, next, url) => {
const { provide, openSpy } = harness([{ text: first }, { text: next }], 100);
const links = provide(1).filter((link) => /^https?:/.test(link.text));
expect(links.map((link) => link.text)).toEqual([url]);
links[0].activate(null, links[0].text);
expect(openSpy).toHaveBeenCalledExactlyOnceWith(url, '_blank', 'noopener,noreferrer');
expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]);
}
);
it.each(STILL_CONTINUES_ROWS)('links the whole URL from both rows after %s', (first, next, joined) => {
const { provide, openSpy } = harness([{ text: first }, { text: next }], 100);
for (const line of [1, 2]) {
// One link per row the URL covers, each opening the whole URL.
const links = provide(line).filter((link) => /^https?:/.test(link.text));
expect(new Set(links.map((link) => link.text))).toEqual(new Set([joined]));
}
const [link] = provide(2).filter((l) => /^https?:/.test(l.text));
link.activate(null, link.text);
expect(openSpy).toHaveBeenCalledExactlyOnceWith(joined, '_blank', 'noopener,noreferrer');
});
it('keeps an indented email address out of the URL above it', () => {
const { provide } = harness([{ text: ' https://example.com/' }, { text: ' user@example.com' }], 100);
expect(
provide(1)
.filter((link) => /^https?:/.test(link.text))
.map((link) => link.text)
).toEqual(['https://example.com/']);
expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]);
});
it.each(TRADE_OFF_ROWS)('pins the known trade-off link after %s', (first, next, joined) => {
const { provide } = harness([{ text: first }, { text: next }], 100);
for (const line of [1, 2]) {
const links = provide(line).filter((link) => /^https?:/.test(link.text));
expect(links.map((link) => link.text)).toContain(joined);
}
});
it('keeps a full-width divider row out of a URL that reaches the right edge', () => {
const cols = 40;
const url = 'https://example.com/' + 'a'.repeat(cols - 'https://example.com/'.length);
for (const divider of ['─'.repeat(cols), '-'.repeat(cols)]) {
const { provide } = harness([{ text: url }, { text: divider }], cols);
expect(
provide(1)
.filter((link) => /^https?:/.test(link.text))
.map((link) => link.text)
).toEqual([url]);
expect(provide(2).filter((link) => /^https?:/.test(link.text))).toEqual([]);
}
});
it('opens the whole URL from any of its rows', () => {
const { provide, openSpy } = harness(TABLE, COLS);
for (const line of [2, 3, 4]) {
const urlLinks = provide(line).filter((link) => link.text.startsWith('https://'));
expect(urlLinks.length).toBeGreaterThan(0);
for (const link of urlLinks) expect(link.text).toBe(URL);
const onRow = urlLinks.find((link) => link.range.start.y === line);
expect(onRow).toBeDefined();
onRow!.activate(null, onRow!.text);
expect(openSpy).toHaveBeenLastCalledWith(URL, '_blank', 'noopener,noreferrer');
}
});
it('opens the whole table URL without including the adjacent date and owner columns', () => {
const { provide, openSpy } = harness(TABLE_WITH_TRAILING_COLUMNS, TABLE_COLS);
for (const line of [2, 3, 4]) {
const links = provide(line).filter((link) => link.text.startsWith('https://'));
expect(links.length).toBeGreaterThan(0);
for (const link of links) expect(link.text).toBe(URL);
links[0].activate(null, links[0].text);
expect(openSpy).toHaveBeenLastCalledWith(URL, '_blank', 'noopener,noreferrer');
}
const firstRowLink = provide(2).find((link) => link.text === URL && link.range.start.y === 2);
expect(firstRowLink?.range.end.x).toBe(TABLE[1].text.length);
});
it('opens every segment after a full-width table divider', () => {
const rows = [{ text: '─'.repeat(TABLE_COLS) }, ...TABLE_WITH_TRAILING_COLUMNS.slice(1, 4)];
const { provide, openSpy } = harness(rows, TABLE_COLS);
for (const line of [2, 3, 4]) {
const link = provide(line).find((item) => item.text === URL && item.range.start.y === line);
expect(link).toBeDefined();
link!.activate(null, link!.text);
expect(openSpy).toHaveBeenLastCalledWith(URL, '_blank', 'noopener,noreferrer');
}
});
it('underlines only the URL cells on each row, never the indent between pieces', () => {
const { provide } = harness(TABLE, COLS);
const segments = provide(3).filter((link) => link.text === URL);
expect(segments.map((link) => link.range.start.y)).toEqual([2, 3, 4]);
for (const link of segments) expect(link.range.start.y).toBe(link.range.end.y);
expect(segments[0].range.start.x).toBe(TABLE[1].text.indexOf('https://') + 1);
expect(segments[0].range.end.x).toBe(TABLE[1].text.length);
expect(segments[1].range.start.x).toBe(CELL + 1);
expect(segments[2].range.start.x).toBe(CELL + 1);
// Ends on `true`, not on the closing paren after it.
expect(segments[2].range.end.x).toBe(TABLE[3].text.length - 1);
});
it('links the column on a soft-wrapped URL row and the row below it separately', () => {
const { provide } = harness(SOFT_WRAPPED_URL_ROWS, SOFT_WRAP_COLS);
const texts = [1, 2, 3].flatMap((line) => provide(line).map((link) => link.text));
expect(texts.filter((text) => text.includes('report.txtccc'))).toEqual([]);
expect(provide(2).map((link) => link.text)).toContain('/tmp/report.txt');
expect(provide(3).map((link) => link.text)).toContain('ccc/x.html');
});
it('keeps a single-row URL with a paren pair whole', () => {
const line = 'Open https://example.com/wiki/Widget_(tool) please';
const { provide } = harness([{ text: line }], 80);
const [link] = provide(1).filter((l) => l.text.startsWith('https://'));
expect(link.text).toBe('https://example.com/wiki/Widget_(tool)');
});
});
+4 -1
View File
@@ -299,7 +299,10 @@ describe('openLinkThroughWebTabIfLoopback', () => {
describe('callers consult the hook first', () => {
it('terminal URL links try the web tab before window.open', () => {
const activate = TERMINAL_UI.indexOf('activate(_event, text) {');
// URL links (and OSC 8 links) open through _openTerminalUrl(), so the desktop
// click and the mobile touch-tap path both consult the hook. Read that function.
expect(TERMINAL_UI).toContain('self._openTerminalUrl(url);');
const activate = TERMINAL_UI.indexOf('_openTerminalUrl(text) {');
expect(activate).toBeGreaterThan(-1);
const body = TERMINAL_UI.slice(activate, TERMINAL_UI.indexOf('},', activate));
expect(body.indexOf('openLinkThroughWebTabIfLoopback?.(text)')).toBeGreaterThan(-1);