File size: 5,060 Bytes
e249c6d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 | /**
* Rendering helpers for session tool output in the TUI.
*
* Normalizes paths/text/image fallbacks before tool results are styled or truncated.
*/
import * as os from "node:os";
import {
type Component,
getCapabilities,
getImageDimensions,
imageFallback,
Text,
} from "@earendil-works/pi-tui";
import { shortenPathWithHome } from "../../../infra/home-display.js";
import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
import type { Theme } from "../../modes/interactive/theme/theme.js";
import { sanitizeBinaryOutput } from "../../shell-utils.js";
import type { ToolRenderResultOptions } from "../extensions/types.js";
import { DEFAULT_MAX_BYTES, formatSize, type TruncationResult } from "./truncate.js";
/** Shortens paths under the current home directory for display. */
export function shortenPath(path: unknown): string {
if (typeof path !== "string") {
return "";
}
return shortenPathWithHome(path, { home: os.homedir(), prefix: "~" });
}
/** Returns a display string for string/nullish values, or null for unsupported values. */
export function str(value: unknown): string | null {
if (typeof value === "string") {
return value;
}
if (value == null) {
return "";
}
return null;
}
/** Replaces tabs with stable spaces so terminal layout does not shift by tab stop. */
export function replaceTabs(text: string): string {
return text.replace(/\t/g, " ");
}
/** Normalizes raw terminal output before display. */
export function normalizeDisplayText(text: string): string {
return text.replace(/\r/g, "");
}
export function trimTrailingEmptyLines(lines: readonly string[]): string[] {
return lines.slice(0, lines.findLastIndex((line) => line !== "") + 1);
}
export function reuseTextComponent(lastComponent: Component | undefined, content: string): Text {
const text = (lastComponent as Text | undefined) ?? new Text("", 0, 0); // SAFETY: Render slots only reuse the component returned by this renderer.
text.setText(content);
return text;
}
/** Extracts text output and image placeholders from a tool result. */
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(c.text || "", { ansiMode: "compat" }).replace(/\r/g, ""))
.join("\n");
const caps = getCapabilities();
if (imageBlocks.length > 0 && (!caps.images || !showImages)) {
// When inline images are unavailable, preserve visible evidence that media was returned.
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;
}
/** Renders bounded text output with the shared TUI expansion hint. */
export function formatSessionToolOutput(
result: { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> },
options: ToolRenderResultOptions,
theme: Pick<Theme, "fg">,
showImages: boolean,
collapsedLineLimit: number,
): string {
const output = getTextOutput(result, showImages).trim();
if (!output) {
return "";
}
const lines = output.split("\n");
const maxLines = options.expanded ? lines.length : collapsedLineLimit;
const displayLines = lines.slice(0, maxLines);
const remaining = lines.length - maxLines;
let text = `\n${displayLines.map((line) => theme.fg("toolOutput", line)).join("\n")}`;
if (remaining > 0) {
text += `${theme.fg("muted", `\n... (${remaining} more lines,`)} ${keyHint("app.tools.expand", "to expand")})`;
}
return text;
}
export function appendSessionToolTruncationWarning(
text: string,
theme: Pick<Theme, "fg">,
options: {
limit?: { count: number; noun: string };
truncation?: Pick<TruncationResult, "truncated" | "maxBytes">;
additionalWarnings?: readonly string[];
},
): string {
const warnings: string[] = [];
if (options.limit) {
warnings.push(`${options.limit.count} ${options.limit.noun} limit`);
}
if (options.truncation?.truncated) {
warnings.push(`${formatSize(options.truncation.maxBytes ?? DEFAULT_MAX_BYTES)} limit`);
}
warnings.push(...(options.additionalWarnings ?? []));
if (warnings.length === 0) {
return text;
}
return `${text}\n${theme.fg("warning", `[Truncated: ${warnings.join(", ")}]`)}`;
}
/** Formats the invalid-argument marker with the active theme. */
export function invalidArgText(theme: Pick<Theme, "fg">): string {
return theme.fg("error", "[invalid arg]");
}
|