From 9f62da9ac311b593e1b3b135704d419c159b5664 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Thu, 22 Oct 2026 17:22:21 -0400 Subject: [PATCH] feat(terminal): link URLs and paths that wrap across rows or sit in table cells A CLI that word-wraps inside a markdown table cell or an indented paragraph breaks a long URL or path short of the right edge and indents the rest, so nothing is flagged isWrapped and no row is full width. Each row linked only a fragment, and the fragment opened the wrong target. - findTerminalUrls() (constants.js) is the URL grammar for the link provider: a ")" that closes a "(" inside the URL is kept, an unmatched one still ends it, so (https://...) and [text](https://...) stay clean. - terminalUrlContinuationExtent() joins rows the CLI wrapped a URL across, only when the fragment ends on a wrap-break character, the next row is indented no further right than the URL started, and its first token looks like URL material. Table columns after the first fragment are left out. - terminalLinkLine() widens terminalPathLine() to that extent; a URL that spans rows is emitted as one link per row, each opening the whole URL, so the indent and other columns between the pieces are not underlined. - terminalCellWrappedPaths() follows an absolute path down its own table cell (space-separated or box-drawn), so two-column tables link both paths. - A full-width table divider no longer counts as a hard-wrapped line. - OSC 8 hyperlinks open through the same opener, web URLs only. --- src/web/public/constants.js | 307 ++++++++++++++++++++- src/web/public/terminal-tile.js | 2 + src/web/public/terminal-ui.js | 240 ++++++++++------ test/terminal-cell-wrapped-paths.test.ts | 210 ++++++++++++++ test/terminal-url-continuation.test.ts | 337 +++++++++++++++++++++++ test/webview-loopback-links.test.ts | 5 +- 6 files changed, 1018 insertions(+), 83 deletions(-) create mode 100644 test/terminal-cell-wrapped-paths.test.ts create mode 100644 test/terminal-url-continuation.test.ts diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 4cb2f099..6e8d4bff 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2173,9 +2173,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(); @@ -2334,7 +2334,12 @@ function terminalLogicalLine(buffer, row, cols, maxRows) { if (r <= 0) return false; if (lineAt(r)?.isWrapped) return true; 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; @@ -2508,6 +2513,293 @@ 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 no further right than the column the URL + * started in (a table cell or list item continuation, not unrelated text); + * - that row's first token looks like URL material (contains one of + * `% & = / ? . - _` or a digit) and is not itself a new `scheme://`. + * 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; 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]; + const trailingColumns = + 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]; + if (!token || indent > urlStartCol) 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; + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token) || !/^[A-Za-z0-9%_~]/.test(token)) break; + if (!/[%&=/?.\-_0-9]/.test(token)) 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 the join is accepted only when the + * whole joined string matches the path pattern from its first character, which + * is what keeps unrelated cell text from being glued on. + * + * 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); + 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 candidate = joined + next.slice(cellCol, tokenEnd); + 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; + 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) // ═══════════════════════════════════════════════════════════════ @@ -3429,7 +3721,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, diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 1a0c7058..961b56b3 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -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(); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index c3778ffe..e4794be6 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -702,6 +702,7 @@ Object.assign(CodemanApp.prototype, { scrollback: scrollback, allowTransparency: true, allowProposedApi: true, + linkHandler: this._terminalOsc8LinkHandler(), }); this.fitAddon = new FitAddon.FitAddon(); @@ -2143,8 +2144,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, @@ -2162,8 +2166,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; } @@ -2175,44 +2187,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.) @@ -2235,6 +2249,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; @@ -2253,43 +2302,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); @@ -2303,9 +2317,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; @@ -2322,6 +2335,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:', @@ -2344,6 +2405,29 @@ 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, 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; + 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. * diff --git a/test/terminal-cell-wrapped-paths.test.ts b/test/terminal-cell-wrapped-paths.test.ts new file mode 100644 index 00000000..6ed0da6a --- /dev/null +++ b/test/terminal-cell-wrapped-paths.test.ts @@ -0,0 +1,210 @@ +// 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 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('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 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('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); + } + }); +}); diff --git a/test/terminal-url-continuation.test.ts b/test/terminal-url-continuation.test.ts new file mode 100644 index 00000000..9616ac53 --- /dev/null +++ b/test/terminal-url-continuation.test.ts @@ -0,0 +1,337 @@ +// 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;`, + context, + { filename: 'constants.js' } + ); + const ctx = context as unknown as { + __urls: (text: string) => UrlRef[]; + __extent: (buffer: unknown, row: number, cols: number, maxRows?: number) => Extent; + }; + return { findTerminalUrls: ctx.__urls, terminalUrlContinuationExtent: ctx.__extent }; +} + +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; + +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 } = 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('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); + }); +}); + +describe('native OSC 8 terminal URLs', () => { + it('opens complete HTTP links through the terminal opener and rejects unsafe schemes', () => { + const open = vi.fn(); + const context = vm.createContext({ + console, + CodemanApp: class CodemanApp {}, + document: { addEventListener: vi.fn() }, + window: { addEventListener: vi.fn(), removeEventListener: vi.fn() }, + }); + 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); + for (const unsafe of ['javascript:alert(1)', 'data:text/html,hello', 'file:///etc/passwd']) { + handler.activate(null, unsafe); + } + expect(open).toHaveBeenCalledTimes(1); + }); +}); + +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 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('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('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)'); + }); +}); diff --git a/test/webview-loopback-links.test.ts b/test/webview-loopback-links.test.ts index c19d3086..3d053971 100644 --- a/test/webview-loopback-links.test.ts +++ b/test/webview-loopback-links.test.ts @@ -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);