/** * 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 * as path from "node:path"; import type { ToolCallContext } from "@oh-my-pi/pi-agent-core"; import type { Ellipsis } from "@oh-my-pi/pi-natives"; import type { Component } from "@oh-my-pi/pi-tui"; import { replaceTabs, 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 { Hasher } from "../tui/utils"; import { formatDimensionNote, type ResizedImage } from "../utils/image-resize"; export { Ellipsis } from "@oh-my-pi/pi-natives"; export { replaceTabs, truncateToWidth, wrapTextWithAnsi } 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)}`; } // ============================================================================= // Code Frame Formatting // ============================================================================= export type CodeFrameMarker = "" | " " | "*" | "+" | "-" | ">"; export function formatCodeFrameLine( marker: CodeFrameMarker, lineNumber: string | number, content: string, lineNumberWidth: number, ): string { const markerText = marker.trim(); const lineNumberText = String(lineNumber).trim(); const gutterText = markerText && lineNumberText ? `${markerText}${lineNumberText}` : lineNumberText || markerText; return `${gutterText.padStart(lineNumberWidth + 1, " ")}│${content}`; } // ============================================================================= // 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 sanitizeDiagnosticDisplayText(text: string): string { return replaceTabs(text); } 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: sanitizeDiagnosticDisplayText(match[1]), line: parseInt(match[2], 10), col: parseInt(match[3], 10), severity: match[4] as ParsedDiagnostic["severity"], source: match[5] ? sanitizeDiagnosticDisplayText(match[5]) : undefined, message: sanitizeDiagnosticDisplayText(match[6]), code: match[7] ? sanitizeDiagnosticDisplayText(match[7]) : undefined, }; } 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(); 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(sanitizeDiagnosticDisplayText(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"); const summary = sanitizeDiagnosticDisplayText(diag.summary); let output = `\n\n${headerIcon} ${theme.fg("toolTitle", "Diagnostics")} ${theme.fg("dim", `(${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 formatToolWorkingDirectory(workdir: string | undefined, projectDir: string): string | undefined { if (!workdir) return undefined; const resolvedProjectDir = path.resolve(projectDir); const resolvedWorkdir = path.resolve(projectDir, workdir); if (resolvedWorkdir === resolvedProjectDir) { return undefined; } const relativePath = path.relative(resolvedProjectDir, resolvedWorkdir); const isWithinProject = relativePath.length > 0 && !relativePath.startsWith("..") && !relativePath.startsWith(`..${path.sep}`); const displayWorkdir = isWithinProject ? relativePath : shortenPath(resolvedWorkdir); return replaceTabs(displayWorkdir); } 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(); 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[], total?: number): string[] { const deduped = dedupeParseErrors(errors); if (deduped.length === 0) return []; const fullCount = total ?? deduped.length; const capped = deduped.slice(0, PARSE_ERRORS_LIMIT); const header = fullCount > capped.length ? `Parse issues (${capped.length} / ${fullCount}):` : "Parse issues:"; return [header, ...capped.map(err => `- ${err}`)]; } /** * Cap an upstream parse-error list to {@link PARSE_ERRORS_LIMIT} unique entries, * preserving the original deduplicated total. Use this at the source so tool * details never carry thousands of per-file parse errors into traces or * renderers. */ export function capParseErrors( errors: string[] | undefined, limit: number = PARSE_ERRORS_LIMIT, ): { errors: string[]; total: number } { const deduped = dedupeParseErrors(errors); return { errors: deduped.slice(0, limit), total: deduped.length }; } // ============================================================================= // Renderer helpers shared by search / find / ast tools // ============================================================================= /** * Group `rawLines` by blank-line separators, mirroring the historical search / * ast-grep / ast-edit renderer behavior: if any blank line is present, splits on * runs of blank lines; otherwise collapses non-empty lines into a single group. */ export function splitGroupsByBlankLine(rawLines: string[]): string[][] { const hasSeparators = rawLines.some(line => line.trim().length === 0); const groups: string[][] = []; if (hasSeparators) { let current: string[] = []; for (const line of rawLines) { if (line.trim().length === 0) { if (current.length > 0) { groups.push(current); current = []; } continue; } current.push(line); } if (current.length > 0) groups.push(current); } else { const nonEmpty = rawLines.filter(line => line.trim().length > 0); if (nonEmpty.length > 0) { groups.push(nonEmpty); } } return groups; } /** * Standard width+expand keyed render cache used by every search-style tool * renderer. `compute` re-runs only when the cache key changes; the returned * Component is the canonical `{ render, invalidate }` pair. */ export function createCachedComponent( getExpanded: () => boolean, compute: (width: number, expanded: boolean) => string[], ): Component { let cached: { key: bigint; lines: string[] } | undefined; return { render(width: number): string[] { const expanded = getExpanded(); const key = new Hasher().bool(expanded).u32(width).digest(); if (cached?.key === key) return cached.lines; const lines = compute(width, expanded); cached = { key, lines }; return lines; }, invalidate() { cached = undefined; }, }; } /** * Append the indented bullet list of parse errors (capped at * {@link PARSE_ERRORS_LIMIT}) to `lines`, with an overflow summary line if the * total exceeds the cap. No-op when `parseErrors` is empty. */ export function appendParseErrorsBulletList( lines: string[], parseErrors: readonly string[] | undefined, theme: Theme, total?: number, ): void { if (!parseErrors || parseErrors.length === 0) return; const fullCount = total ?? parseErrors.length; const capped = parseErrors.slice(0, PARSE_ERRORS_LIMIT); for (const err of capped) { lines.push(theme.fg("warning", ` - ${err}`)); } if (fullCount > capped.length) { lines.push(theme.fg("dim", ` … ${fullCount - capped.length} more`)); } } /** * Human-readable summary string for the parse-issues count, capped by * {@link PARSE_ERRORS_LIMIT}. */ export function formatParseErrorsCountLabel(parseErrors: readonly string[], total?: number): string { const fullCount = total ?? parseErrors.length; return fullCount > PARSE_ERRORS_LIMIT ? `${PARSE_ERRORS_LIMIT} / ${fullCount} parse issues` : `${fullCount} parse issue${fullCount !== 1 ? "s" : ""}`; } // ============================================================================= // LSP Batching // ============================================================================= const LSP_BATCH_TOOLS = new Set(["edit", "write"]); export interface LspBatchRequest { id: string; flush: boolean; } export function getLspBatchRequest(toolCall: ToolCallContext | undefined): LspBatchRequest | undefined { if (!toolCall) { return undefined; } const hasOtherWrites = toolCall.toolCalls.some( (call, index) => index !== toolCall.index && LSP_BATCH_TOOLS.has(call.name), ); if (!hasOtherWrites) { return undefined; } const hasLaterWrites = toolCall.toolCalls.slice(toolCall.index + 1).some(call => LSP_BATCH_TOOLS.has(call.name)); return { id: toolCall.batchId, flush: !hasLaterWrites }; }