From 7d46a9d3363a7435ad35a18dcdd52e03e13f2875 Mon Sep 17 00:00:00 2001 From: Randalix Date: Sat, 10 Oct 2026 19:56:38 +0200 Subject: [PATCH] feat(preview): open ~/, relative and line-broken file paths Terminal and response-viewer links only knew rooted absolute paths. Agent output is full of the other forms: Claude Code echoes an attached image as `~/repos/x/garage.png`, scripts print `builds/captures/x.png`, and Claude Code wraps tool output a few columns short of the edge, so a long path arrives as `.../captures/2` + `026-10-10/x.png` and only its prefix linked. - constants.js: RELATIVE_FILE_PATH_LINK_PATTERN + findFilePathLinks() (one entry point for both consumers; never inside an http(s) URL), resolveLinkedFilePath() (relative -> session workingDir, ~/ as is), terminalPathLine() (glues a path a program broke over lines; guarded so ordinary short lines are never joined). - terminal-ui.js: provider uses them, no overlapping links, opens the resolved path. Touch selection keeps terminalLogicalLine. - app.js: response viewer links the same forms; only its own links are resolved, a markdown document's rebased links stay as they were. - Server: registerExternalAttachment accepts ~/ (local homedir(); remote probe expands $HOME on the host). Guards run on the resolved path. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/attachment-registry.ts | 13 +- src/remote-files.ts | 6 + src/web/public/app.js | 29 +-- src/web/public/constants.js | 218 +++++++++++++++++- src/web/public/panels-ui.js | 3 + src/web/public/terminal-ui.js | 55 +++-- test/file-path-link-forms.test.ts | 279 ++++++++++++++++++++++++ test/link-provider-regex.test.ts | 18 +- test/response-viewer-file-links.test.ts | 26 ++- 9 files changed, 609 insertions(+), 38 deletions(-) create mode 100644 test/file-path-link-forms.test.ts diff --git a/src/attachment-registry.ts b/src/attachment-registry.ts index 010a1af6..4dfd6f9e 100644 --- a/src/attachment-registry.ts +++ b/src/attachment-registry.ts @@ -9,7 +9,8 @@ import { randomUUID } from 'node:crypto'; import { realpathSync } from 'node:fs'; import fs from 'node:fs/promises'; -import { basename, extname, isAbsolute } from 'node:path'; +import { homedir } from 'node:os'; +import { basename, extname, isAbsolute, join } from 'node:path'; import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from './config/attachment-guard.js'; import { EDITABLE_EXTENSIONS } from './config/file-editing.js'; import { validateSessionFilePath } from './web/route-helpers.js'; @@ -330,13 +331,19 @@ export async function registerExternalAttachment( requestedPath: string, options: RegisterExternalAttachmentOptions = {} ): Promise { - if (!requestedPath || !isAbsolute(requestedPath)) { + // `~/…` is the home of the session's HOST: expanded here for a local session, + // and by the probe on the remote host for a remote one (`buildRemoteProbeCommand`), + // whose home this server does not know. A clicked terminal link sends it as is + // (Claude Code echoes attachments as `~/…`). Every guard below runs on the + // resolved absolute path either way. + const homeRelative = typeof requestedPath === 'string' && requestedPath.startsWith('~/'); + if (!requestedPath || (!isAbsolute(requestedPath) && !homeRelative)) { throw new AttachmentRegistrationError('Attachment path must be an absolute local path'); } const resolved = await (options.remote ? resolveRemoteAttachment(requestedPath, options.remote, options.sessionWorkingDir, options.remoteProbes) - : resolveLocalAttachment(requestedPath)); + : resolveLocalAttachment(homeRelative ? join(homedir(), requestedPath.slice(2)) : requestedPath)); // COD-53: enforce the active attachment-guard policy on the symlink-resolved // path before doing anything else. diff --git a/src/remote-files.ts b/src/remote-files.ts index 565b3098..393becee 100644 --- a/src/remote-files.ts +++ b/src/remote-files.ts @@ -135,6 +135,10 @@ export function buildRemoteFileCommand(remote: SessionRemote, shellCommand: stri * `echo` (which land before the script runs) from the records without any "last N * lines" guesswork. `realPath` is the last field, so a `|` in a path still parses. * + * A path starting with `~/` is taken relative to the remote user's `$HOME`: this + * server does not know that home, and a clicked `~/…` link means the host the + * session runs on. The record's `realPath` is absolute, as for any other path. + * * Symlink resolution is portable AND fails closed. `readlink -f` where available * (Linux, macOS >= 12.3); otherwise the fallback canonicalizes the directory chain * with `cd -P`/`pwd -P` and then follows the LAST component with plain `readlink` @@ -166,6 +170,8 @@ export function buildRemoteProbeCommand(paths: readonly string[]): string { 'probe() {', ' i=$1', ' p=$2', + // `~/…` from a clicked link: THIS host's home (assignment, so no word splitting). + " case $p in '~/'*) p=$HOME/${p#??} ;; esac", ` if [ ! -e "$p" ]; then printf '%s|${NOT_FOUND_MARKER}\\0' "$i"; return; fi`, ` r=$(readlink -f "$p" 2>/dev/null) || r=$(resolve_last "$p") || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`, ` [ -n "$r" ] || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`, diff --git a/src/web/public/app.js b/src/web/public/app.js index dda35644..4fe132ee 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2744,7 +2744,12 @@ class CodemanApp { const filePath = pathLink.dataset.path; // A rendered document's links name the session the preview was opened // for (_rebaseFilePreviewMarkdownRefs), which need not be the active tab. - if (filePath) this.openFilePreview(filePath, pathLink.dataset.sessionId || this.activeSessionId); + // A path the linkifier found in prose may be relative: resolve it against + // that session's working directory. A markdown document's own links + // (rebased, no `linkified` mark) already are what the routes expect. + const sessionId = pathLink.dataset.sessionId || this.activeSessionId; + const target = pathLink.dataset.linkified ? this._resolveLinkedFilePath?.(filePath, sessionId) : filePath; + if (filePath) this.openFilePreview(target || filePath, sessionId); return; } @@ -2862,7 +2867,8 @@ class CodemanApp { } /** - * Make absolute file paths in a rendered message clickable. + * Make file paths in a rendered message clickable: absolute, `~/` and + * relative ones (`findFilePathLinks`, constants.js). * * The terminal's link provider never sees these: the response viewer is * markdown, and a path the agent wrote as prose or inline code renders as @@ -2880,8 +2886,7 @@ class CodemanApp { if (!root || typeof document === 'undefined') return; // Guarded: a stale cached constants.js must degrade to plain text, not throw // out of the middle of rendering a message. - if (typeof absoluteFilePathPattern !== 'function') return; - const pattern = absoluteFilePathPattern(); + if (typeof findFilePathLinks !== 'function') return; // Collect first: replacing a node while the walker is positioned on it // invalidates the traversal. @@ -2889,27 +2894,25 @@ class CodemanApp { const targets = []; for (let node = walker.nextNode(); node; node = walker.nextNode()) { if (node.parentElement?.closest('a')) continue; - pattern.lastIndex = 0; - if (pattern.test(node.nodeValue || '')) targets.push(node); + const found = findFilePathLinks(node.nodeValue || ''); + if (found.length > 0) targets.push([node, found]); } - for (const node of targets) { + for (const [node, found] of targets) { const value = node.nodeValue; const frag = document.createDocumentFragment(); let cursor = 0; - let match; - pattern.lastIndex = 0; - while ((match = pattern.exec(value)) !== null) { - const path = match[1]; - if (match.index > cursor) frag.appendChild(document.createTextNode(value.slice(cursor, match.index))); + for (const { path, index } of found) { + if (index > cursor) frag.appendChild(document.createTextNode(value.slice(cursor, index))); const link = document.createElement('a'); link.className = 'rv-path'; link.href = '#'; link.dataset.path = path; + link.dataset.linkified = '1'; link.title = path; link.textContent = path; frag.appendChild(link); - cursor = match.index + path.length; + cursor = index + path.length; } if (cursor < value.length) frag.appendChild(document.createTextNode(value.slice(cursor))); node.parentNode?.replaceChild(frag, node); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 8a1d500f..ae38421b 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -1976,6 +1976,95 @@ function previewsInFileViewer(filePath) { return FILE_PREVIEW_EXTENSIONS.has(ext); } +/** + * Home-relative and relative file paths: `~/repos/x/garage.png` (how Claude Code + * echoes an attached image), `builds/captures/shot.png`, `./a.md`, `../b.json`, + * or a bare `look_montage.png`. Same extension list and body as + * {@link FILE_PATH_LINK_PATTERN} (test/link-provider-regex.test.ts pins the two + * equal). + * + * Without a known root to anchor on, the START is what has to be unambiguous: + * group 1 is the boundary in front of the path (line start, whitespace, a quote, + * an opening bracket, `=`, `,`, `>`), so a match can never begin in the middle of + * a word, of an absolute path (`/nix/store/x.png` is not `nix/store/x.png`) or of + * a URL. The path itself is group 2. Its first character is never `/`: absolute + * paths belong to the rooted pattern, which deliberately leaves `/etc` out. A + * token containing `://` is a URL, never a path. Brackets, `=` and `,` end a + * relative path (unlike a rooted one): `(garage.png)` and `OUT=shots/a.png` link + * the file, not the punctuation around it. + * + * ⚠ Like its sibling, never share an instance (`lastIndex`): use + * {@link findFilePathLinks}. + */ +const RELATIVE_FILE_PATH_LINK_PATTERN = + /(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|mp3|wav))\b/g; + +/** The `(?:log|txt|…)` extension group, read off the rooted pattern so it cannot drift. */ +const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1]; + +/** + * Every linkable file path in `text`, in order: absolute (rooted) paths first, + * then `~/` and relative ones that do not overlap them or a URL. + * + * The one entry point for both consumers (terminal link provider, response + * viewer), so a path form is clickable in both or in neither. + * + * @param {string} text + * @returns {Array<{path: string, index: number}>} `index` is where `path` starts in `text`. + */ +function findFilePathLinks(text) { + const value = String(text || ''); + const found = []; + const overlaps = (index, length) => found.some((f) => index < f.index + f.path.length && f.index < index + length); + // Web URLs are never cut into a path: `https://x.io/a/home/b.png` and + // `?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; + let match; + while ((match = urlPattern.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(); + while ((match = absolute.exec(value)) !== null) { + if (!inUrl(match.index, match[1].length)) found.push({ path: match[1], index: match.index }); + } + const relative = new RegExp(RELATIVE_FILE_PATH_LINK_PATTERN.source, 'g'); + while ((match = relative.exec(value)) !== null) { + const path = match[2]; + const index = match.index + match[1].length; + if (overlaps(index, path.length) || inUrl(index, path.length)) continue; + found.push({ path, index }); + } + return found.sort((a, b) => a.index - b.index); +} + +/** + * Where a clicked path points, as the preview routes need it. + * + * Absolute and `~/` paths pass through unchanged — `~` is the home of the + * session's HOST, which only the server knows (a remote case's home is on the + * remote machine), so the server expands it. A relative path is resolved + * against the session's working directory, `.` and `..` collapsed, so a + * `../other/x.png` ends up outside the workspace and routes like any other + * external path. Without a working directory it is returned as is. + * + * @param {string} filePath + * @param {string} [workingDir] + * @returns {string} + */ +function resolveLinkedFilePath(filePath, workingDir) { + const path = String(filePath || ''); + if (!path || path.startsWith('/') || path.startsWith('~/') || !workingDir) return path; + const parts = []; + for (const part of `${workingDir}/${path}`.split('/')) { + if (!part || part === '.') continue; + if (part === '..') parts.pop(); + else parts.push(part); + } + return `/${parts.join('/')}`; +} + /** * The LOGICAL line a terminal row belongs to — the rows it spans, its text as one @@ -2080,6 +2169,125 @@ function terminalLogicalLine(buffer, row, cols, maxRows) { return { startRow, endRow, text, offsetToCell, cellToOffset }; } +/** Roots of {@link FILE_PATH_LINK_PATTERN}, read off its source (`home|Users|…`). */ +const FILE_LINK_ROOT_GROUP = /^\(\\\/\(\?:([^()]*)\)/.exec(FILE_PATH_LINK_PATTERN.source)[1]; +const PATH_TOKEN_ENDS_WITH_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}$`); +const PATH_TOKEN_HAS_EXTENSION = new RegExp(`\\.${FILE_LINK_EXTENSION_GROUP}\\b`); +const PATH_TOKEN_STARTS_NEW_PATH = new RegExp(`^(?:~\\/|\\/(?:${FILE_LINK_ROOT_GROUP})\\/)`); + +/** + * Whether the line `nextText` carries on a file path `prevText` broke off. + * + * Answers `'join'` when the path ends (reaches a known extension) on the next + * line, `'chain'` when the next line is nothing but more of the path (a path + * spanning three or more lines; only worth keeping once a later line ends it), + * and `null` otherwise. The broken-off token must already look like a path in + * progress: it contains a `/`, ends on a path character and does not yet end + * on an extension. A next line that opens a NEW rooted path (`/home/…`, `~/…`) + * is never glued on. + */ +function pathContinuation(prevText, nextText) { + const tail = /[^\s"'<>|;&]+$/.exec(String(prevText || '').replace(/\s+$/, ''))?.[0] || ''; + if (!tail.includes('/') || !/[\w\-.~+@%/]$/.test(tail) || PATH_TOKEN_ENDS_WITH_EXTENSION.test(tail)) return null; + const next = String(nextText || '').replace(/^\s+/, ''); + const head = /^[^\s"'<>|;&]+/.exec(next)?.[0] || ''; + if (!head || PATH_TOKEN_STARTS_NEW_PATH.test(head)) return null; + if (PATH_TOKEN_HAS_EXTENSION.test(head)) return 'join'; + return head === next.replace(/\s+$/, '') ? 'chain' : null; +} + +/** + * {@link terminalLogicalLine}, extended across the line breaks a program puts + * INSIDE a file path. Used by the link provider only; touch selection keeps the + * plain logical line. + * + * Claude Code wraps its tool output itself, a few columns short of the terminal + * edge, so neither continuation signal of terminalLogicalLine fires (no + * `isWrapped`, the row does not reach the last column) and a long path was only + * linked up to the break: + * + * ⎿ BILD /mnt/build/neon_getaway-mgr-hebel/builds/captures/2 + * 026-10-10/ghost1b_gameplay.png err=0 … + * + * A following logical line is glued on (its indent dropped, cell mapping kept + * exact) when {@link pathContinuation} says it carries the path on and the row it + * broke off is at least as long as the row it continues on. Bounded by the same + * `maxRows` as the logical line. + * + * @returns Same shape as {@link terminalLogicalLine}; null when the row does not exist. + */ +function terminalPathLine(buffer, row, cols, maxRows) { + const base = terminalLogicalLine(buffer, row, cols, maxRows); + if (!base) return null; + const bound = Math.max(1, maxRows || 12); + const lineAt = (r) => terminalLogicalLine(buffer, r, cols, bound); + const rowsOf = (line) => line.endRow - line.startRow + 1; + // A program breaking a long token fills the row to its wrap width, and no + // later row of that block is wider. So the row a path broke off is at least as + // long as the row it continues on — which keeps `cd /mnt/foo` from being glued + // to a longer ` file.png written` below it. + const rowLength = (r) => (buffer.getLine(r)?.translateToString(true) || '').length; + const continues = (prev, next, prevText) => + rowLength(prev.endRow) >= rowLength(next.startRow) && pathContinuation(prevText, next.text); + + /** `first` and the lines a path carries on into, stopping at the last confirmed join. */ + const extend = (first) => { + const parts = [first]; + let text = first.text; + let rows = rowsOf(first); + let confirmed = 1; + for (;;) { + const next = lineAt(parts[parts.length - 1].endRow + 1); + if (!next || rows + rowsOf(next) > bound) break; + const kind = continues(parts[parts.length - 1], next, text); + if (!kind) break; + parts.push(next); + text += next.text.replace(/^\s+/, ''); + rows += rowsOf(next); + if (kind === 'join') confirmed = parts.length; + } + return parts.slice(0, confirmed); + }; + + // The hovered row may be the second or third line of a broken path: walk back + // to where it could start, then let the forward pass decide what really joins. + let first = base; + let rows = rowsOf(base); + while (first.startRow > 0) { + const prev = lineAt(first.startRow - 1); + if (!prev || rows + rowsOf(prev) > bound || !continues(prev, first, prev.text)) break; + first = prev; + rows += rowsOf(prev); + } + let parts = extend(first); + if (parts[parts.length - 1].endRow < base.startRow) parts = extend(base); + if (parts.length === 1) return parts[0]; + + const segments = []; + let text = ''; + for (const [i, part] of parts.entries()) { + const skip = i === 0 ? 0 : part.text.length - part.text.replace(/^\s+/, '').length; + segments.push({ part, textStart: text.length, skip }); + text += part.text.slice(skip); + } + const offsetToCell = (offset) => { + for (let i = segments.length - 1; i >= 0; i--) { + const seg = segments[i]; + if (offset >= seg.textStart || i === 0) return seg.part.offsetToCell(offset - seg.textStart + seg.skip); + } + return segments[0].part.offsetToCell(offset); + }; + const cellToOffset = (targetRow, targetCol) => { + for (const seg of segments) { + if (targetRow < seg.part.startRow || targetRow > seg.part.endRow) continue; + const offset = seg.part.cellToOffset(targetRow, targetCol); + return offset < 0 ? -1 : seg.textStart + Math.max(0, offset - seg.skip); + } + return -1; + }; + return { startRow: parts[0].startRow, endRow: parts[parts.length - 1].endRow, text, offsetToCell, cellToOffset }; +} + // ═══════════════════════════════════════════════════════════════ // Split-Pane Sessions — pure helpers (divider math, picker list) // ═══════════════════════════════════════════════════════════════ @@ -2992,8 +3200,14 @@ function describeSessionHarness(session, catalog) { if (typeof window !== 'undefined') { window.CodemanSessionHarness = { describeSessionHarness, SESSION_MODEL_MAX_CHARS }; window.CodemanHistoryFormat = { formatHistoryBytes, computeHistoryTruncationNotice, computeRewriteScrollLine }; - window.CodemanFilePaths = { absoluteFilePathPattern, previewsInFileViewer, FILE_PREVIEW_EXTENSIONS }; - window.CodemanTerminalLines = { terminalLogicalLine }; + window.CodemanFilePaths = { + absoluteFilePathPattern, + findFilePathLinks, + resolveLinkedFilePath, + previewsInFileViewer, + FILE_PREVIEW_EXTENSIONS, + }; + window.CodemanTerminalLines = { terminalLogicalLine, terminalPathLine }; window.CodemanUrlSession = { sessionIdFromFragment }; window.CodemanSplitPane = { clampDividerPercent, diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 6781e575..57fe70ff 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -4037,6 +4037,9 @@ Object.assign(CodemanApp.prototype, { * unknown workingDir answers false, leaving the historical path untouched. */ _isExternalPreviewPath(filePath, sessionId) { + // `~/…` (a terminal or response-viewer link): only the server knows the home + // of the session's host, and only the attachment route expands it. + if (typeof filePath === 'string' && filePath.startsWith('~/')) return true; if (typeof filePath !== 'string' || !filePath.startsWith('/')) return false; const workingDir = this.sessions.get(sessionId)?.workingDir; if (!workingDir) return false; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 5a8f659b..afe24597 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1918,6 +1918,17 @@ Object.assign(CodemanApp.prototype, { }); }, + /** + * A clicked path as the preview needs it: relative paths resolved against the + * session's working directory, absolute and `~/` paths unchanged + * (`resolveLinkedFilePath`, constants.js). Shared by the terminal link + * provider and the response viewer, so one path opens the same file in both. + */ + _resolveLinkedFilePath(filePath, sessionId) { + if (typeof resolveLinkedFilePath !== 'function') return filePath; + return resolveLinkedFilePath(filePath, this.sessions?.get?.(sessionId)?.workingDir); + }, + /** * Register a custom link provider for xterm.js that detects file paths * in terminal output and makes them clickable. @@ -1970,10 +1981,14 @@ Object.assign(CodemanApp.prototype, { // both continuation kinds, the indent a hard wrap leaves on its // continuation, and the offset↔cell mapping — because touch selection // measures the SAME lines and the two must not disagree. + // `terminalPathLine` adds one thing on top, for links only: a path the + // program broke over lines itself (Claude Code wraps tool output short of + // the edge) is glued back together, so `…/captures/2` + `026-10-10/x.png` + // 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. const MAX_STITCHED_ROWS = 12; - const logical = window.CodemanTerminalLines?.terminalLogicalLine( + const logical = window.CodemanTerminalLines?.terminalPathLine( buffer, bufferLineNumber - 1, terminal.cols, @@ -1991,12 +2006,18 @@ Object.assign(CodemanApp.prototype, { return { x: cell.col + 1, y: cell.row + 1 }; }; - if (!lineText || !lineText.includes('/')) { + // A path needs a `/` or an extension's `.`; a line with neither has no link. + if (!lineText || (!lineText.includes('/') && !lineText.includes('.'))) { callback(undefined); return; } const links = []; + // Text offsets of every link so far: a later pattern must not cut a + // second, overlapping link out of one already found (a relative `b.png` + // inside a URL's `?img=b.png`). + 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 // @@ -2016,6 +2037,7 @@ Object.assign(CodemanApp.prototype, { const start = coordAt(startCol); const end = coordAt(startCol + cleaned.length); 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 }); links.push({ text: cleaned, @@ -2045,15 +2067,14 @@ Object.assign(CodemanApp.prototype, { // the whole tab on hover. Non-empty token + bounded reps is O(n). const cmdPattern = /\b(tail|cat|head|less|grep|watch|vim|nano)\s+(?:[^\s\/]+\s+){0,4}(\/[^\s"'<>|;&\n\x00-\x1f]+)/g; - // Pattern 2: Paths with common extensions. Image/PDF/media extensions are + // Pattern 2: Paths with common extensions — absolute, `~/` and relative + // (`findFilePathLinks`, constants.js). Image/PDF/media extensions are // included so pasted-attachment paths (`.codeman-uploads/paste-*.png`) and // screenshots an agent just wrote are clickable; those open the file // preview rather than the log viewer (see addLink). // - // The literal lives in constants.js because the response viewer linkifies - // the SAME paths out of markdown — one definition, two consumers. A fresh - // instance per call: `lastIndex` is per-object state. - const extPattern = absoluteFilePathPattern(); + // The patterns live in constants.js because the response viewer linkifies + // the SAME paths out of markdown — one definition, two consumers. // Pattern 3: Bash() tool output const bashPattern = /Bash\([^)]*?(\/(?:home|tmp|var|etc|opt)[^\s"'<>|;&\)\n\x00-\x1f]+)/g; @@ -2064,8 +2085,10 @@ Object.assign(CodemanApp.prototype, { const start = coordAt(startCol); const end = coordAt(startCol + filePath.length); - // Skip if already have link at this position + // Skip if already have link at this position, or one overlapping it if (links.some((l) => l.range.start.x === start.x && l.range.start.y === start.y)) return; + if (isTaken(startCol, filePath.length)) return; + taken.push({ start: startCol, end: startCol + filePath.length }); links.push({ text: filePath, @@ -2088,12 +2111,17 @@ Object.assign(CodemanApp.prototype, { // 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(); - if (previewsInFileViewer(text) || self._isExternalPreviewPath(text, sessionId)) { - self.openFilePreview(text, sessionId); + const target = self._resolveLinkedFilePath(text, sessionId); + if (previewsInFileViewer(target) || self._isExternalPreviewPath(target, sessionId)) { + self.openFilePreview(target, sessionId); return; } - self.openLogViewerWindow(text, sessionId); + self.openLogViewerWindow(target, sessionId); }, hover() { setHovered(true); @@ -2117,9 +2145,8 @@ Object.assign(CodemanApp.prototype, { addLink(match[2], match.index); } - extPattern.lastIndex = 0; - while ((match = extPattern.exec(lineText)) !== null) { - addLink(match[1], match.index); + for (const found of findFilePathLinks(lineText)) { + addLink(found.path, found.index); } bashPattern.lastIndex = 0; diff --git a/test/file-path-link-forms.test.ts b/test/file-path-link-forms.test.ts new file mode 100644 index 00000000..428e0563 --- /dev/null +++ b/test/file-path-link-forms.test.ts @@ -0,0 +1,279 @@ +/** + * @fileoverview Clickable file paths beyond the rooted absolute form: `~/…`, + * relative paths, and paths a program broke over lines itself. + * + * - `findFilePathLinks` / `resolveLinkedFilePath` / `terminalPathLine` + * (constants.js) on the shapes real agent output has: Claude Code echoes an + * attached image as `› [image] ~/…/garage.png`, scripts print + * `builds/captures/…png`, and Claude Code wraps tool output a few columns + * short of the edge, so a long path arrives as `…/captures/2` + `026-10-10/x.png`. + * - The terminal link provider turns those into ONE link and opens the resolved + * path (relative against the session's working directory, `~/` as is). + * - The server expands `~/` (local: this host's home; remote: the probe on the + * remote host), so the attachment route can serve it. + * + * Real code under test: constants.js + terminal-ui.js in a `vm` context, and + * the shipped attachment registry / probe script. + */ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import vm from 'node:vm'; +import { afterAll, describe, expect, it, vi } from 'vitest'; +import { registerExternalAttachment } from '../src/attachment-registry.js'; +import { buildRemoteProbeCommand, parseRemoteProbeOutput } from '../src/remote-files.js'; + +type Line = { + startRow: number; + endRow: number; + text: string; + offsetToCell: (o: number) => { row: number; col: number }; +}; +type Helpers = { + findFilePathLinks: (text: string) => Array<{ path: string; index: number }>; + resolveLinkedFilePath: (path: string, workingDir?: string) => string; + terminalPathLine: (buffer: unknown, row: number, cols: number, maxRows?: number) => Line | null; +}; + +const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8'); + +function loadHelpers(): Helpers { + const context = vm.createContext({}); + vm.runInContext( + `${read('constants.js')}\nglobalThis.__h = { findFilePathLinks, resolveLinkedFilePath, terminalPathLine };`, + context, + { filename: 'constants.js' } + ); + return (context as { __h: Helpers }).__h; +} + +const { findFilePathLinks, resolveLinkedFilePath, terminalPathLine } = loadHelpers(); +const paths = (text: string) => findFilePathLinks(text).map((f) => f.path); + +/** An xterm buffer stand-in: hard-broken rows (no `isWrapped`), as Claude Code emits them. */ +function buffer(lines: string[], cols = 80) { + return { + length: lines.length, + getLine: (row: number) => + row >= 0 && row < lines.length + ? { + isWrapped: false, + translateToString: (trim?: boolean) => (trim ? lines[row].replace(/\s+$/, '') : lines[row].padEnd(cols)), + } + : undefined, + }; +} + +// Verbatim from a live pane (2026-10-10): the tool output wrapped at ~64 columns. +const BROKEN = [ + ' ⎿ BILD /mnt/build/neon_getaway-mgr-hebel/builds/captures/2', + ' 026-10-10/ghost1b_gameplay.png err=0 kamera=(1108,13,811', + ' ) blick=', +]; +const BROKEN_PATH = '/mnt/build/neon_getaway-mgr-hebel/builds/captures/2026-10-10/ghost1b_gameplay.png'; + +describe('findFilePathLinks', () => { + it('links home-relative and relative paths', () => { + expect(paths(' › [image] ~/repos/neon-menu/assets/ui/backgrounds/garage_plate.png (1.1MB)')).toEqual([ + '~/repos/neon-menu/assets/ui/backgrounds/garage_plate.png', + ]); + expect(paths('2 Bericht: refs/shaderpatch_pixel_1010/BERICHT.md. Alle 12')).toEqual([ + 'refs/shaderpatch_pixel_1010/BERICHT.md', + ]); + expect(paths('./a.md ../b.json look_montage.png')).toEqual(['./a.md', '../b.json', 'look_montage.png']); + }); + + it('keeps brackets, `=` and trailing punctuation out of a relative path', () => { + expect(paths('1. Garage-BG (garage_plate.png) — Bühne')).toEqual(['garage_plate.png']); + expect(paths('OUT=builds/captures/x.png, "shots/b.webp"')).toEqual(['builds/captures/x.png', 'shots/b.webp']); + expect(paths('see src/a.ts:120.')).toEqual(['src/a.ts']); + }); + + it('still finds rooted absolute paths, and never a relative path inside one', () => { + expect(paths('sign-off from /mnt/raid1/x/refs/look_montage.png.')).toEqual(['/mnt/raid1/x/refs/look_montage.png']); + // Unknown root: not linked at all (no `nix/store/…` cut out of it). + expect(paths('/nix/store/abc-x/share/icon.png')).toEqual([]); + // `/etc` stays out on purpose (the server refuses that tree). + expect(paths('/etc/app/config.json')).toEqual([]); + }); + + it('never cuts a path out of a web URL, but keeps file:// paths', () => { + expect(paths('https://x.io/a/home/b.png?img=c.png')).toEqual([]); + expect(paths('file:///home/joe/x.png')).toEqual(['/home/joe/x.png']); + }); + + it('reports where each path starts', () => { + const text = 'a ~/x.png b /tmp/y.pdf c z.md'; + for (const { path, index } of findFilePathLinks(text)) expect(text.slice(index, index + path.length)).toBe(path); + }); +}); + +describe('resolveLinkedFilePath', () => { + it('resolves relative paths against the working directory, collapsing . and ..', () => { + expect(resolveLinkedFilePath('builds/x.png', '/mnt/ws')).toBe('/mnt/ws/builds/x.png'); + expect(resolveLinkedFilePath('./a/./b.md', '/mnt/ws/')).toBe('/mnt/ws/a/b.md'); + expect(resolveLinkedFilePath('../other/x.png', '/mnt/ws')).toBe('/mnt/other/x.png'); + }); + + it('leaves absolute and ~/ paths alone, and relative ones without a working directory', () => { + expect(resolveLinkedFilePath('/tmp/a.png', '/mnt/ws')).toBe('/tmp/a.png'); + expect(resolveLinkedFilePath('~/a.png', '/mnt/ws')).toBe('~/a.png'); + expect(resolveLinkedFilePath('a.png', undefined)).toBe('a.png'); + }); +}); + +describe('terminalPathLine', () => { + it('glues a path broken short of the edge back together, from either row', () => { + for (const row of [0, 1]) { + const line = terminalPathLine(buffer(BROKEN), row, 80)!; + expect([line.startRow, line.endRow]).toEqual([0, 1]); + const [found] = findFilePathLinks(line.text); + expect(found.path).toBe(BROKEN_PATH); + // The cells are exact on both rows: after ` ⎿ BILD ` and after the 5-column indent. + expect(line.offsetToCell(found.index)).toEqual({ row: 0, col: 10 }); + expect(line.offsetToCell(found.index + found.path.length - 1)).toEqual({ row: 1, col: 34 }); + } + expect(terminalPathLine(buffer(BROKEN), 2, 80)).toMatchObject({ startRow: 2, endRow: 2 }); + }); + + it('follows a path over three rows', () => { + const rows = ['x /tmp/claude-1001/-home-joe-cases', ' -neon/de4576ce-aaaaaaaaaaaaa', ' -56b4/shot.png']; + const line = terminalPathLine(buffer(rows), 1, 80)!; + expect(paths(line.text)).toEqual(['/tmp/claude-1001/-home-joe-cases-neon/de4576ce-aaaaaaaaaaaaa-56b4/shot.png']); + }); + + it('does not glue lines that are not a broken path', () => { + // A shorter line ending on a directory, then a longer one: not a wrap. + expect(terminalPathLine(buffer(['cd /mnt/raid1/foo', ' file.png erstellt']), 0, 80)).toMatchObject({ endRow: 0 }); + // A chain that never reaches a file is dropped. + const listing = terminalPathLine(buffer(['cd ~/repos/neon', 'ls', 'a.png']), 2, 80)!; + expect(paths(listing.text)).toEqual(['a.png']); + // A path that already ended, and a next line opening a new rooted path. + expect(terminalPathLine(buffer(['see /tmp/a/b.png', 'c/d.png']), 0, 80)).toMatchObject({ endRow: 0 }); + expect(terminalPathLine(buffer(['/home/joe/dir/', '/home/x.png']), 0, 80)).toMatchObject({ endRow: 0 }); + }); +}); + +type Link = { + text: string; + range: { start: { x: number; y: number }; end: { x: number; y: number } }; + activate: (ev: unknown, text: string) => void; +}; +type Provider = { provideLinks: (line: number, cb: (links: Link[] | undefined) => void) => void }; + +function loadApp() { + const CodemanApp = function CodemanApp(this: unknown) {}; + const context = vm.createContext({ + window: {}, + document: { + body: { classList: { contains: () => false } }, + activeElement: null, + addEventListener: vi.fn(), + getElementById: () => null, + }, + CodemanApp, + console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() }, + _crashDiag: { log: vi.fn() }, + performance: { now: () => 0 }, + requestAnimationFrame: () => 1, + setTimeout: () => 1, + MobileDetection: { isTouchDevice: () => false }, + DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g, + TERMINAL_CHUNK_SIZE: 32 * 1024, + }); + vm.runInContext(read('constants.js'), context, { filename: 'constants.js' }); + vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' }); + const app = new (CodemanApp as unknown as new () => Record)(); + app.activeSessionId = 's1'; + app.sessions = new Map([['s1', { workingDir: '/mnt/build/neon_getaway-mgr-hebel' }]]); + app.openFilePreview = vi.fn(); + app.openLogViewerWindow = vi.fn(); + app._isExternalPreviewPath = (p: string) => p.startsWith('~/'); + return app; +} + +function linksOn(lines: string[], row: number) { + const app = loadApp(); + app.terminal = { cols: 80, registerLinkProvider: vi.fn(), buffer: { active: buffer(lines) } }; + const provider = app.registerFilePathLinkProvider() as Provider; + let out: Link[] = []; + provider.provideLinks(row, (links) => { + out = links || []; + }); + return { app, links: out }; +} + +describe('terminal link provider', () => { + it('links a broken path once, across both rows, and opens the whole path', () => { + const { app, links } = linksOn(BROKEN, 2); + expect(links.map((l) => l.text)).toEqual([BROKEN_PATH]); + expect(links[0].range.start).toEqual({ x: 11, y: 1 }); + expect(links[0].range.end.y).toBe(2); + links[0].activate({}, links[0].text); + expect(app.openFilePreview).toHaveBeenCalledWith(BROKEN_PATH, 's1'); + }); + + it('opens a relative path resolved against the working directory', () => { + const { app, links } = linksOn([' ⎿ wrote builds/captures/2026-10-10/a.png'], 1); + links[0].activate({}, links[0].text); + expect(app.openFilePreview).toHaveBeenCalledWith( + '/mnt/build/neon_getaway-mgr-hebel/builds/captures/2026-10-10/a.png', + 's1' + ); + }); + + it('opens a ~/ path as is, in the preview (the server expands it), even for text', () => { + const { app, links } = linksOn([' › [image] ~/repos/x/garage.png', 'notes ~/logs/run.log'], 2); + links[0].activate({}, links[0].text); + expect(app.openFilePreview).toHaveBeenCalledWith('~/logs/run.log', 's1'); + expect(app.openLogViewerWindow).not.toHaveBeenCalled(); + }); + + it('opens a relative text file in the log viewer by its resolved path', () => { + const { app, links } = linksOn(['tail logs/run.log'], 1); + links[0].activate({}, links[0].text); + expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/build/neon_getaway-mgr-hebel/logs/run.log', 's1'); + }); + + it('never adds a path link overlapping a URL link', () => { + const { links } = linksOn(['see https://x.io/view?img=shots/a.png now'], 1); + expect(links.map((l) => l.text)).toEqual(['https://x.io/view?img=shots/a.png']); + }); +}); + +describe('server: ~/ expands on the session host', () => { + const root = mkdtempSync(join(tmpdir(), 'codeman-tilde-')); + afterAll(() => rmSync(root, { recursive: true, force: true })); + + it('a local session registers ~/… against this host home', async () => { + // test/setup.ts points HOME at a temp dir, so this never touches the real home. + const dir = join(homedir(), 'tilde-link-test'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'shot.png'), 'PNG'); + try { + const event = await registerExternalAttachment('tilde-session', '~/tilde-link-test/shot.png', {}); + expect(event.attachmentId).toMatch(/^att_/); + expect(event.size).toBe(3); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('a relative path is still refused', async () => { + await expect(registerExternalAttachment('tilde-session', 'shots/a.png', {})).rejects.toThrow(/absolute/); + }); + + it('the remote probe expands ~/ to the remote $HOME, and reports the absolute path', () => { + writeFileSync(join(root, 'remote.png'), 'PNGPNG'); + const requested = ['~/remote.png', '~/missing.png']; + const stdout = execFileSync('sh', ['-c', buildRemoteProbeCommand(requested)], { + env: { ...process.env, HOME: root }, + }).toString(); + const [found, missing] = parseRemoteProbeOutput(stdout, requested); + expect(found).toMatchObject({ kind: 'file', size: 6 }); + expect(found?.realPath.endsWith('/remote.png')).toBe(true); + expect(found?.realPath.startsWith('/')).toBe(true); + expect(missing).toBeNull(); + }); +}); diff --git a/test/link-provider-regex.test.ts b/test/link-provider-regex.test.ts index b295d703..e8962053 100644 --- a/test/link-provider-regex.test.ts +++ b/test/link-provider-regex.test.ts @@ -36,7 +36,13 @@ function shippedPattern(name: string): RegExp { return new RegExp(lit.slice(1, lastSlash), lit.slice(lastSlash + 1)); } -const PATTERN_NAMES = ['urlPattern', 'cmdPattern', 'FILE_PATH_LINK_PATTERN', 'bashPattern']; +const PATTERN_NAMES = [ + 'urlPattern', + 'cmdPattern', + 'FILE_PATH_LINK_PATTERN', + 'RELATIVE_FILE_PATH_LINK_PATTERN', + 'bashPattern', +]; /** Lines that made 0.9.10's cmdPattern backtrack exponentially (>2s each). */ const KILLER_LINES = [ @@ -167,13 +173,19 @@ describe('terminal link-provider regexes (shipped source)', () => { } }); - it('terminal-ui builds its path pattern from the shared factory', () => { + it('terminal-ui finds its paths through the shared helper', () => { // Structural guard: a local literal here would drift from the response // viewer's linkifier, which is the divergence the move exists to prevent. - expect(SOURCE).toContain('absoluteFilePathPattern()'); + expect(SOURCE).toContain('findFilePathLinks(lineText)'); expect(SOURCE).not.toMatch(/const extPattern =\s*\n?\s*\//); }); + it('the rooted and the relative path pattern link the same extensions', () => { + const extensions = (name: string) => /\\\.\(\?:([^()]*)\)\)\\b$/.exec(shippedPattern(name).source)?.[1]; + expect(extensions('FILE_PATH_LINK_PATTERN')).toBeTruthy(); + expect(extensions('RELATIVE_FILE_PATH_LINK_PATTERN')).toBe(extensions('FILE_PATH_LINK_PATTERN')); + }); + it('cmdPattern arg group cannot match empty tokens (the exponential trigger)', () => { // structural guard: the dangerous construct is an empty-matchable token // inside a repeated group — `[^\s\/]*\s+` repeated. Check the pattern diff --git a/test/response-viewer-file-links.test.ts b/test/response-viewer-file-links.test.ts index 43e46833..a771ad7c 100644 --- a/test/response-viewer-file-links.test.ts +++ b/test/response-viewer-file-links.test.ts @@ -111,10 +111,24 @@ describe('response viewer file-path linkifier', () => { }); it('leaves text with no path untouched', () => { - const root = linkify('

