fix(terminal): keep shell prompts and status lines out of wrapped URLs

A URL ending a row on `/` took the next row's first word whenever it
carried URL punctuation, so a shell prompt, a home path or an HTTP
status line could be appended to it. Refuse those tokens, and refuse a
column-0 row as a hard continuation (real continuations are indented),
which also keeps `status=200`, `24/7` and `really?` out.

A divider row no longer continues the row above it, so a URL reaching
the right edge does not absorb a full-width rule below it.

Pin the new negatives at detector and provider level, re-indent the
positive continuation fixtures, and pin the `self-hosted.` and `and/or`
trade-offs.
This commit is contained in:
Aamer Akhter committed 2026-10-24 19:54:40 -04:00
1 parent 27c93b20fb
commit 3a4e2c996e
3 files changed
+115 -9

No files matched your search

+101 -5
View File
@@ -30,15 +30,20 @@ type Link = {
function loadPure() {
const context = vm.createContext({ console, window: undefined });
vm.runInContext(
`${CONSTANTS}\nglobalThis.__urls = findTerminalUrls; globalThis.__extent = terminalUrlContinuationExtent;`,
`${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,
};
return { findTerminalUrls: ctx.__urls, terminalUrlContinuationExtent: ctx.__extent };
}
function fakeBuffer(rows: Row[], cols: number) {
@@ -102,6 +107,29 @@ const PROSE_ROWS = [
['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],
];
// 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',
],
];
describe('findTerminalUrls (grammar)', () => {
const { findTerminalUrls } = loadPure();
@@ -144,7 +172,7 @@ describe('findTerminalUrls (grammar)', () => {
});
describe('terminalUrlContinuationExtent (wrap joining)', () => {
const { terminalUrlContinuationExtent } = loadPure();
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);
@@ -196,10 +224,42 @@ describe('terminalUrlContinuationExtent (wrap joining)', () => {
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(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);
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 });
}
@@ -208,7 +268,10 @@ describe('terminalUrlContinuationExtent (wrap joining)', () => {
it.each(['part%20two', 'x=1&y=2', '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);
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 });
}
);
@@ -339,6 +402,39 @@ describe('terminal link provider with wrapped table URLs', () => {
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(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]) {