mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
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:
commit
3d0abbf21c
10 files changed
+1591
-89
No files matched your search
+390
-4
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user