Ratio 3/4 on 2026/08/16, see src/app.ts

'); + const root = linkify('

Ratio 3/4 on 2026/08/16, see the app

'); expect(paths(root)).toHaveLength(0); - expect(root.textContent).toBe('Ratio 3/4 on 2026/08/16, see src/app.ts'); + expect(root.textContent).toBe('Ratio 3/4 on 2026/08/16, see the app'); + }); + + it('links ~/ and relative paths too, the same forms the terminal links', () => { + const root = linkify( + '

Attached ~/repos/x/garage.png, wrote builds/captures/a.png (see src/app.ts)

' + ); + + expect(paths(root).map((a) => a.getAttribute('data-path'))).toEqual([ + '~/repos/x/garage.png', + 'builds/captures/a.png', + 'src/app.ts', + ]); + expect(root.textContent).toBe('Attached ~/repos/x/garage.png, wrote builds/captures/a.png (see src/app.ts)'); + expect(paths(root).every((a) => a.getAttribute('data-linkified') === '1')).toBe(true); }); it('never linkifies /etc paths — the server blocks the whole tree, so the link could only 403', () => { @@ -145,6 +159,12 @@ describe('response viewer file-path linkifier', () => { // either leaves inert paths (no linkify) or dead links (no handler). expect(APP_SOURCE).toContain('this._linkifyFilePaths(renderedText)'); expect(APP_SOURCE).toMatch(/closest\('a\.rv-path'\)/); - expect(APP_SOURCE).toMatch(/openFilePreview\(filePath, pathLink\.dataset\.sessionId \|\| this\.activeSessionId\)/); + expect(APP_SOURCE).toMatch(/const sessionId = pathLink\.dataset\.sessionId \|\| this\.activeSessionId;/); + // Only the linkifier's own links are resolved; a markdown document's rebased + // links keep the path the rebase computed. + expect(APP_SOURCE).toMatch( + /pathLink\.dataset\.linkified \? this\._resolveLinkedFilePath\?\.\(filePath, sessionId\) : filePath/ + ); + expect(APP_SOURCE).toMatch(/openFilePreview\(target \|\| filePath, sessionId\)/); }); });