Files
oh-my-pi/packages/coding-agent/src/tui/hyperlink.ts
T

131 lines
4.7 KiB
TypeScript

/**
* 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";
import {
LocalProtocolHandler,
memoryRootsFromRegistry,
parseInternalUrl,
resolveLocalUrlToPath,
resolveMemoryUrlToPath,
} from "../internal-urls";
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}`;
}
/**
* Synchronously resolve a filesystem-backed internal URL (e.g. `local://foo.md`,
* `memory://root/notes.md`) to its absolute filesystem path. Returns `undefined`
* for inputs that aren't fs-backed, aren't resolvable in the current session
* registry, or fail to parse.
*
* Used by renderers to wrap fs-backed internal URLs in OSC 8 hyperlinks even
* when the resolved path isn't yet available from tool result details (e.g.
* during the call/streaming phase before a result lands).
*
* Async-resolved schemes (`artifact://`, `agent://`, `skill://`, `rule://`,
* `omp://`) are not handled here — those rely on `details.resolvedPath` set
* by the read tool's router resolution.
*/
export function tryResolveInternalUrlSync(input: string): string | undefined {
try {
if (input.startsWith("local://")) {
const opts = LocalProtocolHandler.resolveOptions();
if (!opts) return undefined;
return resolveLocalUrlToPath(input, opts);
}
if (input.startsWith("memory://")) {
const url = parseInternalUrl(input);
const roots = memoryRootsFromRegistry();
for (const root of roots) {
try {
return resolveMemoryUrlToPath(url, root);
} catch {
// Try the next root; some sessions may not have this namespace mounted.
}
}
return undefined;
}
} catch {
return undefined;
}
return undefined;
}