From fb0fcdfa7c1c51bd32d190d732da19f6fd02cd32 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 21 May 2026 05:01:23 +0000 Subject: [PATCH] feat(tui): added OSC 8 hyperlink support for file paths in tool output --- packages/coding-agent/CHANGELOG.md | 4 + .../src/config/settings-schema.ts | 12 ++ packages/coding-agent/src/edit/renderer.ts | 8 +- packages/coding-agent/src/tools/ast-edit.ts | 88 +++++---- packages/coding-agent/src/tools/ast-grep.ts | 84 ++++---- packages/coding-agent/src/tools/find.ts | 43 +++-- packages/coding-agent/src/tools/read.ts | 8 +- packages/coding-agent/src/tools/search.ts | 70 ++++--- packages/coding-agent/src/tui/file-list.ts | 13 +- packages/coding-agent/src/tui/hyperlink.ts | 84 ++++++++ packages/coding-agent/src/tui/index.ts | 1 + .../coding-agent/test/tui/hyperlink.test.ts | 181 ++++++++++++++++++ 12 files changed, 480 insertions(+), 116 deletions(-) create mode 100644 packages/coding-agent/src/tui/hyperlink.ts create mode 100644 packages/coding-agent/test/tui/hyperlink.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6526c6481..7ba4f8d79 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added OSC 8 terminal hyperlink support for file paths in tool output. When the terminal supports hyperlinks (kitty, Ghostty, WezTerm, iTerm2, Alacritty, VS Code) and the new `tui.hyperlinks` setting is `auto` (default) or `always`, OMP wraps file paths emitted by `read`, `find`, `search`, `edit`, `ast_grep`, and `ast_edit` renderers in `file:///abs/path` hyperlinks. `local://` and other fs-backed internal URLs resolve to their backing path. Set `tui.hyperlinks: off` to disable. ([#1244](https://github.com/can1357/oh-my-pi/issues/1244)) + ## [15.1.8] - 2026-05-20 ### Fixed diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index 52e077faa..b17996aab 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -583,6 +583,18 @@ export const SETTINGS_SCHEMA = { description: "Maximum height in terminal rows for inline images (default 20). Set to 0 to use only the viewport-based limit (60% of terminal height).", }, + + "tui.hyperlinks": { + type: "enum", + values: ["off", "auto", "always"] as const, + default: "auto", + ui: { + tab: "appearance", + label: "Terminal Hyperlinks", + description: + "Wrap file paths in OSC 8 hyperlinks for terminal-native click-to-open (auto: detect support; off: never; always: unconditional)", + }, + }, // Display rendering "display.tabWidth": { type: "number", diff --git a/packages/coding-agent/src/edit/renderer.ts b/packages/coding-agent/src/edit/renderer.ts index 886dbc1be..1fd113a8e 100644 --- a/packages/coding-agent/src/edit/renderer.ts +++ b/packages/coding-agent/src/edit/renderer.ts @@ -25,7 +25,7 @@ import { truncateDiffByHunk, } from "../tools/render-utils"; import { type VimRenderArgs, vimToolRenderer } from "../tools/vim"; -import { Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui"; +import { fileHyperlink, Hasher, type RenderCache, renderStatusLine, truncateToWidth } from "../tui"; import type { EditMode } from "../utils/edit-mode"; import type { VimToolDetails } from "../vim/types"; import type { DiffError, DiffResult } from "./diff"; @@ -219,14 +219,16 @@ function formatEditPathDisplay( uiTheme: Theme, options?: { rename?: string; firstChangedLine?: number }, ): string { - let pathDisplay = rawPath ? uiTheme.fg("accent", shortenPath(rawPath)) : uiTheme.fg("toolOutput", "…"); + let pathDisplay = rawPath + ? fileHyperlink(rawPath, uiTheme.fg("accent", shortenPath(rawPath))) + : uiTheme.fg("toolOutput", "…"); if (options?.firstChangedLine) { pathDisplay += uiTheme.fg("warning", `:${options.firstChangedLine}`); } if (options?.rename) { - pathDisplay += ` ${uiTheme.fg("dim", "→")} ${uiTheme.fg("accent", shortenPath(options.rename))}`; + pathDisplay += ` ${uiTheme.fg("dim", "→")} ${fileHyperlink(options.rename, uiTheme.fg("accent", shortenPath(options.rename)))}`; } return pathDisplay; diff --git a/packages/coding-agent/src/tools/ast-edit.ts b/packages/coding-agent/src/tools/ast-edit.ts index 7e27253d1..4d7af7ab3 100644 --- a/packages/coding-agent/src/tools/ast-edit.ts +++ b/packages/coding-agent/src/tools/ast-edit.ts @@ -9,7 +9,7 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import { computeLineHash, HL_BODY_SEP } from "../hashline/hash"; import type { Theme } from "../modes/theme/theme"; import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" }; -import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; +import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import type { ToolSession } from "."; import { createFileRecorder, formatResultPath } from "./file-recorder"; @@ -155,6 +155,9 @@ export interface AstEditToolDetails { /** Pre-formatted text for the user-visible TUI render. Mirrors `result.text` lines but uses * a `│` gutter (no model-only hashline anchors). The TUI uses this directly so it never parses model-facing text. */ displayContent?: string; + /** Absolute base directory used during the edit. Used by the renderer to resolve + * display-relative paths to absolute paths for OSC 8 hyperlinks. */ + searchPath?: string; } export class AstEditTool implements AgentTool { @@ -233,17 +236,18 @@ export class AstEditTool implements AgentTool 0 ? { parseErrors: cappedParseErrors, parseErrorsTotal } : {}), - scopePath, - files: fileList, - fileReplacements: [], - }; + const baseDetails: AstEditToolDetails = { + totalReplacements: result.totalReplacements, + filesTouched: result.filesTouched, + filesSearched: result.filesSearched, + applied: result.applied, + limitReached: result.limitReached, + ...(cappedParseErrors.length > 0 ? { parseErrors: cappedParseErrors, parseErrorsTotal } : {}), + scopePath, + searchPath: resolvedSearchPath, + files: fileList, + fileReplacements: [], + }; if (result.totalReplacements === 0) { const parseMessage = cappedParseErrors.length @@ -480,30 +484,44 @@ export const astEditToolRenderer = { uiTheme.fg("warning", formatParseErrorsCountLabel(details.parseErrors, details.parseErrorsTotal)), ); } - return createCachedComponent( - () => options.expanded, - width => { - const changeLines = renderTreeList( - { - items: changeGroups, - expanded: options.expanded, - maxCollapsed: changeGroups.length, - maxCollapsedLines: COLLAPSED_CHANGE_LIMIT, - itemType: "change", - renderItem: group => - group.map(line => { - if (line.startsWith("## ")) return uiTheme.fg("dim", line); - if (line.startsWith("# ")) return uiTheme.fg("accent", line); - if (line.startsWith("+")) return uiTheme.fg("toolDiffAdded", line); - if (line.startsWith("-")) return uiTheme.fg("toolDiffRemoved", line); - return uiTheme.fg("toolOutput", line); - }), + return createCachedComponent( + () => options.expanded, + width => { + const searchBase = details?.searchPath; + const changeLines = renderTreeList( + { + items: changeGroups, + expanded: options.expanded, + maxCollapsed: changeGroups.length, + maxCollapsedLines: COLLAPSED_CHANGE_LIMIT, + itemType: "change", + renderItem: group => { + let contextDir = searchBase ?? ""; + return group.map(line => { + if (line.startsWith("# ")) { + const dirPart = line.slice(2).trimEnd().replace(/\/$/, ""); + if (searchBase) { + contextDir = dirPart === "." ? searchBase : path.join(searchBase, dirPart); + } + return uiTheme.fg("accent", line); + } + if (line.startsWith("## ")) { + const fileName = line.slice(3).trimEnd(); + const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined; + const styled = uiTheme.fg("dim", line); + return absPath ? fileHyperlink(absPath, styled) : styled; + } + if (line.startsWith("+")) return uiTheme.fg("toolDiffAdded", line); + if (line.startsWith("-")) return uiTheme.fg("toolDiffRemoved", line); + return uiTheme.fg("toolOutput", line); + }); }, - uiTheme, - ); - return [header, ...changeLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); - }, - ); + }, + uiTheme, + ); + return [header, ...changeLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); + }, + ); }, mergeCallAndResult: true, }; diff --git a/packages/coding-agent/src/tools/ast-grep.ts b/packages/coding-agent/src/tools/ast-grep.ts index 0c87b4f47..0aea82395 100644 --- a/packages/coding-agent/src/tools/ast-grep.ts +++ b/packages/coding-agent/src/tools/ast-grep.ts @@ -8,7 +8,7 @@ import * as z from "zod/v4"; import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text" }; -import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; +import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import type { ToolSession } from "."; import { createFileRecorder, formatResultPath } from "./file-recorder"; @@ -113,6 +113,9 @@ export interface AstGrepToolDetails { /** Pre-formatted text for the user-visible TUI render. Mirrors `result.text` lines but uses * a `│` gutter and `*` to mark match lines. The TUI uses this directly so it never parses model-facing text. */ displayContent?: string; + /** Absolute base directory used during search. Used by the renderer to resolve + * display-relative paths to absolute paths for OSC 8 hyperlinks. */ + searchPath?: string; } export class AstGrepTool implements AgentTool { @@ -190,16 +193,17 @@ export class AstGrepTool implements AgentTool 0 ? { parseErrors: cappedParseErrors, parseErrorsTotal } : {}), - scopePath, - files: fileList, - fileMatches: [], - }; + const baseDetails: AstGrepToolDetails = { + matchCount: result.totalMatches, + fileCount: result.filesWithMatches, + filesSearched: result.filesSearched, + limitReached: result.limitReached, + ...(cappedParseErrors.length > 0 ? { parseErrors: cappedParseErrors, parseErrorsTotal } : {}), + scopePath, + searchPath: resolvedSearchPath, + files: fileList, + fileMatches: [], + }; if (result.matches.length === 0) { const noMatchMessage = cappedParseErrors.length @@ -363,29 +367,43 @@ export const astGrepToolRenderer = { ); } - return createCachedComponent( - () => options.expanded, - width => { - const matchLines = renderTreeList( - { - items: matchGroups, - expanded: options.expanded, - maxCollapsed: matchGroups.length, - maxCollapsedLines: COLLAPSED_MATCH_LIMIT, - itemType: "match", - renderItem: group => - group.map(line => { - if (line.startsWith("## ")) return uiTheme.fg("dim", line); - if (line.startsWith("# ")) return uiTheme.fg("accent", line); - if (line.startsWith(" meta:")) return uiTheme.fg("dim", line); - return uiTheme.fg("toolOutput", line); - }), + return createCachedComponent( + () => options.expanded, + width => { + const searchBase = details?.searchPath; + const matchLines = renderTreeList( + { + items: matchGroups, + expanded: options.expanded, + maxCollapsed: matchGroups.length, + maxCollapsedLines: COLLAPSED_MATCH_LIMIT, + itemType: "match", + renderItem: group => { + let contextDir = searchBase ?? ""; + return group.map(line => { + if (line.startsWith("# ")) { + const dirPart = line.slice(2).trimEnd().replace(/\/$/, ""); + if (searchBase) { + contextDir = dirPart === "." ? searchBase : path.join(searchBase, dirPart); + } + return uiTheme.fg("accent", line); + } + if (line.startsWith("## ")) { + const fileName = line.slice(3).trimEnd(); + const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined; + const styled = uiTheme.fg("dim", line); + return absPath ? fileHyperlink(absPath, styled) : styled; + } + if (line.startsWith(" meta:")) return uiTheme.fg("dim", line); + return uiTheme.fg("toolOutput", line); + }); }, - uiTheme, - ); - return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); - }, - ); + }, + uiTheme, + ); + return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); + }, + ); }, mergeCallAndResult: true, }; diff --git a/packages/coding-agent/src/tools/find.ts b/packages/coding-agent/src/tools/find.ts index bc4082aa8..1d80d2f5f 100644 --- a/packages/coding-agent/src/tools/find.ts +++ b/packages/coding-agent/src/tools/find.ts @@ -11,7 +11,7 @@ import { InternalUrlRouter } from "../internal-urls"; import type { Theme } from "../modes/theme/theme"; import findDescription from "../prompts/tools/find.md" with { type: "text" }; import { type TruncationResult, truncateHead } from "../session/streaming-output"; -import { Ellipsis, renderFileList, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; +import { Ellipsis, fileHyperlink, renderFileList, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import type { ToolSession } from "."; import { applyListLimit } from "./list-limit"; import { formatFullOutputReference, type OutputMeta } from "./output-meta"; @@ -84,6 +84,9 @@ export interface FindToolDetails { files?: string[]; truncated?: boolean; error?: string; + /** Working directory at search time. Used by the renderer to resolve relative + * file paths to absolute paths for OSC 8 hyperlinks. */ + cwd?: string; /** User-supplied paths whose base directory was missing on disk. The tool * skipped these and continued with the surviving entries; surfaced as a * non-fatal warning in the renderer and in the model-facing text. */ @@ -216,11 +219,12 @@ export class FindTool implements AgentTool { const notice = opts?.notice; const forceTruncated = opts?.forceTruncated ?? false; if (files.length === 0) { - const details: FindToolDetails = { + const details: FindToolDetails = { scopePath, fileCount: 0, files: [], truncated: forceTruncated, + cwd: this.session.cwd, missingPaths: missingPaths.length > 0 ? missingPaths : undefined, }; const parts = ["No files found matching pattern"]; @@ -246,6 +250,7 @@ export class FindTool implements AgentTool { truncated: Boolean(forceTruncated || limitMeta.resultLimit || truncation.truncated), resultLimitReached: limitMeta.resultLimit?.reached, truncation: truncation.truncated ? truncation : undefined, + cwd: this.session.cwd, missingPaths: missingPaths.length > 0 ? missingPaths : undefined, }; @@ -510,20 +515,26 @@ export const findToolRenderer = { } if (missingNote) extraLines.push(missingNote); - return createCachedComponent( - () => options.expanded, - width => { - const fileLines = renderFileList( - { - files: files.map(entry => ({ path: entry, isDirectory: entry.endsWith("/") })), - expanded: options.expanded, - maxCollapsed: COLLAPSED_LIST_LIMIT, - }, - uiTheme, - ); - return [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); - }, - ); + return createCachedComponent( + () => options.expanded, + width => { + const cwd = details?.cwd; + const fileLines = renderFileList( + { + files: files.map(entry => ({ + path: entry, + isDirectory: entry.endsWith("/"), + absPath: cwd && !entry.endsWith("/") ? path.resolve(cwd, entry) : undefined, + })), + expanded: options.expanded, + maxCollapsed: COLLAPSED_LIST_LIMIT, + hyperlinkFn: fileHyperlink, + }, + uiTheme, + ); + return [header, ...fileLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); + }, + ); }, mergeCallAndResult: true, }; diff --git a/packages/coding-agent/src/tools/read.ts b/packages/coding-agent/src/tools/read.ts index 9c8ff85c5..1090d93a4 100644 --- a/packages/coding-agent/src/tools/read.ts +++ b/packages/coding-agent/src/tools/read.ts @@ -27,7 +27,7 @@ import { truncateHeadBytes, truncateLine, } from "../session/streaming-output"; -import { renderCodeCell, renderMarkdownCell, renderStatusLine } from "../tui"; +import { fileHyperlink, renderCodeCell, renderMarkdownCell, renderStatusLine } from "../tui"; import { CachedOutputBlock } from "../tui/output-block"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import { ImageInputTooLargeError, loadImageInput, MAX_IMAGE_INPUT_BYTES } from "../utils/image-loading"; @@ -2261,7 +2261,11 @@ export const readToolRenderer = { } const suffix = details?.suffixResolution; - const displayPath = suffix ? shortenPath(suffix.to) : filePath; + const plainDisplayPath = suffix ? shortenPath(suffix.to) : filePath; + // resolvedPath is the absolute fs path for regular files and fs-backed internal URLs (local://, skill://, etc.) + const displayPath = details?.resolvedPath + ? fileHyperlink(details.resolvedPath, plainDisplayPath) + : plainDisplayPath; const correction = suffix ? ` ${uiTheme.fg("dim", `(corrected from ${shortenPath(suffix.from)})`)}` : ""; let title = displayPath ? `Read ${displayPath}${correction}` : "Read"; if (args?.offset !== undefined || args?.limit !== undefined) { diff --git a/packages/coding-agent/src/tools/search.ts b/packages/coding-agent/src/tools/search.ts index 6d412859d..9f959d10d 100644 --- a/packages/coding-agent/src/tools/search.ts +++ b/packages/coding-agent/src/tools/search.ts @@ -10,7 +10,7 @@ import type { RenderResultOptions } from "../extensibility/custom-tools/types"; import type { Theme } from "../modes/theme/theme"; import searchDescription from "../prompts/tools/search.md" with { type: "text" }; import { DEFAULT_MAX_COLUMN, type TruncationResult, truncateHead } from "../session/streaming-output"; -import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; +import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import { resolveFileDisplayMode } from "../utils/file-display-mode"; import type { ToolSession } from "."; import { createFileRecorder, formatResultPath } from "./file-recorder"; @@ -103,6 +103,9 @@ export interface SearchToolDetails { * `result.text` lines but uses a `│` gutter and `*` to mark match lines (vs space for * context). The TUI uses this directly so it never parses model-facing hashline anchors. */ displayContent?: string; + /** Absolute base directory used during search. Used by the renderer to resolve + * display-relative paths to absolute paths for OSC 8 hyperlinks. */ + searchPath?: string; /** User-supplied paths whose base directory was missing on disk. The tool * skipped these and continued with the surviving entries; surfaced as a * non-fatal warning in the renderer and in the model-facing text. */ @@ -317,8 +320,9 @@ export class SearchTool implements AgentTool 0 ? `Skipped missing paths: ${missingPaths.join(", ")}` : undefined; if (selectedMatches.length === 0) { - const details: SearchToolDetails = { + const details: SearchToolDetails = { scopePath, + searchPath, matchCount: 0, fileCount: 0, files: [], @@ -423,8 +427,9 @@ export class SearchTool implements AgentTool options.expanded, - width => { - const collapsedMatchLineBudget = Math.max(COLLAPSED_TEXT_LIMIT - extraLines.length, 0); - const matchLines = renderTreeList( - { - items: matchGroups, - expanded: options.expanded, - maxCollapsed: matchGroups.length, - maxCollapsedLines: collapsedMatchLineBudget, - itemType: "match", - renderItem: group => - group.map(line => { - if (line.startsWith("## ")) return uiTheme.fg("dim", line); - if (line.startsWith("# ")) return uiTheme.fg("accent", line); - return uiTheme.fg("toolOutput", line); - }), + return createCachedComponent( + () => options.expanded, + width => { + const collapsedMatchLineBudget = Math.max(COLLAPSED_TEXT_LIMIT - extraLines.length, 0); + const searchBase = details?.searchPath; + const matchLines = renderTreeList( + { + items: matchGroups, + expanded: options.expanded, + maxCollapsed: matchGroups.length, + maxCollapsedLines: collapsedMatchLineBudget, + itemType: "match", + renderItem: group => { + // Track directory context within a group for ## file headers. + let contextDir = searchBase ?? ""; + return group.map(line => { + if (line.startsWith("# ")) { + const dirPart = line.slice(2).trimEnd().replace(/\/$/, ""); + if (searchBase) { + contextDir = dirPart === "." ? searchBase : path.join(searchBase, dirPart); + } + return uiTheme.fg("accent", line); + } + if (line.startsWith("## ")) { + const fileName = line.slice(3).trimEnd(); + const absPath = contextDir && fileName ? path.join(contextDir, fileName) : undefined; + const styled = uiTheme.fg("dim", line); + return absPath ? fileHyperlink(absPath, styled) : styled; + } + return uiTheme.fg("toolOutput", line); + }); }, - uiTheme, - ); - return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); - }, - ); + }, + uiTheme, + ); + return [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit)); + }, + ); }, mergeCallAndResult: true, }; diff --git a/packages/coding-agent/src/tui/file-list.ts b/packages/coding-agent/src/tui/file-list.ts index 830295af0..be8adce54 100644 --- a/packages/coding-agent/src/tui/file-list.ts +++ b/packages/coding-agent/src/tui/file-list.ts @@ -7,6 +7,9 @@ import { renderTreeList } from "./tree-list"; export interface FileEntry { path: string; + /** Absolute filesystem path. When provided together with {@link FileListOptions.hyperlinkFn}, the + * rendered path text is wrapped in an OSC 8 hyperlink. */ + absPath?: string; isDirectory?: boolean; meta?: string; } @@ -16,10 +19,13 @@ export interface FileListOptions { expanded?: boolean; maxCollapsed?: number; showIcons?: boolean; + /** When provided, called with the entry's absolute path and the ANSI-styled display string to + * optionally wrap the path in an OSC 8 hyperlink. Only invoked when {@link FileEntry.absPath} is set. */ + hyperlinkFn?: (absPath: string, displayText: string) => string; } export function renderFileList(options: FileListOptions, theme: Theme): string[] { - const { files, expanded = false, maxCollapsed = 8, showIcons = true } = options; + const { files, expanded = false, maxCollapsed = 8, showIcons = true, hyperlinkFn } = options; return renderTreeList( { @@ -39,7 +45,10 @@ export function renderFileList(options: FileListOptions, theme: Theme): string[] const labelColor = isDirectory ? "accent" : "toolOutput"; const meta = entry.meta ? ` ${theme.fg("dim", entry.meta)}` : ""; const iconPrefix = icon ? `${icon} ` : ""; - return `${iconPrefix}${theme.fg(labelColor, displayPath)}${meta}`; + const pathStr = theme.fg(labelColor, displayPath); + const linkedPath = + entry.absPath && hyperlinkFn ? hyperlinkFn(entry.absPath, pathStr) : pathStr; + return `${iconPrefix}${linkedPath}${meta}`; }, }, theme, diff --git a/packages/coding-agent/src/tui/hyperlink.ts b/packages/coding-agent/src/tui/hyperlink.ts new file mode 100644 index 000000000..1ab6e80f3 --- /dev/null +++ b/packages/coding-agent/src/tui/hyperlink.ts @@ -0,0 +1,84 @@ +/** + * OSC 8 terminal hyperlink support for file paths. + * + * Wraps display text in `ESC ] 8 ; id=HASH ; URI ESC \ TEXT ESC ] 8 ; ; ESC \` + * sequences when the active terminal supports hyperlinks and the user setting + * permits it. Falls back to plain text when disabled. + */ +import { TERMINAL } from "@oh-my-pi/pi-tui"; +import { settings } from "../config/settings"; + +const OSC = "\x1b]"; +const ST = "\x1b\\"; + +/** Stable 8-char hex ID derived from a URI — hints terminals to coalesce identical adjacent links. */ +function buildLinkId(uri: string): string { + let h = 0; + for (let i = 0; i < uri.length; i++) { + // FNV-1a-inspired mix — good enough for a UI hint, no deps + h = (Math.imul(31, h) + uri.charCodeAt(i)) | 0; + } + return (h >>> 0).toString(16).padStart(8, "0"); +} + +/** Build a `file://` URI for an absolute path with optional line/col query params. */ +function buildFileUri(absPath: string, opts?: { line?: number; col?: number }): string { + // Normalize backslashes for Windows paths before constructing the URL. + const normalized = absPath.replaceAll("\\", "/"); + const prefix = normalized.startsWith("/") ? "file://" : "file:///"; + // Split on slashes, encode each component, reassemble. + const encoded = normalized + .split("/") + .map(segment => encodeURIComponent(segment)) + .join("/"); + const params: string[] = []; + if (opts?.line !== undefined) params.push(`line=${opts.line}`); + if (opts?.col !== undefined) params.push(`col=${opts.col}`); + const query = params.length > 0 ? `?${params.join("&")}` : ""; + return `${prefix}${encoded}${query}`; +} + +/** + * Returns true when OSC 8 hyperlinks should be emitted. + * + * Respects `tui.hyperlinks` setting: + * - `"off"`: never + * - `"auto"`: when `process.stdout.isTTY`, `NO_COLOR` is unset, and the detected terminal reports hyperlink support + * - `"always"`: unconditionally (useful for viewers that support OSC 8 without advertising it) + */ +export function isHyperlinkEnabled(): boolean { + const mode = settings.get("tui.hyperlinks"); + if (mode === "off") return false; + if (mode === "always") return true; + // auto: respect terminal capabilities and NO_COLOR + if (Bun.env.NO_COLOR) return false; + if (!process.stdout.isTTY) return false; + return TERMINAL.hyperlinks; +} + +/** + * Wrap `displayText` in an OSC 8 hyperlink pointing at the given absolute file path. + * + * Returns `displayText` unchanged when hyperlinks are disabled or when + * the text already contains an OSC 8 sequence (prevents double-wrapping). + * + * The caller is responsible for passing an absolute path. Relative paths + * produce invalid `file://` URIs and are accepted silently to avoid runtime + * errors in renderer hot paths. + * + * @param absPath - Absolute filesystem path + * @param displayText - Text to render as the hyperlink anchor (may contain ANSI codes) + * @param opts - Optional line/col position appended as `?line=N&col=M` query params + */ +export function fileHyperlink( + absPath: string, + displayText: string, + opts?: { line?: number; col?: number }, +): string { + if (!isHyperlinkEnabled()) return displayText; + // Do not double-wrap if the text already embeds an OSC 8 sequence. + if (displayText.includes("\x1b]8;")) return displayText; + const uri = buildFileUri(absPath, opts); + const id = buildLinkId(uri); + return `${OSC}8;id=${id};${uri}${ST}${displayText}${OSC}8;;${ST}`; +} diff --git a/packages/coding-agent/src/tui/index.ts b/packages/coding-agent/src/tui/index.ts index 75b76f462..3f02c16e9 100644 --- a/packages/coding-agent/src/tui/index.ts +++ b/packages/coding-agent/src/tui/index.ts @@ -9,3 +9,4 @@ export * from "./status-line"; export * from "./tree-list"; export * from "./types"; export * from "./utils"; +export * from "./hyperlink"; diff --git a/packages/coding-agent/test/tui/hyperlink.test.ts b/packages/coding-agent/test/tui/hyperlink.test.ts new file mode 100644 index 000000000..1d9cdaf78 --- /dev/null +++ b/packages/coding-agent/test/tui/hyperlink.test.ts @@ -0,0 +1,181 @@ +import { afterEach, describe, expect, it, spyOn } from "bun:test"; +import * as settingsModule from "@oh-my-pi/pi-coding-agent/config/settings"; +import * as terminalCaps from "@oh-my-pi/pi-tui"; +import { fileHyperlink, isHyperlinkEnabled } from "@oh-my-pi/pi-coding-agent/tui/hyperlink"; + +// OSC 8 sequence markers +const OSC = "\x1b]"; +const ST = "\x1b\\"; +const LINK_START = (id: string, uri: string) => `${OSC}8;id=${id};${uri}${ST}`; +const LINK_END = `${OSC}8;;${ST}`; + +/** Extract the hyperlink URI from a wrapped string. Returns undefined if not wrapped. */ +function extractLinkUri(text: string): string | undefined { + const match = text.match(/\x1b\]8;[^;]*;([^\x1b]+)\x1b\\/); + return match?.[1]; +} + +/** Returns true if the string contains an OSC 8 hyperlink wrapping a given display text. */ +function isHyperlinked(text: string): boolean { + return text.includes(`${OSC}8;`) && text.includes(LINK_END); +} + +describe("isHyperlinkEnabled", () => { + afterEach(() => { + delete Bun.env.NO_COLOR; + }); + + it('returns false when mode is "off"', () => { + const spy = spyOn(settingsModule.settings, "get").mockImplementation( + (key: string) => (key === "tui.hyperlinks" ? "off" : undefined) as never, + ); + expect(isHyperlinkEnabled()).toBe(false); + spy.mockRestore(); + }); + + it('returns true when mode is "always" regardless of TTY', () => { + const spy = spyOn(settingsModule.settings, "get").mockImplementation( + (key: string) => (key === "tui.hyperlinks" ? "always" : undefined) as never, + ); + expect(isHyperlinkEnabled()).toBe(true); + spy.mockRestore(); + }); + + it('returns false in auto mode when NO_COLOR is set', () => { + const spy = spyOn(settingsModule.settings, "get").mockImplementation( + (key: string) => (key === "tui.hyperlinks" ? "auto" : undefined) as never, + ); + Bun.env.NO_COLOR = "1"; + expect(isHyperlinkEnabled()).toBe(false); + spy.mockRestore(); + }); + + it('returns false in auto mode when stdout is not a TTY', () => { + const spy = spyOn(settingsModule.settings, "get").mockImplementation( + (key: string) => (key === "tui.hyperlinks" ? "auto" : undefined) as never, + ); + const origTTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + Object.defineProperty(process.stdout, "isTTY", { value: false, configurable: true }); + expect(isHyperlinkEnabled()).toBe(false); + if (origTTY) Object.defineProperty(process.stdout, "isTTY", origTTY); + spy.mockRestore(); + }); + + it("returns TERMINAL.hyperlinks value in auto mode when conditions are met", () => { + const settingsSpy = spyOn(settingsModule.settings, "get").mockImplementation( + (key: string) => (key === "tui.hyperlinks" ? "auto" : undefined) as never, + ); + const origTTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + // TERMINAL.hyperlinks may be true or false depending on the test runner env; + // what matters is that isHyperlinkEnabled mirrors it. + const expected = terminalCaps.TERMINAL.hyperlinks; + expect(isHyperlinkEnabled()).toBe(expected); + if (origTTY) Object.defineProperty(process.stdout, "isTTY", origTTY); + settingsSpy.mockRestore(); + }); +}); + +describe("fileHyperlink", () => { + afterEach(() => { + delete Bun.env.NO_COLOR; + }); + + it("returns plain text when hyperlinks are disabled (mode=off)", () => { + const spy = spyOn(settingsModule.settings, "get").mockImplementation( + (key: string) => (key === "tui.hyperlinks" ? "off" : undefined) as never, + ); + const result = fileHyperlink("/Users/foo/bar.ts", "bar.ts"); + expect(result).toBe("bar.ts"); + spy.mockRestore(); + }); + + it("wraps text in OSC 8 when hyperlinks are enabled (mode=always)", () => { + const spy = spyOn(settingsModule.settings, "get").mockImplementation( + (key: string) => (key === "tui.hyperlinks" ? "always" : undefined) as never, + ); + const result = fileHyperlink("/Users/foo/bar.ts", "bar.ts"); + expect(isHyperlinked(result)).toBe(true); + expect(result).toContain("bar.ts"); + spy.mockRestore(); + }); + + it("builds a valid file:// URI with the absolute path", () => { + const spy = spyOn(settingsModule.settings, "get").mockImplementation( + (key: string) => (key === "tui.hyperlinks" ? "always" : undefined) as never, + ); + const result = fileHyperlink("/Users/foo/bar.ts", "bar.ts"); + const uri = extractLinkUri(result); + expect(uri).toMatch(/^file:\/\//); + expect(uri).toContain("bar.ts"); + spy.mockRestore(); + }); + + it("encodes spaces in the path", () => { + const spy = spyOn(settingsModule.settings, "get").mockImplementation( + (key: string) => (key === "tui.hyperlinks" ? "always" : undefined) as never, + ); + const result = fileHyperlink("/Users/foo/my file.ts", "my file.ts"); + const uri = extractLinkUri(result); + expect(uri).toContain("%20"); + expect(uri).not.toContain(" "); + spy.mockRestore(); + }); + + it("appends line and col as query params when provided", () => { + const spy = spyOn(settingsModule.settings, "get").mockImplementation( + (key: string) => (key === "tui.hyperlinks" ? "always" : undefined) as never, + ); + const result = fileHyperlink("/Users/foo/bar.ts", "bar.ts", { line: 42, col: 7 }); + const uri = extractLinkUri(result); + expect(uri).toContain("line=42"); + expect(uri).toContain("col=7"); + spy.mockRestore(); + }); + + it("omits query params when line/col are not provided", () => { + const spy = spyOn(settingsModule.settings, "get").mockImplementation( + (key: string) => (key === "tui.hyperlinks" ? "always" : undefined) as never, + ); + const result = fileHyperlink("/Users/foo/bar.ts", "bar.ts"); + const uri = extractLinkUri(result); + expect(uri).not.toContain("?"); + spy.mockRestore(); + }); + + it("produces a stable id for the same path", () => { + const spy = spyOn(settingsModule.settings, "get").mockImplementation( + (key: string) => (key === "tui.hyperlinks" ? "always" : undefined) as never, + ); + const r1 = fileHyperlink("/Users/foo/bar.ts", "bar.ts"); + const r2 = fileHyperlink("/Users/foo/bar.ts", "different display text"); + // Extract id= from params (between "id=" and next ";") + const id1 = r1.match(/id=([^;]+)/)?.[1]; + const id2 = r2.match(/id=([^;]+)/)?.[1]; + expect(id1).toBeDefined(); + expect(id1).toBe(id2); + spy.mockRestore(); + }); + + it("does not double-wrap text that already contains an OSC 8 sequence", () => { + const spy = spyOn(settingsModule.settings, "get").mockImplementation( + (key: string) => (key === "tui.hyperlinks" ? "always" : undefined) as never, + ); + const alreadyWrapped = `${OSC}8;id=abc123;file:///foo/bar.ts${ST}bar.ts${LINK_END}`; + const result = fileHyperlink("/Users/foo/other.ts", alreadyWrapped); + // Should return the already-wrapped text unchanged + expect(result).toBe(alreadyWrapped); + spy.mockRestore(); + }); + + it("preserves ANSI color codes inside the hyperlink", () => { + const spy = spyOn(settingsModule.settings, "get").mockImplementation( + (key: string) => (key === "tui.hyperlinks" ? "always" : undefined) as never, + ); + const colored = "\x1b[32mbar.ts\x1b[0m"; + const result = fileHyperlink("/Users/foo/bar.ts", colored); + expect(result).toContain(colored); + expect(isHyperlinked(result)).toBe(true); + spy.mockRestore(); + }); +});