Files
oh-my-pi/packages/coding-agent/src/tools/render-utils.ts
T
can1357 9b78a6fa85 refactor(natives): migrated native bindings to NAPI-RS with auto-generated types
- Migrated native bindings from TypeScript wrappers to NAPI-RS generated modules with auto-generated type definitions and runtime enums.
- Replaced chunk tree API with stateful ChunkState class supporting render, edit, and resolve operations with improved error handling.
- Converted callback signatures to error-first pattern (error, result) for shell, PTY, glob, and grep operations.
- Introduced type-safe enums for MacOSAppearance, GrepOutputMode, KeyEventType, ImageFormat, and AstMatchStrictness replacing string literals.
- Refactored chunk tree implementation with dedicated modules for edit, indent, resolve, and state management with comprehensive validation.
- Moved clipboard utilities from native bindings to coding-agent package with improved OSC 52 and Termux compatibility.
2026-04-07 02:00:59 +02:00

604 lines
20 KiB
TypeScript

/**
* Shared utilities and constants for tool renderers.
*
* Provides consistent formatting, truncation, and display patterns across all
* tool renderers to ensure a unified TUI experience.
*/
import * as os from "node:os";
import type { Ellipsis } from "@oh-my-pi/pi-natives";
import { truncateToWidth } from "@oh-my-pi/pi-tui";
import { pluralize } from "@oh-my-pi/pi-utils";
import { settings } from "../config/settings";
import type { Theme } from "../modes/theme/theme";
import { formatDimensionNote, type ResizedImage } from "../utils/image-resize";
export { Ellipsis } from "@oh-my-pi/pi-natives";
export { replaceTabs, truncateToWidth } from "@oh-my-pi/pi-tui";
// =============================================================================
// Standardized Display Constants
// =============================================================================
/** Resolve inline image dimension caps from settings and viewport. */
export function resolveImageOptions(): { maxWidthCells: number; maxHeightCells?: number } {
const maxWidthCells = settings.get("tui.maxInlineImageColumns");
const rowSetting = Math.max(0, settings.get("tui.maxInlineImageRows"));
const viewportRows = process.stdout.rows;
const viewportFraction = viewportRows ? Math.floor(viewportRows * 0.6) : 0;
let maxHeightCells: number | undefined;
if (rowSetting === 0) {
// No explicit cap — use viewport fraction as safety bound
maxHeightCells = viewportFraction || undefined;
} else if (viewportFraction > 0) {
maxHeightCells = Math.min(rowSetting, viewportFraction);
} else {
// Viewport size unknown (transitional state) — honor explicit setting
maxHeightCells = rowSetting;
}
return { maxWidthCells, maxHeightCells };
}
/** Preview limits for collapsed/expanded views */
export const PREVIEW_LIMITS = {
/** Lines shown in collapsed view */
COLLAPSED_LINES: 3,
/** Lines shown in expanded view */
EXPANDED_LINES: 12,
/** Items (files, results) shown in collapsed view */
COLLAPSED_ITEMS: 8,
/** Output preview lines in collapsed view */
OUTPUT_COLLAPSED: 3,
/** Output preview lines in expanded view */
OUTPUT_EXPANDED: 10,
/** Max hunks shown when collapsed (edit tool) */
DIFF_COLLAPSED_HUNKS: 8,
/** Max diff lines shown when collapsed (edit tool) */
DIFF_COLLAPSED_LINES: 40,
} as const;
/** Truncation lengths for different content types */
export const TRUNCATE_LENGTHS = {
/** Short titles, labels */
TITLE: 60,
/** Medium-length content (messages, previews) */
CONTENT: 80,
/** Longer content (code, explanations) */
LONG: 100,
/** Full line content */
LINE: 110,
/** Very short (task previews, badges) */
SHORT: 40,
} as const;
/** Standard expand hint text */
export const EXPAND_HINT = "(Ctrl+O for more)";
// =============================================================================
// Text Truncation Utilities
// =============================================================================
/**
* Get first N lines of text as preview, with each line truncated.
*/
export function getPreviewLines(text: string, maxLines: number, maxLineLen: number, ellipsis?: Ellipsis): string[] {
const lines = text.split("\n").filter(l => l.trim());
return lines.slice(0, maxLines).map(l => truncateToWidth(l.trim(), maxLineLen, ellipsis));
}
// =============================================================================
// URL Utilities
// =============================================================================
/**
* Extract domain from URL, stripping www. prefix.
*/
export function getDomain(url: string): string {
try {
const u = new URL(url);
return u.hostname.replace(/^www\./, "");
} catch {
return url;
}
}
// =============================================================================
// Formatting Utilities
// =============================================================================
export { formatAge, formatBytes, formatCount, formatDuration, pluralize } from "@oh-my-pi/pi-utils";
// =============================================================================
// Theme Helper Utilities
// =============================================================================
/**
* Get the appropriate status icon with color for a given state.
* Standardizes status icon usage across all renderers.
*/
export function formatStatusIcon(status: ToolUIStatus, theme: Theme, spinnerFrame?: number): string {
switch (status) {
case "success":
return theme.styledSymbol("status.success", "success");
case "error":
return theme.styledSymbol("status.error", "error");
case "warning":
return theme.styledSymbol("status.warning", "warning");
case "info":
return theme.styledSymbol("status.info", "accent");
case "pending":
return theme.styledSymbol("status.pending", "muted");
case "running":
if (spinnerFrame !== undefined) {
const frames = theme.spinnerFrames;
return frames[spinnerFrame % frames.length];
}
return theme.styledSymbol("status.running", "accent");
case "aborted":
return theme.styledSymbol("status.aborted", "error");
}
}
/**
* Format the expand hint with proper theming.
* Returns empty string if already expanded or there is nothing more to show.
*/
export function formatExpandHint(theme: Theme, expanded?: boolean, hasMore?: boolean): string {
if (expanded) return "";
if (hasMore === false) return "";
return theme.fg("dim", wrapBrackets(EXPAND_HINT, theme));
}
/**
* Format a badge like [done] or [failed] with brackets and color.
*/
export function formatBadge(label: string, color: ToolUIColor, theme: Theme): string {
const left = theme.format.bracketLeft;
const right = theme.format.bracketRight;
return theme.fg(color, `${left}${label}${right}`);
}
/**
* Build a "more items" suffix line for truncated lists.
* Uses consistent wording pattern.
*/
export function formatMoreItems(remaining: number, itemType: string): string {
const safeRemaining = Number.isFinite(remaining) ? remaining : 0;
return `… ${safeRemaining} more ${pluralize(itemType, safeRemaining)}`;
}
export function formatMeta(meta: string[], theme: Theme): string {
return meta.length > 0 ? ` ${theme.fg("muted", meta.join(theme.sep.dot))}` : "";
}
export function formatErrorMessage(message: string | undefined, theme: Theme): string {
const clean = (message ?? "").replace(/^Error:\s*/, "").trim();
return `${theme.styledSymbol("status.error", "error")} ${theme.fg("error", `Error: ${clean || "Unknown error"}`)}`;
}
export function formatEmptyMessage(message: string, theme: Theme): string {
return `${theme.styledSymbol("status.warning", "warning")} ${theme.fg("muted", message)}`;
}
// =============================================================================
// Tool UI Helpers
// =============================================================================
export type ToolUIStatus = "success" | "error" | "warning" | "info" | "pending" | "running" | "aborted";
export type ToolUIColor = "success" | "error" | "warning" | "accent" | "muted";
export interface ToolUITitleOptions {
bold?: boolean;
}
export function formatTitle(label: string, theme: Theme, options?: ToolUITitleOptions): string {
const content = options?.bold === false ? label : theme.bold(label);
return theme.fg("toolTitle", content);
}
// =============================================================================
// Diagnostic Formatting
// =============================================================================
interface ParsedDiagnostic {
filePath: string;
line: number;
col: number;
severity: "error" | "warning" | "info" | "hint";
source?: string;
message: string;
code?: string;
}
function getSeverityRank(severity: ParsedDiagnostic["severity"]): number {
switch (severity) {
case "error":
return 0;
case "warning":
return 1;
case "info":
return 2;
case "hint":
return 3;
}
}
function parseDiagnosticMessage(msg: string): ParsedDiagnostic | null {
const match = msg.match(/^(.+?):(\d+):(\d+)\s+\[(\w+)\]\s+(?:\[([^\]]+)\]\s+)?(.+?)(?:\s+\(([^)]+)\))?$/);
if (!match) return null;
return {
filePath: match[1],
line: parseInt(match[2], 10),
col: parseInt(match[3], 10),
severity: match[4] as ParsedDiagnostic["severity"],
source: match[5],
message: match[6],
code: match[7],
};
}
export function formatDiagnostics(
diag: { errored: boolean; summary: string; messages: string[] },
expanded: boolean,
theme: Theme,
getLangIcon: (filePath: string) => string,
): string {
if (diag.messages.length === 0) return "";
const byFile = new Map<string, ParsedDiagnostic[]>();
const unparsed: string[] = [];
for (const msg of diag.messages) {
const parsed = parseDiagnosticMessage(msg);
if (parsed) {
const existing = byFile.get(parsed.filePath) ?? [];
existing.push(parsed);
byFile.set(parsed.filePath, existing);
} else {
unparsed.push(msg);
}
}
for (const diagnostics of byFile.values()) {
diagnostics.sort((a, b) => {
const severityCompare = getSeverityRank(a.severity) - getSeverityRank(b.severity);
if (severityCompare !== 0) return severityCompare;
if (a.line !== b.line) return a.line - b.line;
if (a.col !== b.col) return a.col - b.col;
return a.message.localeCompare(b.message);
});
}
const headerIcon = diag.errored
? theme.styledSymbol("status.error", "error")
: theme.styledSymbol("status.warning", "warning");
let output = `\n\n${headerIcon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", `(${diag.summary})`)}`;
const maxDiags = expanded ? diag.messages.length : 5;
let diagsShown = 0;
const files = Array.from(byFile.entries());
// Count total diagnostics for "... X more" calculation
const totalParsedDiags = files.reduce((sum, [, diags]) => sum + diags.length, 0);
const totalDiags = totalParsedDiags + unparsed.length;
// Helper to check if this is the very last item in the tree
const isTreeEnd = (fileIdx: number, diagIdx: number | null, unparsedIdx: number | null): boolean => {
const willShowMore = totalDiags > diagsShown + 1;
if (willShowMore) return false;
if (unparsedIdx !== null) {
return unparsedIdx === unparsed.length - 1;
}
if (diagIdx !== null) {
const isLastDiagInFile = diagIdx === files[fileIdx][1].length - 1;
const isLastFile = fileIdx === files.length - 1;
return isLastDiagInFile && isLastFile && unparsed.length === 0;
}
// File node - never the tree end if it has diagnostics
return false;
};
for (let fi = 0; fi < files.length && diagsShown < maxDiags; fi++) {
const [filePath, diagnostics] = files[fi];
// File is "last" only if no more files AND no unparsed AND we'll show all diags AND no "... X more"
const remainingDiagsInFile = diagnostics.length;
const remainingDiagsAfter = files.slice(fi + 1).reduce((sum, [, d]) => sum + d.length, 0) + unparsed.length;
const willShowAllRemaining = diagsShown + remainingDiagsInFile + remainingDiagsAfter <= maxDiags;
const isLastFileNode = fi === files.length - 1 && unparsed.length === 0 && willShowAllRemaining;
const fileBranch = isLastFileNode ? theme.tree.last : theme.tree.branch;
const fileIcon = theme.fg("muted", getLangIcon(filePath));
output += `\n ${theme.fg("dim", fileBranch)} ${fileIcon} ${theme.fg("accent", filePath)}`;
for (let di = 0; di < diagnostics.length && diagsShown < maxDiags; di++) {
const d = diagnostics[di];
const isLastDiagInFile = di === diagnostics.length - 1;
// This is the last visible diag in file if it's actually last OR we're about to hit the limit
const atDisplayLimit = diagsShown + 1 >= maxDiags;
const isLastVisibleInFile = isLastDiagInFile || atDisplayLimit;
// Check if this is the last visible item in the entire tree
const isVeryLast = isTreeEnd(fi, di, null);
const diagBranch = isLastFileNode
? isLastVisibleInFile || isVeryLast
? ` ${theme.tree.last}`
: ` ${theme.tree.branch}`
: isLastVisibleInFile || isVeryLast
? `${theme.tree.vertical} ${theme.tree.last}`
: `${theme.tree.vertical} ${theme.tree.branch}`;
const sevIcon =
d.severity === "error"
? theme.styledSymbol("status.error", "error")
: d.severity === "warning"
? theme.styledSymbol("status.warning", "warning")
: theme.styledSymbol("status.info", "muted");
const location = theme.fg("dim", `:${d.line}:${d.col}`);
const codeTag = d.code ? theme.fg("dim", ` (${d.code})`) : "";
const msgColor = d.severity === "error" ? "error" : d.severity === "warning" ? "warning" : "toolOutput";
output += `\n ${theme.fg("dim", diagBranch)} ${sevIcon}${location} ${theme.fg(msgColor, d.message)}${codeTag}`;
diagsShown++;
}
}
for (let ui = 0; ui < unparsed.length && diagsShown < maxDiags; ui++) {
const msg = unparsed[ui];
const isVeryLast = isTreeEnd(-1, null, ui);
const branch = isVeryLast ? theme.tree.last : theme.tree.branch;
const color = msg.includes("[error]") ? "error" : msg.includes("[warning]") ? "warning" : "dim";
output += `\n ${theme.fg("dim", branch)} ${theme.fg(color, msg)}`;
diagsShown++;
}
if (totalDiags > diagsShown) {
const remaining = totalDiags - diagsShown;
output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg(
"muted",
`… ${remaining} more`,
)} ${formatExpandHint(theme)}`;
}
return output;
}
// =============================================================================
// Diff Utilities
// =============================================================================
export interface DiffStats {
added: number;
removed: number;
hunks: number;
lines: number;
}
export function getDiffStats(diffText: string): DiffStats {
const lines = diffText ? diffText.split("\n") : [];
let added = 0;
let removed = 0;
let hunks = 0;
let inHunk = false;
for (const line of lines) {
const isAdded = line.startsWith("+");
const isRemoved = line.startsWith("-");
const isChange = isAdded || isRemoved;
if (isAdded) added++;
if (isRemoved) removed++;
if (isChange && !inHunk) {
hunks++;
inHunk = true;
} else if (!isChange) {
inHunk = false;
}
}
return { added, removed, hunks, lines: lines.length };
}
export function formatDiffStats(added: number, removed: number, hunks: number, theme: Theme): string {
const parts: string[] = [];
if (added > 0) parts.push(theme.fg("toolDiffAdded", `+${added}`));
if (removed > 0) parts.push(theme.fg("toolDiffRemoved", `-${removed}`));
if (hunks > 0) parts.push(theme.fg("dim", `${hunks} hunk${hunks !== 1 ? "s" : ""}`));
return parts.join(theme.fg("dim", " / "));
}
interface DiffSegment {
lines: string[];
isChange: boolean;
isEllipsis: boolean;
}
function parseDiffSegments(lines: string[]): DiffSegment[] {
const segments: DiffSegment[] = [];
let current: DiffSegment | null = null;
for (const line of lines) {
const isChange = line.startsWith("+") || line.startsWith("-");
const isEllipsis = line.trimStart().startsWith("...");
if (isEllipsis) {
if (current) segments.push(current);
segments.push({ lines: [line], isChange: false, isEllipsis: true });
current = null;
} else if (!current || current.isChange !== isChange) {
if (current) segments.push(current);
current = { lines: [line], isChange, isEllipsis: false };
} else {
current.lines.push(line);
}
}
if (current) segments.push(current);
return segments;
}
export function truncateDiffByHunk(
diffText: string,
maxHunks: number,
maxLines: number,
): { text: string; hiddenHunks: number; hiddenLines: number } {
const lines = diffText ? diffText.split("\n") : [];
const totalStats = getDiffStats(diffText);
if (lines.length <= maxLines && totalStats.hunks <= maxHunks) {
return { text: diffText, hiddenHunks: 0, hiddenLines: 0 };
}
const segments = parseDiffSegments(lines);
const changeSegments = segments.filter(s => s.isChange);
const changeLineCount = changeSegments.reduce((sum, s) => sum + s.lines.length, 0);
if (changeLineCount > maxLines) {
const kept: string[] = [];
let keptHunks = 0;
for (const seg of segments) {
if (seg.isChange) {
keptHunks++;
if (keptHunks > maxHunks) break;
}
kept.push(...seg.lines);
if (kept.length >= maxLines) break;
}
const keptStats = getDiffStats(kept.join("\n"));
return {
text: kept.join("\n"),
hiddenHunks: Math.max(0, totalStats.hunks - keptStats.hunks),
hiddenLines: Math.max(0, lines.length - kept.length),
};
}
const contextBudget = maxLines - changeLineCount;
const contextSegments = segments.filter(s => !s.isChange && !s.isEllipsis);
const totalContextLines = contextSegments.reduce((sum, s) => sum + s.lines.length, 0);
const kept: string[] = [];
let keptHunks = 0;
if (totalContextLines <= contextBudget) {
for (const seg of segments) {
if (seg.isChange) {
keptHunks++;
if (keptHunks > maxHunks) break;
}
kept.push(...seg.lines);
}
} else {
const contextRatio = contextSegments.length > 0 ? contextBudget / totalContextLines : 0;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (seg.isChange) {
keptHunks++;
if (keptHunks > maxHunks) break;
kept.push(...seg.lines);
} else if (seg.isEllipsis) {
kept.push(...seg.lines);
} else {
const allowedLines = Math.max(1, Math.floor(seg.lines.length * contextRatio));
const isBeforeChange = segments[i + 1]?.isChange;
const isAfterChange = segments[i - 1]?.isChange;
if (isBeforeChange && isAfterChange) {
const half = Math.ceil(allowedLines / 2);
if (seg.lines.length > allowedLines) {
kept.push(...seg.lines.slice(0, half));
kept.push(seg.lines[0].replace(/^(\s*\d*\s*).*/, "$1..."));
kept.push(...seg.lines.slice(-half));
} else {
kept.push(...seg.lines);
}
} else if (isBeforeChange) {
kept.push(...seg.lines.slice(-allowedLines));
} else if (isAfterChange) {
kept.push(...seg.lines.slice(0, allowedLines));
} else {
kept.push(...seg.lines.slice(0, Math.min(allowedLines, 2)));
}
}
}
}
const keptStats = getDiffStats(kept.join("\n"));
return {
text: kept.join("\n"),
hiddenHunks: Math.max(0, totalStats.hunks - keptStats.hunks),
hiddenLines: Math.max(0, lines.length - kept.length),
};
}
// =============================================================================
// Path Utilities
// =============================================================================
export function shortenPath(filePath: string, homeDir?: string): string {
const home = homeDir ?? os.homedir();
if (home && filePath.startsWith(home)) {
return `~${filePath.slice(home.length)}`;
}
return filePath;
}
export function formatScreenshot(opts: {
saveFullRes: boolean;
savedMimeType: string;
savedByteLength: number;
dest: string;
resized: ResizedImage;
}): string[] {
const lines = ["Screenshot captured"];
if (opts.saveFullRes) {
lines.push(
`Saved: ${opts.savedMimeType} (${(opts.savedByteLength / 1024).toFixed(2)} KB) to ${shortenPath(opts.dest)}`,
);
lines.push(
`Model: ${opts.resized.mimeType} (${(opts.resized.buffer.length / 1024).toFixed(2)} KB, ${opts.resized.width}x${opts.resized.height})`,
);
} else {
lines.push(`Format: ${opts.resized.mimeType} (${(opts.resized.buffer.length / 1024).toFixed(2)} KB)`);
lines.push(`Dimensions: ${opts.resized.width}x${opts.resized.height}`);
}
const dimensionNote = formatDimensionNote(opts.resized);
if (dimensionNote) {
lines.push(dimensionNote);
}
return lines;
}
export function wrapBrackets(text: string, theme: Theme): string {
return `${theme.format.bracketLeft}${text}${theme.format.bracketRight}`;
}
export const PARSE_ERRORS_LIMIT = 20;
export function dedupeParseErrors(errors: string[] | undefined): string[] {
if (!errors || errors.length === 0) return [];
const seen = new Set<string>();
const deduped: string[] = [];
for (const error of errors) {
if (seen.has(error)) continue;
seen.add(error);
deduped.push(error);
}
return deduped;
}
export function formatParseErrors(errors: string[]): string[] {
const deduped = dedupeParseErrors(errors);
if (deduped.length === 0) return [];
const capped = deduped.slice(0, PARSE_ERRORS_LIMIT);
const header =
deduped.length > PARSE_ERRORS_LIMIT
? `Parse issues (${PARSE_ERRORS_LIMIT} / ${deduped.length}):`
: "Parse issues:";
return [header, ...capped.map(err => `- ${err}`)];
}