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

+390 -4
View File
@@ -2184,9 +2184,9 @@ function findFilePathLinks(text) {
// `?img=b.png` belong to the URL link, not to a second, overlapping file link.
// (`file:///home/x.png` is not one of them: its path is the file.)
const urls = [];
const urlPattern = /\bhttps?:\/\/\S+/gi;
const webUrlPattern = /\bhttps?:\/\/\S+/gi;
let match;
while ((match = urlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] });
while ((match = webUrlPattern.exec(value)) !== null) urls.push({ index: match.index, path: match[0] });
const inUrl = (index, length) => urls.some((u) => index < u.index + u.path.length && u.index < index + length);
const absolute = absoluteFilePathPattern();
@@ -2344,8 +2344,16 @@ function terminalLogicalLine(buffer, row, cols, maxRows) {
const continuesPrevious = (r) => {
if (r <= 0) return false;
if (lineAt(r)?.isWrapped) return true;
// A divider row never continues the row above, even when that row reaches
// the right edge: a URL ending at the edge must not absorb `────` below it.
if (/^[\s─━═╌╍┄┅┈┉-]+$/u.test(lineAt(r)?.translateToString(true) || '')) return false;
const prev = lineAt(r - 1);
return !!prev && (prev.translateToString(true) || '').length >= width;
const prevText = prev?.translateToString(true) || '';
// A full-width table divider is a row boundary, not a hard-wrapped line.
// Treating it as a continuation glues the divider onto the row below and
// hides that row's wrapped URL from the link provider.
if (/^[\s─━═╌╍┄┅┈┉-]+$/u.test(prevText)) return false;
return prevText.length >= width;
};
let startRow = row;
@@ -2519,6 +2527,377 @@ function terminalPathLine(buffer, row, cols, maxRows) {
return { startRow: parts[0].startRow, endRow: parts[parts.length - 1].endRow, text, offsetToCell, cellToOffset };
}
/**
* Web URLs in terminal text, with the trimming the link provider applies.
*
* A single `&` is part of the URL (query separator); `&&` is the shell operator
* and ends it. `)` is kept INSIDE a URL when it closes a `(` that is also inside
* it (`…/Team-Space(QA)Archive/…` used to link only as far as `(QA`), and an
* unmatched `)` ends the URL, which keeps the closing paren of `(https://…)` and
* `[text](https://…)` out of the link. Trailing sentence punctuation is trimmed.
*
* @param {string} text
* @returns {Array<{url: string, startIndex: number, endIndex: number, rawEnd: number}>}
* `endIndex` is exclusive; `rawEnd` is where the match ran to before the
* paren/punctuation trim (the wrap-continuation test needs it).
*/
function findTerminalUrls(text) {
// Named `urlPattern` on purpose: test/link-provider-regex.test.ts extracts it
// by name to prove it stays linear-time on hostile lines.
const urlPattern = /https?:\/\/(?:[^\s"'<>|;&\]\x00-\x1f]|&(?!&))+/g;
const urls = [];
let match;
while ((match = urlPattern.exec(String(text || ''))) !== null) {
const raw = match[0];
let end = raw.length;
let depth = 0;
for (let i = 0; i < raw.length; i++) {
if (raw[i] === '(') depth++;
else if (raw[i] === ')') {
if (depth === 0) {
end = i;
break;
}
depth--;
}
}
const url = raw.slice(0, end).replace(/[.,;:!?&]+$/, '');
if (!/:\/\/[^/]/.test(url)) continue;
urls.push({ url, startIndex: match.index, endIndex: match.index + url.length, rawEnd: match.index + raw.length });
}
return urls;
}
/**
* The row span a URL covers when the CLI wrapped it with its OWN newlines at a
* point short of the terminal width.
*
* {@link terminalLogicalLine} joins soft wraps and full-width hard wraps. A CLI
* that word-wraps inside a table cell or a paragraph (codex's markdown tables,
* for one) breaks a long URL at `/`, `?`, `-` … well before the right edge and
* indents the rest to the cell's column, so neither rule fires and each row
* linked a fragment. A row continues the URL above it only when ALL hold:
* - the URL fragment ends on a character a wrapper breaks after
* (`/ ? & = - _ % # + ~`); a following table column may be excluded;
* - the next row is indented, but no further right than the column the URL
* started in (a table cell or list item continuation, not unrelated text),
* and no column gap lies between its indent and the URL on the first row
* (that would be the next record of a table);
* - that row's first token looks like URL material (contains one of
* `% & = / ? . - _`), is not a bare number/version or list marker,
* and is not itself a new `scheme://`, a shell prompt (`user@host:`),
* a home path (`~/`), an email address, an HTTP status line, a label
* ending in `:`, a measurement or clock time (`45%`, `12.3ms`,
* `10:00:00`), or a `name=value` field under a URL ending in `/`;
* without `% & = / ?`, the token must finish its cell (row end or a
* gap of at least two spaces) and must not be code with a call paren
* or a word ending a sentence (`Done.`).
* The last rule is what stops prose such as "…/files/" + "then run it" from
* being glued into one link.
*
* Bounded by `maxRows`, like the logical line. Pure: reads rows only.
*
* @param {{getLine: (row: number) => any, length: number}} buffer
* @param {number} row 0-based absolute row being examined.
* @param {number} cols Terminal width.
* @param {number} [maxRows]
* @returns {{startRow: number, endRow: number, firstRowUrlEnd?: number} | null}
* `firstRowUrlEnd` is where the URL's cell ends on the first row when
* other table columns follow it and that row does not soft-wrap; null
* means the row is not part of a URL continued this way.
*/
function terminalUrlContinuationExtent(buffer, row, cols, maxRows) {
const bound = Math.max(1, maxRows || 12);
const here = terminalLogicalLine(buffer, row, cols, bound);
if (!here) return null;
const chainFrom = (start) => {
const first = terminalLogicalLine(buffer, start, cols, bound);
if (!first || first.startRow !== start) return null;
let text = first.text.replace(/\s+$/, '');
let endRow = first.endRow;
let urlStartCol = -1;
let firstRowUrlEnd = null;
let extended = false;
while (endRow - start + 1 < bound) {
const urls = findTerminalUrls(text);
const last = urls[urls.length - 1];
// Only on a one-row first line: firstRowUrlEnd cuts the first PHYSICAL
// row (terminalLinkLine), so a column sitting on the soft-wrapped part of
// the URL's row would stay in the text and take the next row with it.
const trailingColumns =
first.startRow === first.endRow &&
endRow === first.endRow &&
last &&
last.rawEnd < text.length &&
/^ {2,}\S/.test(text.slice(last.rawEnd));
const urlText = trailingColumns ? text.slice(0, last.rawEnd) : text;
if (!last || last.rawEnd !== urlText.length || !/[/?&=\-_%#+~]$/.test(urlText)) break;
if (urlStartCol < 0) urlStartCol = first.offsetToCell(last.startIndex).col;
const next = buffer.getLine(endRow + 1);
if (!next || next.isWrapped) break;
const nextText = next.translateToString(true) || '';
const indent = nextText.length - nextText.replace(/^\s+/, '').length;
const token = nextText.slice(indent).split(/\s/, 1)[0];
// A hard continuation is indented (a table cell, a list item, Claude's
// two-space paragraph indent); a column-0 row is a new line of output.
if (!token || !indent || indent > urlStartCol || /^(?:\d+[.)]|[-*•])(?:\s|$)/.test(nextText.slice(indent)))
break;
// When other table columns follow the URL, the next row must align with
// the start of its cell. A new numbered row must not become URL text.
if (
trailingColumns &&
(!indent ||
first.text[indent - 1] !== ' ' ||
first.text[indent] === ' ' ||
/ {2,}/.test(first.text.slice(indent, last.startIndex)))
)
break;
// The next row must start inside the URL's own cell: a column gap between
// its indent and the URL on the first row means it opens an earlier
// column (the next record of a table), not the rest of the URL.
if (/ {2,}/.test(first.text.slice(indent, last.startIndex).trimStart())) break;
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token) || !/^[A-Za-z0-9%_~]/.test(token)) break;
// A shell prompt (`user@host:~/dir$`), a home path and an HTTP status line
// are never the rest of a URL, however much URL punctuation they carry.
if (/^[\w.-]+@[\w.-]+:|^~\/|^HTTP\/\d/i.test(token)) break;
// Nor is a token that is a whole email address.
if (/^[\w.+-]+@[\w-]+(?:\.[\w-]+)+$/.test(token)) break;
if (!/[%&=/?.\-_]/.test(token) || /^v?\d+(?:\.\d+)*(?:[-+][\w.-]+)?[.,;:!?)]?$/i.test(token)) break;
// A label ends with a colon (`Failure/Error:`, `prometheus.io/scrape:`);
// the rest of a URL does not.
if (token.endsWith(':')) break;
// After a `/`, a `name=value` token is the next assignment or log field
// (`API_KEY=...`, `level=info`), not the URL: a query starts with `?`.
if (urlText.endsWith('/') && /^[A-Za-z_][\w.-]*=/.test(token)) break;
// Nor is a measurement or a clock time: `45%` and `45%|####` progress,
// `12.3MB/45.0MB`, `12.3ms`, `10:00:00` (`%7B` stays percent-encoding).
if (/^\d+(?:\.\d+)?(?:%(?![0-9A-Fa-f]{2})|(?:ms|s|[kKMGT]i?B)(?![\w-]))|^\d{1,2}:\d{2}/.test(token)) break;
// Hyphens, underscores and dots also occur in prose. Without stronger
// URL punctuation, the token must finish its cell, not introduce a sentence.
const rest = nextText.slice(indent + token.length);
if (!/[%&=/?]/.test(token)) {
// Code (`client.get()`, `expect(x).toBe(y)`) is not a URL piece, and a
// word that ends a sentence (`ready.`, `Done.`) is prose. A wiki title
// (`Mercury_(planet)`) and a file name before its period
// (`install.html.`) still continue the URL.
if (/[^_]\(/.test(token)) break;
if (/[.,;!]$/.test(token) && !/\.[A-Za-z][A-Za-z0-9]{1,4}[.,;!]+$/.test(token)) break;
if (rest.trim() !== '' && !/^ {2,}/.test(rest)) break;
}
const nextLogical = terminalLogicalLine(buffer, endRow + 1, cols, bound);
if (!nextLogical) break;
if (trailingColumns) firstRowUrlEnd = last.rawEnd;
text = urlText + nextLogical.text.slice(indent).replace(/\s+$/, '');
endRow = nextLogical.endRow;
extended = true;
}
return { startRow: start, endRow, extended, firstRowUrlEnd };
};
// Walk up through earlier logical lines: the nearest one whose URL chain
// reaches this row owns it.
let start = here.startRow;
while (start >= 0 && here.startRow - start < bound) {
const chain = chainFrom(start);
if (chain && chain.extended && chain.endRow >= here.startRow) {
return {
startRow: chain.startRow,
endRow: chain.endRow,
...(chain.firstRowUrlEnd === null ? {} : { firstRowUrlEnd: chain.firstRowUrlEnd }),
};
}
if (start === 0) break;
const above = terminalLogicalLine(buffer, start - 1, cols, bound);
if (!above) break;
start = above.startRow;
}
return null;
}
/**
* The text the link provider scans for `row`: {@link terminalPathLine}, widened
* to the rows of a URL the CLI wrapped short of the edge
* ({@link terminalUrlContinuationExtent}). Same shape as terminalPathLine.
*
* Over a widened span every row is read trimmed (except one a soft wrap
* continues from, which fills the width), a HARD continuation's indent is
* dropped (the cell mapping keeps the cells), and the first row stops at the
* URL's cell when other table columns follow it, so those columns never sit
* between the URL's pieces.
*
* @returns Same shape as {@link terminalPathLine}; null when the row does not exist.
*/
function terminalLinkLine(buffer, row, cols, maxRows) {
const base = terminalPathLine(buffer, row, cols, maxRows);
if (!base) return null;
const extent = terminalUrlContinuationExtent(buffer, row, cols, maxRows);
if (!extent || (extent.startRow >= base.startRow && extent.endRow <= base.endRow)) return base;
const startRow = Math.min(extent.startRow, base.startRow);
const endRow = Math.max(extent.endRow, base.endRow);
const segments = [];
let text = '';
for (let r = startRow; r <= endRow; r++) {
const line = buffer.getLine(r);
if (!line) break;
const next = r < endRow ? buffer.getLine(r + 1) : null;
let rowText = line.translateToString(!(next && next.isWrapped)) || '';
if (r === extent.startRow && extent.firstRowUrlEnd != null) rowText = rowText.slice(0, extent.firstRowUrlEnd);
let colStart = 0;
if (r > startRow && !line.isWrapped) {
colStart = rowText.length - rowText.replace(/^\s+/, '').length;
rowText = rowText.slice(colStart);
}
segments.push({ row: r, textStart: text.length, colStart });
text += rowText;
}
const offsetToCell = (offset) => {
for (let i = segments.length - 1; i >= 0; i--) {
const seg = segments[i];
if (offset >= seg.textStart || i === 0) return { row: seg.row, col: seg.colStart + (offset - seg.textStart) };
}
return { row: startRow, col: offset };
};
const cellToOffset = (targetRow, targetCol) => {
const seg = segments.find((s) => s.row === targetRow);
return seg ? seg.textStart + Math.max(0, targetCol - seg.colStart) : -1;
};
return { startRow, endRow: segments[segments.length - 1].row, text, offsetToCell, cellToOffset };
}
/**
* Absolute file paths the CLI word-wrapped INSIDE a table cell (codex markdown
* tables), returned as one link per path with a segment per row.
*
* Before screenshot (/tmp/x/docs/review/ After screenshot (/tmp/x/review/BLDMESH-
* BLDMESH-243/before-overview.png) 243/after-overview.png)
*
* Neither fragment matches FILE_PATH_LINK_PATTERN alone (the first has no
* extension, the second no absolute root), and a linear stitch of the rows
* cannot serve a two-column table: each column's continuation sits in the same
* next row. So each fragment is followed DOWN ITS OWN CELL: the cell's left
* edge is where its text starts after the previous ≥2-space column gap, and the
* continuation is the token starting at exactly that column on the next row.
* A fragment qualifies only when it is the last thing in its cell on that row
* (row end or a ≥2-space gap follows) and ends on `/` or `-`, the only places
* a wrapper breaks a path; a cell that ends inside a name (`/home/u/proj` over
* `config.yaml`) is a whole value, never the first piece of the next row's. A
* continuation row must be blank to the left of the cell (a wrapper wraps every
* column of one table row together), unless a path in a left column continued
* on it too: a label there (`Entry`, `│ Log │`) is the next record. Every
* continuation must pass pathContinuation and its cell must be no wider than the
* preceding cell's text, so independent listing rows and longer prose never
* displace links.
*
* Pure: reads rows only. Cells are taken one per string character (xterm rows
* with wide glyphs would shift columns; such rows simply fail the alignment).
*
* @param {{getLine: (row: number) => any, length: number}} buffer
* @param {number} row 0-based absolute row being examined.
* @param {number} [maxRows] Bound on rows one path may span.
* @returns {Array<{path: string, segments: Array<{row: number, startCol: number, endCol: number}>}>}
* Paths with a segment on `row`; `endCol` is exclusive, columns 0-based.
*/
function terminalCellWrappedPaths(buffer, row, maxRows) {
const bound = Math.max(2, maxRows || 4);
const rootPattern = new RegExp(`\\/(?:${FILE_LINK_ROOT_GROUP})\\/`, 'g');
// Box-drawing verticals are cell borders (Claude Code draws tables with them).
const isPathChar = (ch) => !!ch && !/[\s"'<>|;&\x00-\x1f│┃║]/.test(ch);
const rowText = (r) => {
const line = buffer.getLine(r);
return line ? (line.translateToString(true) || '').replace(/\s+$/, '') : null;
};
// A fragment is the last text in its cell on this row when the row ends, a
// column gap (≥2 spaces) follows it, or a cell border does.
const endsCell = (text, end) => end >= text.length || /^(?: {2,}| *[│┃║|])/.test(text.slice(end));
const fromStart = (startRow) => {
const found = [];
const text = rowText(startRow);
if (!text) return found;
rootPattern.lastIndex = 0;
let m;
while ((m = rootPattern.exec(text)) !== null) {
const start = m.index;
if (start > 0 && !/[\s(\[{"'`<]/.test(text[start - 1])) continue;
let end = start;
while (end < text.length && isPathChar(text[end])) end++;
rootPattern.lastIndex = Math.max(end, start + 1);
if (!endsCell(text, end)) continue;
// Already a complete path on its own row: the ordinary detector owns it.
const alone = absoluteFilePathPattern().exec(text.slice(start, end));
if (alone && alone.index === 0 && alone[0].length === end - start) continue;
// The cell's left edge: just past the nearest ≥2-space gap or cell border
// (and the padding after it) before the path.
const before = text.slice(0, start);
let cellCol = before.length - before.replace(/^\s+/, '').length;
const edge = /(?: {2,}|[│┃║|] *)(?=\S)/g;
let e;
const upToPath = text.slice(0, start + 1);
while ((e = edge.exec(upToPath)) !== null) cellCol = e.index + e[0].length;
let joined = text.slice(start, end);
let previousCellLength = end - cellCol;
const segments = [{ row: startRow, startCol: start, endCol: end }];
for (let r = startRow + 1; r < startRow + bound; r++) {
const line = buffer.getLine(r);
if (!line || line.isWrapped) break;
const next = rowText(r);
if (!next || !isPathChar(next[cellCol]) || (cellCol > 0 && next[cellCol - 1] !== ' ')) break;
let tokenEnd = cellCol;
while (tokenEnd < next.length && isPathChar(next[tokenEnd])) tokenEnd++;
const token = next.slice(cellCol, tokenEnd);
// Include prose after the token in this cell, but not another column.
const cellText = next.slice(cellCol).split(/ {2,}|[│┃║|]/, 1)[0].trimEnd();
// A wrapper breaks a path after `/` or `-`; a fragment that ends inside a
// name (`/home/u/proj` over `config.yaml`) is a whole value, not a piece.
// codex wraps table cells on Unicode line-break opportunities plus a
// hyphen splitter, which break after `/` and `-` but never at `_` (UAX #14
// class AL).
if (!/[/-]$/.test(joined)) break;
// A wrapper wraps every column of one table row together, so a row that
// carries a cell's continuation is blank to its left unless a path in a
// left column continued on that row too. A label there (`Entry`,
// `│ Log │`) opens the next record, even under a directory ending in `/`
// (`Workspace /home/u/proj/app/` over `Entry main.py`). A left column of
// wrapped prose (a `Description | Path` table) fills that row as well, so
// the path beside it is not joined and keeps its per-row fragment links:
// the safe direction.
// Known trade-off: with nothing to the left, a directory written with its
// trailing slash over a relative path in the same column (`/home/u/proj/`
// over `index.ts`) still joins, since it reads exactly like a wrap at `/`.
if (
/[^\s│┃║|]/.test(next.slice(0, cellCol)) &&
!found.some((hit) => hit.segments.some((seg) => seg.row === r && seg.endCol <= cellCol))
) {
break;
}
if (cellText.length > previousCellLength || !pathContinuation(joined, token)) break;
const candidate = joined + token;
const match = absoluteFilePathPattern().exec(candidate);
if (match && match.index === 0 && match[0].length > joined.length) {
segments.push({ row: r, startCol: cellCol, endCol: cellCol + match[0].length - joined.length });
found.push({ path: match[0], segments });
break;
}
if (!endsCell(next, tokenEnd)) break;
joined = candidate;
previousCellLength = cellText.length;
segments.push({ row: r, startCol: cellCol, endCol: tokenEnd });
}
}
return found;
};
const results = [];
for (let s = row; s >= 0 && row - s < bound; s--) {
for (const hit of fromStart(s)) {
if (hit.segments.some((seg) => seg.row === row)) results.push(hit);
}
}
return results;
}
// ═══════════════════════════════════════════════════════════════
// Split-Pane Sessions — pure helpers (divider math, picker list)
// ═══════════════════════════════════════════════════════════════
@@ -3440,7 +3819,14 @@ if (typeof window !== 'undefined') {
rendersAsPage,
HTML_PAGE_EXTENSIONS,
};
window.CodemanTerminalLines = { terminalLogicalLine, terminalPathLine };
window.CodemanTerminalLines = {
terminalLogicalLine,
terminalPathLine,
terminalLinkLine,
terminalUrlContinuationExtent,
findTerminalUrls,
terminalCellWrappedPaths,
};
window.CodemanUrlSession = { sessionIdFromFragment };
window.CodemanSplitPane = {
clampDividerPercent,
+2
View File
@@ -244,6 +244,8 @@
scrollback: this.scrollback ?? DEFAULT_SCROLLBACK,
allowTransparency: true,
allowProposedApi: true,
// OSC 8 hyperlinks open through the primary pane's opener (terminal-ui.js).
linkHandler: global.app?._terminalOsc8LinkHandler?.(),
});
this.fitAddon = new FitAddon.FitAddon();
+163 -78
View File
@@ -767,6 +767,7 @@ Object.assign(CodemanApp.prototype, {
scrollback: scrollback,
allowTransparency: true,
allowProposedApi: true,
linkHandler: this._terminalOsc8LinkHandler(),
});
this.fitAddon = new FitAddon.FitAddon();
@@ -2208,8 +2209,11 @@ Object.assign(CodemanApp.prototype, {
// links as the one file it is.
// Bounded so a screenful of full-width output (wide tables, box drawing)
// cannot make every hover stitch and re-scan the entire viewport.
// `terminalLinkLine` widens that once more to the rows of a URL the CLI
// wrapped short of the edge (a table cell or an indented paragraph),
// where nothing marks the break and no row is full width.
const MAX_STITCHED_ROWS = 12;
const logical = window.CodemanTerminalLines?.terminalPathLine(
const logical = window.CodemanTerminalLines?.terminalLinkLine(
buffer,
bufferLineNumber - 1,
terminal.cols,
@@ -2227,8 +2231,16 @@ Object.assign(CodemanApp.prototype, {
return { x: cell.col + 1, y: cell.row + 1 };
};
// Paths a CLI word-wrapped inside a table cell (terminalCellWrappedPaths,
// constants.js). They follow their own cell down the rows, which the
// linear stitch above cannot do for a multi-column table, so they are
// found from the buffer directly and claim their cells over the rest.
const cellWrappedPaths = (
window.CodemanTerminalLines?.terminalCellWrappedPaths?.(buffer, bufferLineNumber - 1, 4) ?? []
).filter((hit) => !/(^|\/)\.\.(\/|$)/.test(hit.path));
// A path needs a `/` or an extension's `.`; a line with neither has no link.
if (!lineText || (!lineText.includes('/') && !lineText.includes('.'))) {
if (!lineText || (!lineText.includes('/') && !lineText.includes('.') && cellWrappedPaths.length === 0)) {
callback(undefined);
return;
}
@@ -2240,44 +2252,46 @@ Object.assign(CodemanApp.prototype, {
const taken = [];
const isTaken = (start, length) => taken.some((t) => start < t.end && t.start < start + length);
// Pattern 0: URLs (https://, http://) — matched first so they take priority
//
// A single `&` is PART of the URL: it separates query parameters, so excluding
// it truncated every real query string (`?post=1479&action=edit` linked only
// through `1479`, landing on the wrong page). `&&` is still a boundary, since
// that is the shell operator and never appears inside a URL. A lone trailing
// `&` is trimmed below with the other trailing punctuation.
const urlPattern = /https?:\/\/(?:[^\s"'<>|;&)\]\x00-\x1f]|&(?!&))+/g;
const addUrlLink = (url, matchIndex) => {
// Strip trailing punctuation that's likely not part of the URL
const cleaned = url.replace(/[.,;:!?)&]+$/, '');
const startCol = lineText.indexOf(cleaned, matchIndex);
if (startCol === -1) return;
const start = coordAt(startCol);
const end = coordAt(startCol + cleaned.length);
// Pattern 0: URLs (https://, http://) — matched first so they take priority.
// The grammar (balanced parens, `&` vs `&&`, trailing punctuation) is
// findTerminalUrls() in constants.js, which the wrap-continuation extent
// above reads URLs with too, so the two cannot disagree.
const addUrlLink = ({ url, startIndex }) => {
const start = coordAt(startIndex);
if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return;
taken.push({ start: startCol, end: startCol + cleaned.length });
taken.push({ start: startIndex, end: startIndex + url.length });
links.push({
text: cleaned,
range: { start, end },
decorations: { pointerCursor: true, underline: true },
activate(_event, text) {
// A `localhost` link tapped from another device can only work
// through the server: route it into a proxied web tab
// (webview-tabs.js). Anything else opens as before.
if (self.openLinkThroughWebTabIfLoopback?.(text)) return;
window.open(text, '_blank', 'noopener,noreferrer');
},
hover() {
setHovered(true);
},
leave() {
setHovered(false);
},
});
// One link per terminal row the URL occupies, each opening the WHOLE
// URL. A single range spanning rows would also underline (and make
// clickable) the indent and table columns between the pieces of a
// URL the CLI wrapped. A single-row URL keeps its original range.
const endIndex = startIndex + url.length;
const pieces = [];
let pieceStart = startIndex;
for (let i = startIndex + 1; i < endIndex; i++) {
if (coordAt(i).y !== coordAt(i - 1).y) {
pieces.push([pieceStart, i]);
pieceStart = i;
}
}
pieces.push([pieceStart, endIndex]);
for (let n = 0; n < pieces.length; n++) {
const [from, to] = pieces[n];
links.push({
text: url,
range: { start: coordAt(from), end: pieces.length === 1 ? coordAt(to) : coordAt(to - 1) },
decorations: { pointerCursor: true, underline: true },
activate() {
self._openTerminalUrl(url);
},
hover() {
setHovered(true);
},
leave() {
setHovered(false);
},
});
}
};
// Pattern 1: Commands with file paths (tail -f, cat, head, grep pattern, etc.)
@@ -2300,6 +2314,41 @@ Object.assign(CodemanApp.prototype, {
// Pattern 3: Bash() tool output
const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g;
const activateFilePath = (text, folder = false) => {
// Tailing a PNG in the log viewer shows binary noise; the file preview
// already renders images, PDFs, documents and media inline — and it
// now reaches files outside the workspace too, which is where an
// agent's screenshots and scratchpad captures actually land.
//
// Text goes to the log viewer, which follows a file that is still
// being written — but ONLY where it can actually read: it spawns
// `tail -f` and allows the workspace, /var/log and ~/logs, so an
// out-of-workspace path there answered "Path must be within
// working directory or allowed log directories" while the SAME
// path clicked in the response viewer previewed fine. The preview
// reads those through the guarded attachment routes, so external
// paths route there and the two surfaces agree.
//
// A relative path is resolved against the session's working
// directory first, and a `~/` path always previews (only the
// server knows that host's home, and the log viewer cannot ask).
const sessionId = getSessionId();
const target = self._resolveLinkedFilePath(text, sessionId);
// A folder path opens the folder grid (which falls back to the
// preview if the server finds a file there after all).
if (folder) {
self.openFolderGrid(target, sessionId);
return;
}
// An HTML file renders as a page in the preview, never as source
// in the log viewer (markdown is in FILE_PREVIEW_EXTENSIONS).
if (previewsInFileViewer(target) || rendersAsPage(target) || self._isExternalPreviewPath(target, sessionId)) {
self.openFilePreview(target, sessionId);
return;
}
self.openLogViewerWindow(target, sessionId);
};
const addLink = (filePath, matchIndex, folder = false) => {
const startCol = lineText.indexOf(filePath, matchIndex);
if (startCol === -1) return;
@@ -2318,43 +2367,8 @@ Object.assign(CodemanApp.prototype, {
pointerCursor: true,
underline: true,
},
activate(event, text) {
// Tailing a PNG in the log viewer shows binary noise; the file preview
// already renders images, PDFs, documents and media inline — and it
// now reaches files outside the workspace too, which is where an
// agent's screenshots and scratchpad captures actually land.
//
// Text goes to the log viewer, which follows a file that is still
// being written — but ONLY where it can actually read: it spawns
// `tail -f` and allows the workspace, /var/log and ~/logs, so an
// out-of-workspace path there answered "Path must be within
// working directory or allowed log directories" while the SAME
// path clicked in the response viewer previewed fine. The preview
// reads those through the guarded attachment routes, so external
// paths route there and the two surfaces agree.
//
// A relative path is resolved against the session's working
// directory first, and a `~/` path always previews (only the
// server knows that host's home, and the log viewer cannot ask).
const sessionId = getSessionId();
const target = self._resolveLinkedFilePath(text, sessionId);
// A folder path opens the folder grid (which falls back to the
// preview if the server finds a file there after all).
if (folder) {
self.openFolderGrid(target, sessionId);
return;
}
// An HTML file renders as a page in the preview, never as source
// in the log viewer (markdown is in FILE_PREVIEW_EXTENSIONS).
if (
previewsInFileViewer(target) ||
rendersAsPage(target) ||
self._isExternalPreviewPath(target, sessionId)
) {
self.openFilePreview(target, sessionId);
return;
}
self.openLogViewerWindow(target, sessionId);
activate(_event, text) {
activateFilePath(text, folder);
},
hover() {
setHovered(true);
@@ -2368,9 +2382,8 @@ Object.assign(CodemanApp.prototype, {
// Match all patterns — URLs first so they take priority
let match;
urlPattern.lastIndex = 0;
while ((match = urlPattern.exec(lineText)) !== null) {
addUrlLink(match[0], match.index);
for (const reference of window.CodemanTerminalLines?.findTerminalUrls?.(lineText) ?? []) {
addUrlLink(reference);
}
cmdPattern.lastIndex = 0;
@@ -2387,6 +2400,54 @@ Object.assign(CodemanApp.prototype, {
addLink(match[1], match.index);
}
// One link per row segment, each opening the WHOLE path (as for a wrapped
// URL). A fragment of the same cells linked by another detector (a
// relative `243/after-overview.png`, say) would open the wrong file, so
// the cell-wrapped path displaces it.
// A path the line stitch already joined whole (a single-column wrap, which
// terminalPathLine handles) keeps its one link.
const cellOnlyPaths = cellWrappedPaths.filter(
(hit) =>
!links.some(
(l) =>
l.text === hit.path &&
l.range.start.y === hit.segments[0].row + 1 &&
l.range.start.x === hit.segments[0].startCol + 1
)
);
if (cellOnlyPaths.length > 0) {
const claimed = cellOnlyPaths.flatMap((hit) => hit.segments);
const overlapsClaim = (link) =>
claimed.some((seg) => {
const y = seg.row + 1;
if (y < link.range.start.y || y > link.range.end.y) return false;
const from = y === link.range.start.y ? link.range.start.x : 1;
const to = y === link.range.end.y ? link.range.end.x : Number.MAX_SAFE_INTEGER;
return from <= seg.endCol && to >= seg.startCol + 1;
});
for (let i = links.length - 1; i >= 0; i--) {
if (overlapsClaim(links[i])) links.splice(i, 1);
}
for (const hit of cellOnlyPaths) {
for (const seg of hit.segments) {
links.push({
text: hit.path,
range: { start: { x: seg.startCol + 1, y: seg.row + 1 }, end: { x: seg.endCol, y: seg.row + 1 } },
decorations: { pointerCursor: true, underline: true },
activate() {
activateFilePath(hit.path);
},
hover() {
setHovered(true);
},
leave() {
setHovered(false);
},
});
}
}
}
if (links.length > 0) {
console.debug(
'[LinkProvider] Found links:',
@@ -2409,6 +2470,30 @@ Object.assign(CodemanApp.prototype, {
return provider;
},
/**
* xterm's handler for OSC 8 hyperlinks (a program's own `ESC ]8;;url` links),
* which take precedence over the text link provider. Routed through the same
* opener after confirming the real target (the label may hide it), and only
* for web URLs: any other scheme a program sends is ignored.
*/
_terminalOsc8LinkHandler() {
return {
activate: (_event, text) => {
if (!/^https?:\/\/[^\s\x00-\x1f]+$/i.test(text)) return;
if (window.confirm(`Open this link?\n\n${text}`)) this._openTerminalUrl(text);
},
};
},
/** Open a URL clicked in a terminal (text link or OSC 8). */
_openTerminalUrl(text) {
// A `localhost` link tapped from another device can only work through the
// server: route it into a proxied web tab (webview-tabs.js). Anything else
// opens in a new tab.
if (this.openLinkThroughWebTabIfLoopback?.(text)) return;
window.open(text, '_blank', 'noopener,noreferrer');
},
/**
* The terminal link under a viewport point, or null.
*