mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
42ce989a62
Wrap the path shown in read/write/edit/ls tool titles in an OSC 8 file:// hyperlink when the terminal advertises hyperlink support. The link target is always the absolute path, while the visible text stays relative/home-shortened and line annotations (e.g. :120-140) are kept out of the target.
86 lines
2.7 KiB
TypeScript
86 lines
2.7 KiB
TypeScript
import * as os from "node:os";
|
|
import { pathToFileURL } from "node:url";
|
|
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
|
import { getCapabilities, getImageDimensions, hyperlink, imageFallback } from "@earendil-works/pi-tui";
|
|
import type { Theme } from "../../modes/interactive/theme/theme.ts";
|
|
import { stripAnsi } from "../../utils/ansi.ts";
|
|
import { resolvePath } from "../../utils/paths.ts";
|
|
import { sanitizeBinaryOutput } from "../../utils/shell.ts";
|
|
|
|
export function shortenPath(path: unknown): string {
|
|
if (typeof path !== "string") return "";
|
|
const home = os.homedir();
|
|
if (path.startsWith(home)) {
|
|
return `~${path.slice(home.length)}`;
|
|
}
|
|
return path;
|
|
}
|
|
|
|
export function linkPath(styledText: string, rawPath: string, cwd: string): string {
|
|
if (!getCapabilities().hyperlinks) return styledText;
|
|
const absolutePath = resolvePath(rawPath, cwd);
|
|
return hyperlink(styledText, pathToFileURL(absolutePath).href);
|
|
}
|
|
|
|
export function str(value: unknown): string | null {
|
|
if (typeof value === "string") return value;
|
|
if (value == null) return "";
|
|
return null;
|
|
}
|
|
|
|
export function replaceTabs(text: string): string {
|
|
return text.replace(/\t/g, " ");
|
|
}
|
|
|
|
export function normalizeDisplayText(text: string): string {
|
|
return text.replace(/\r/g, "");
|
|
}
|
|
|
|
export function getTextOutput(
|
|
result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> } | undefined,
|
|
showImages: boolean,
|
|
): string {
|
|
if (!result) return "";
|
|
|
|
const textBlocks = result.content.filter((c) => c.type === "text");
|
|
const imageBlocks = result.content.filter((c) => c.type === "image");
|
|
|
|
let output = textBlocks.map((c) => sanitizeBinaryOutput(stripAnsi(c.text || "")).replace(/\r/g, "")).join("\n");
|
|
|
|
const caps = getCapabilities();
|
|
if (imageBlocks.length > 0 && (!caps.images || !showImages)) {
|
|
const imageIndicators = imageBlocks
|
|
.map((img) => {
|
|
const mimeType = img.mimeType ?? "image/unknown";
|
|
const dims =
|
|
img.data && img.mimeType ? (getImageDimensions(img.data, img.mimeType) ?? undefined) : undefined;
|
|
return imageFallback(mimeType, dims);
|
|
})
|
|
.join("\n");
|
|
output = output ? `${output}\n${imageIndicators}` : imageIndicators;
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
export type ToolRenderResultLike<TDetails> = {
|
|
content: (TextContent | ImageContent)[];
|
|
details: TDetails;
|
|
};
|
|
|
|
export function invalidArgText(theme: Theme): string {
|
|
return theme.fg("error", "[invalid arg]");
|
|
}
|
|
|
|
export function renderToolPath(
|
|
rawPath: string | null,
|
|
theme: Theme,
|
|
cwd: string,
|
|
options?: { emptyFallback?: string },
|
|
): string {
|
|
if (rawPath === null) return invalidArgText(theme);
|
|
const value = rawPath || options?.emptyFallback;
|
|
if (!value) return theme.fg("toolOutput", "...");
|
|
return linkPath(theme.fg("accent", shortenPath(value)), value, cwd);
|
|
}
|