/** * 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 { Ellipsis } from "@f5-sales-demo/pi-natives"; import { replaceTabs, truncateToWidth } from "@f5-sales-demo/pi-tui"; import { pluralize } from "@f5-sales-demo/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 "@f5-sales-demo/pi-natives"; export { replaceTabs, truncateToWidth, wrapTextWithAnsi } from "@f5-sales-demo/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.8) : 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 "@f5-sales-demo/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", "chromeAccent"); case "pending": // Gutter ball's pulse is the spinner. Inline ⏳ would be redundant. return ""; case "running": if (spinnerFrame !== undefined) { const frames = theme.spinnerFrames; return frames[spinnerFrame % frames.length]; } return theme.styledSymbol("status.running", "spinnerAccent"); 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.fg("error", `Error: ${clean || "Unknown error"}`); } export function formatEmptyMessage(message: string, theme: Theme): string { return theme.fg("muted", message); } // ============================================================================= // Tool UI Helpers // ============================================================================= export type ToolUIStatus = "success" | "error" | "warning" | "info" | "pending" | "running" | "aborted"; export type ToolUIColor = | "success" | "error" | "warning" | "accent" | "chromeAccent" | "spinnerAccent" | "contentAccent" | "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(); 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("contentAccent", 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[]): 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}`)]; } // ============================================================================= // JSON / Display Utilities (shared with tool renderers) // ============================================================================= export function stripEmpty(obj: unknown): unknown { if (Array.isArray(obj)) return obj.map(stripEmpty).filter(v => v != null); if (obj && typeof obj === "object") { const entries = Object.entries(obj as Record); if (entries.length === 0) return obj; const out: Record = {}; for (const [k, v] of entries) { if (v == null || v === "" || (Array.isArray(v) && v.length === 0)) continue; const cleaned = stripEmpty(v); if (cleaned != null) out[k] = cleaned; } return Object.keys(out).length > 0 ? out : null; } return obj; } export function formatTimestamp(iso: string): string { return iso.replace("T", " ").replace(/:\d{2}(\.\d+)?Z$/, " UTC"); } export function addSection( sections: Array<{ label?: string; lines: string[] }>, label: string, lines: string[], theme: Theme, maxLines?: number, ): void { const titled = theme.fg("toolTitle", label); if (maxLines && lines.length > maxLines) { const truncated = lines.slice(0, maxLines); truncated.push(theme.fg("dim", `… ${lines.length - maxLines} more lines`)); sections.push({ label: titled, lines: truncated }); } else { sections.push({ label: titled, lines }); } } export function humanizeResourceType(raw: string): string { return raw .replace(/^http_/, "") .replace(/^tcp_/, "TCP ") .replace(/_/g, " ") .replace(/([a-z])(balancer|checker)/gi, "$1 $2") .replace(/s$/, ""); }