/** * LSP Tool TUI Rendering * * Renders LSP tool calls and results in the TUI with: * - Syntax-highlighted hover information * - Color-coded diagnostics by severity * - Grouped references and symbols * - Collapsible/expandable views */ import type { RenderResultOptions } from "@oh-my-pi/pi-agent-core"; import { type HighlightColors, highlightCode as nativeHighlightCode, supportsLanguage } from "@oh-my-pi/pi-natives"; import { type Component, Text } from "@oh-my-pi/pi-tui"; import { getLanguageFromPath, type Theme } from "../modes/theme/theme"; import { formatExpandHint, formatMoreItems, formatStatusIcon, replaceTabs, shortenPath, TRUNCATE_LENGTHS, truncateToWidth, } from "../tools/render-utils"; import { renderStatusLine } from "../tui"; import { CachedOutputBlock } from "../tui/output-block"; import type { LspParams, LspToolDetails } from "./types"; // ============================================================================= // Call Rendering // ============================================================================= /** * Render the LSP tool call in the TUI. * Shows: "lsp " */ function sanitizeInlineText(value: string): string { return replaceTabs(value).replaceAll(/\r?\n/g, " "); } export function renderCall(args: LspParams, _options: RenderResultOptions, theme: Theme): Text { const actionLabel = (args.action ?? "request").replace(/_/g, " "); const queryPreview = args.query ? truncateToWidth(args.query, TRUNCATE_LENGTHS.SHORT) : undefined; const symbolPreview = args.symbol ? truncateToWidth(sanitizeInlineText(args.symbol), TRUNCATE_LENGTHS.SHORT) : undefined; let target: string | undefined; let hasFileTarget = false; if (args.file) { target = shortenPath(args.file); hasFileTarget = true; } if (hasFileTarget && args.line !== undefined) { target += `:${args.line}`; if (symbolPreview) { target += ` (${symbolPreview})`; } } else if (!target && args.line !== undefined) { target = `line ${args.line}`; if (symbolPreview) { target += ` (${symbolPreview})`; } } const meta: string[] = []; if (queryPreview && target) meta.push(`query:${queryPreview}`); if (args.new_name) meta.push(`new:${args.new_name}`); if (args.apply !== undefined) meta.push(`apply:${args.apply ? "true" : "false"}`); const descriptionParts = [actionLabel]; if (target) { descriptionParts.push(target); } else if (queryPreview) { descriptionParts.push(queryPreview); } const text = renderStatusLine( { icon: "pending", title: "LSP", description: descriptionParts.join(" "), meta, }, theme, ); return new Text(text, 0, 0); } // ============================================================================= // Result Rendering // ============================================================================= /** * Render LSP tool result with intelligent formatting based on result type. * Detects hover, diagnostics, references, symbols, etc. and formats accordingly. */ export function renderResult( result: { content: Array<{ type: string; text?: string }>; details?: LspToolDetails; isError?: boolean }, options: RenderResultOptions, theme: Theme, args?: LspParams, ): Component { const content = result.content?.[0]; if (!content || content.type !== "text" || !("text" in content) || !content.text) { const icon = formatStatusIcon("warning", theme, options.spinnerFrame); const header = `${icon} LSP`; return new Text([header, theme.fg("dim", "No result")].join("\n"), 0, 0); } const text = content.text; const lines = text.split("\n"); // Static type detection (result content doesn't change between renders) const codeBlockMatch = text.match(/```(\w*)\n([\s\S]*?)```/); const errorMatch = text.match(/(\d+)\s+error\(s\)/); const warningMatch = text.match(/(\d+)\s+warning\(s\)/); const refMatch = text.match(/(\d+)\s+reference\(s\)/); const symbolsMatch = text.match(/Symbols in (.+):/); const hasStatusError = text.includes(theme.status.error); // Static request info const request = args ?? result.details?.request; const requestLines: string[] = []; if (request?.file) { requestLines.push(theme.fg("toolOutput", request.file)); } if (request?.line !== undefined) { requestLines.push(theme.fg("dim", `line ${request.line}`)); } if (request?.symbol) { requestLines.push(theme.fg("dim", `symbol: ${sanitizeInlineText(request.symbol)}`)); } if (request?.query) requestLines.push(theme.fg("dim", `query: ${request.query}`)); if (request?.new_name) requestLines.push(theme.fg("dim", `new name: ${request.new_name}`)); if (request?.apply !== undefined) requestLines.push(theme.fg("dim", `apply: ${request.apply ? "true" : "false"}`)); const outputBlock = new CachedOutputBlock(); return { render(width: number): string[] { // Read mutable state at render time const { expanded, isPartial, spinnerFrame } = options; // Determine label, state, bodyLines based on type + current expanded let label = "Result"; let state: "success" | "warning" | "error" = "success"; let bodyLines: string[] = []; if (codeBlockMatch) { label = "Hover"; bodyLines = renderHover(codeBlockMatch, text, lines, expanded, theme); } else if (errorMatch || warningMatch || hasStatusError) { label = "Diagnostics"; const errorCount = errorMatch ? Number.parseInt(errorMatch[1], 10) : 0; const warnCount = warningMatch ? Number.parseInt(warningMatch[1], 10) : 0; state = errorCount > 0 ? "error" : warnCount > 0 ? "warning" : "success"; bodyLines = renderDiagnostics(errorMatch, warningMatch, lines, expanded, theme); } else if (refMatch) { label = "References"; bodyLines = renderReferences(refMatch, lines, expanded, theme); } else if (symbolsMatch) { label = "Symbols"; bodyLines = renderSymbols(symbolsMatch, lines, expanded, theme); } else if (result.details?.action === "diagnostics" && text === "OK") { label = "Diagnostics"; state = "success"; bodyLines = [`${theme.styledSymbol("status.success", "success")} ${theme.fg("dim", "OK")}`]; } else { label = "Response"; bodyLines = renderGeneric(text, lines, expanded, theme); } const actionLabel = (request?.action ?? result.details?.action ?? label.toLowerCase()).replace(/_/g, " "); const status = isPartial ? "running" : result.isError ? "error" : "success"; const icon = formatStatusIcon(status, theme, spinnerFrame); const header = `${icon} LSP ${actionLabel}`; return outputBlock.render( { header, state, sections: [ ...(requestLines.length > 0 ? [{ lines: requestLines }] : []), { label: theme.fg("toolTitle", "Response"), lines: bodyLines }, ], width, applyBg: false, }, theme, ); }, invalidate() { outputBlock.invalidate(); }, }; } // ============================================================================= // Hover Rendering // ============================================================================= /** * Render hover information with syntax-highlighted code blocks. */ function renderHover( codeBlockMatch: RegExpMatchArray, fullText: string, _lines: string[], expanded: boolean, theme: Theme, ): string[] { const lang = codeBlockMatch[1] || ""; const code = codeBlockMatch[2].trim(); const codeStart = codeBlockMatch.index ?? 0; const beforeCode = fullText.slice(0, codeStart).trimEnd(); const afterCode = fullText.slice(fullText.indexOf("```", 3) + 3).trim(); const codeLines = highlightCode(code, lang, theme); const icon = theme.styledSymbol("status.info", "accent"); const langLabel = lang ? theme.fg("mdCodeBlockBorder", ` ${lang}`) : ""; if (expanded) { const h = theme.boxSharp.horizontal; const v = theme.boxSharp.vertical; const top = `${theme.boxSharp.topLeft}${h.repeat(3)}`; const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`; let output = `${icon}${langLabel}`; if (beforeCode) { for (const line of beforeCode.split("\n")) { output += `\n ${theme.fg("muted", line)}`; } } output += `\n ${theme.fg("mdCodeBlockBorder", top)}`; for (const line of codeLines) { output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${line}`; } output += `\n ${theme.fg("mdCodeBlockBorder", bottom)}`; if (afterCode) { output += `\n ${theme.fg("muted", afterCode)}`; } return output.split("\n"); } // Collapsed view const firstCodeLine = codeLines[0] || ""; const hasMore = codeLines.length > 1 || Boolean(afterCode) || Boolean(beforeCode); const expandHint = formatExpandHint(theme, expanded, hasMore); let output = `${icon}${langLabel}${expandHint}`; if (beforeCode) { const preview = truncateToWidth(beforeCode, TRUNCATE_LENGTHS.TITLE); output += `\n ${theme.fg("dim", theme.tree.branch)} ${theme.fg("muted", preview)}`; } const h = theme.boxSharp.horizontal; const v = theme.boxSharp.vertical; const bottom = `${theme.boxSharp.bottomLeft}${h.repeat(3)}`; output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${firstCodeLine}`; if (codeLines.length > 1) { output += `\n ${theme.fg("mdCodeBlockBorder", v)} ${theme.fg("muted", `… ${codeLines.length - 1} more lines`)}`; } if (afterCode) { const docPreview = truncateToWidth(afterCode, TRUNCATE_LENGTHS.TITLE); output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", docPreview)}`; } else { output += `\n ${theme.fg("mdCodeBlockBorder", bottom)}`; } return output.split("\n"); } /** * Syntax highlight code using native highlighter. */ function highlightCode(codeText: string, language: string, theme: Theme): string[] { const validLang = language && supportsLanguage(language) ? language : undefined; try { const colors: HighlightColors = { comment: theme.getFgAnsi("syntaxComment"), keyword: theme.getFgAnsi("syntaxKeyword"), function: theme.getFgAnsi("syntaxFunction"), variable: theme.getFgAnsi("syntaxVariable"), string: theme.getFgAnsi("syntaxString"), number: theme.getFgAnsi("syntaxNumber"), type: theme.getFgAnsi("syntaxType"), operator: theme.getFgAnsi("syntaxOperator"), punctuation: theme.getFgAnsi("syntaxPunctuation"), inserted: theme.getFgAnsi("toolDiffAdded"), deleted: theme.getFgAnsi("toolDiffRemoved"), }; return nativeHighlightCode(codeText, validLang, colors).split("\n"); } catch { return codeText.split("\n"); } } // ============================================================================= // Diagnostics Rendering // ============================================================================= function formatDiagnosticLocation(file: string, line: string | number, col: string | number, theme: Theme): string { const lang = getLanguageFromPath(file); const icon = theme.fg("muted", theme.getLangIcon(lang)); return `${icon} ${file}:${line}:${col}`; } /** * Render diagnostics with color-coded severity. */ function renderDiagnostics( errorMatch: RegExpMatchArray | null, warningMatch: RegExpMatchArray | null, lines: string[], expanded: boolean, theme: Theme, ): string[] { const errorCount = errorMatch ? Number.parseInt(errorMatch[1], 10) : 0; const warnCount = warningMatch ? Number.parseInt(warningMatch[1], 10) : 0; const icon = errorCount > 0 ? theme.styledSymbol("status.error", "error") : warnCount > 0 ? theme.styledSymbol("status.warning", "warning") : theme.styledSymbol("status.success", "success"); const meta: string[] = []; if (errorCount > 0) meta.push(`${errorCount} error${errorCount !== 1 ? "s" : ""}`); if (warnCount > 0) meta.push(`${warnCount} warning${warnCount !== 1 ? "s" : ""}`); if (meta.length === 0) meta.push("No issues"); const diagLines = lines.filter(l => l.includes(theme.status.error) || /:\d+:\d+/.test(l)); const parsedDiagnostics = diagLines .map(line => parseDiagnosticLine(line)) .filter((diag): diag is ParsedDiagnostic => diag !== null); const fallbackDiagnostics: RawDiagnostic[] = diagLines.map(line => ({ raw: sanitizeDiagnosticDisplayText(line.trim()), })); if (expanded) { let output = `${icon} ${theme.fg("dim", meta.join(theme.sep.dot))}`; const items: DiagnosticItem[] = parsedDiagnostics.length > 0 ? parsedDiagnostics : fallbackDiagnostics; for (let i = 0; i < items.length; i++) { const item = items[i]; const isLast = i === items.length - 1; const branch = isLast ? theme.tree.last : theme.tree.branch; const detailPrefix = isLast ? " " : `${theme.tree.vertical} `; if ("raw" in item) { output += `\n ${theme.fg("dim", branch)} ${theme.fg("muted", item.raw)}`; continue; } const severityColor = severityToColor(item.severity); const location = formatDiagnosticLocation(item.file, item.line, item.col, theme); output += `\n ${theme.fg("dim", branch)} ${theme.fg(severityColor, location)} ${theme.fg( "dim", `[${item.severity}]`, )}`; if (item.message) { output += `\n ${theme.fg("dim", detailPrefix)}${theme.fg( "muted", truncateToWidth(item.message, TRUNCATE_LENGTHS.LINE), )}`; } } return output.split("\n"); } // Collapsed view const previewItems: DiagnosticItem[] = parsedDiagnostics.length > 0 ? parsedDiagnostics.slice(0, 3) : fallbackDiagnostics.slice(0, 3); const remaining = (parsedDiagnostics.length > 0 ? parsedDiagnostics.length : fallbackDiagnostics.length) - previewItems.length; const expandHint = formatExpandHint(theme, expanded, remaining > 0); let output = `${icon} ${theme.fg("dim", meta.join(theme.sep.dot))}${expandHint}`; for (let i = 0; i < previewItems.length; i++) { const item = previewItems[i]; const isLast = i === previewItems.length - 1 && remaining <= 0; const branch = isLast ? theme.tree.last : theme.tree.branch; if ("raw" in item) { output += `\n ${theme.fg("dim", branch)} ${theme.fg("muted", item.raw)}`; continue; } const severityColor = severityToColor(item.severity); const location = formatDiagnosticLocation(item.file, item.line, item.col, theme); const message = item.message ? ` ${theme.fg("muted", truncateToWidth(item.message, TRUNCATE_LENGTHS.CONTENT))}` : ""; output += `\n ${theme.fg("dim", branch)} ${theme.fg(severityColor, location)}${message}`; } if (remaining > 0) { output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `… ${remaining} more`)}`; } return output.split("\n"); } // ============================================================================= // References Rendering // ============================================================================= /** * Render references grouped by file. */ function renderReferences(refMatch: RegExpMatchArray, lines: string[], expanded: boolean, theme: Theme): string[] { const refCount = Number.parseInt(refMatch[1], 10); const icon = refCount > 0 ? theme.styledSymbol("status.success", "success") : theme.styledSymbol("status.warning", "warning"); const locLines = lines.filter(l => /^\s*\S+:\d+:\d+/.test(l)); // Group by file const byFile = new Map>(); for (const loc of locLines) { const match = loc.trim().match(/^(.+):(\d+):(\d+)$/); if (match) { const [, file, line, col] = match; if (!byFile.has(file)) byFile.set(file, []); byFile.get(file)!.push([line, col]); } } const files = Array.from(byFile.keys()); const renderGrouped = (maxFiles: number, maxLocsPerFile: number, showHint: boolean): string => { const expandHint = formatExpandHint(theme, undefined, showHint); let output = `${icon} ${theme.fg("dim", `${refCount} found`)}${expandHint}`; const filesToShow = files.slice(0, maxFiles); for (let fi = 0; fi < filesToShow.length; fi++) { const file = filesToShow[fi]; const locs = byFile.get(file)!; const isLastFile = fi === filesToShow.length - 1 && files.length <= maxFiles; const fileBranch = isLastFile ? theme.tree.last : theme.tree.branch; const fileCont = isLastFile ? " " : `${theme.tree.vertical} `; const fileMeta = `${locs.length} reference${locs.length !== 1 ? "s" : ""}`; output += `\n ${theme.fg("dim", fileBranch)} ${theme.fg("accent", file)} ${theme.fg("dim", fileMeta)}`; if (maxLocsPerFile > 0) { const locsToShow = locs.slice(0, maxLocsPerFile); for (let li = 0; li < locsToShow.length; li++) { const [line, col] = locsToShow[li]; const isLastLoc = li === locsToShow.length - 1 && locs.length <= maxLocsPerFile; const locBranch = isLastLoc ? theme.tree.last : theme.tree.branch; const locCont = isLastLoc ? " " : `${theme.tree.vertical} `; output += `\n ${theme.fg("dim", fileCont)}${theme.fg("dim", locBranch)} ${theme.fg( "muted", `line ${line}, col ${col}`, )}`; if (expanded) { const context = `at ${file}:${line}:${col}`; output += `\n ${theme.fg("dim", fileCont)}${theme.fg("dim", locCont)}${theme.fg( "muted", truncateToWidth(context, TRUNCATE_LENGTHS.LINE), )}`; } } if (locs.length > maxLocsPerFile) { output += `\n ${theme.fg("dim", fileCont)}${theme.fg("dim", theme.tree.last)} ${theme.fg( "muted", `… ${locs.length - maxLocsPerFile} more`, )}`; } } } if (files.length > maxFiles) { output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( "muted", formatMoreItems(files.length - maxFiles, "file"), )}`; } return output; }; if (expanded) { return renderGrouped(files.length, 3, false).split("\n"); } return renderGrouped(3, 1, true).split("\n"); } // ============================================================================= // Symbols Rendering // ============================================================================= /** * Render document symbols in a hierarchical tree. */ function renderSymbols(symbolsMatch: RegExpMatchArray, lines: string[], expanded: boolean, theme: Theme): string[] { const fileName = symbolsMatch[1]; const icon = theme.styledSymbol("status.info", "accent"); interface SymbolInfo { name: string; line: string; indent: number; icon: string; } const symbolLines = lines.filter(l => l.includes("@") && l.includes("line")); const symbols: SymbolInfo[] = []; for (const line of symbolLines) { const indent = line.match(/^(\s*)/)?.[1].length ?? 0; const symMatch = line.trim().match(/^(\S+)\s+(.+?)\s*@\s*line\s*(\d+)/); if (symMatch) { symbols.push({ icon: symMatch[1], name: symMatch[2], line: symMatch[3], indent }); } } const isLastSibling = (i: number): boolean => { const myIndent = symbols[i].indent; for (let j = i + 1; j < symbols.length; j++) { const nextIndent = symbols[j].indent; if (nextIndent === myIndent) return false; if (nextIndent < myIndent) return true; } return true; }; const getPrefix = (i: number): string => { const myIndent = symbols[i].indent; if (myIndent === 0) return " "; let prefix = " "; for (let level = 2; level <= myIndent; level += 2) { let ancestorIdx = -1; for (let j = i - 1; j >= 0; j--) { if (symbols[j].indent === level - 2) { ancestorIdx = j; break; } } if (ancestorIdx >= 0 && isLastSibling(ancestorIdx)) { prefix += " "; } else { prefix += `${theme.tree.vertical} `; } } return prefix; }; const topLevelCount = symbols.filter(s => s.indent === 0).length; if (expanded) { let output = `${icon} ${theme.fg("dim", `in ${fileName}`)}`; for (let i = 0; i < symbols.length; i++) { const sym = symbols[i]; const prefix = getPrefix(i); const isLast = isLastSibling(i); const branch = isLast ? theme.tree.last : theme.tree.branch; const detailPrefix = isLast ? " " : `${theme.tree.vertical} `; output += `\n${prefix}${theme.fg("dim", branch)} ${theme.fg("accent", sym.icon)} ${theme.fg("accent", sym.name)}`; output += `\n${prefix}${theme.fg("dim", detailPrefix)}${theme.fg("muted", `line ${sym.line}`)}`; } return output.split("\n"); } // Collapsed: show first 3 top-level symbols const topLevel = symbols.filter(s => s.indent === 0).slice(0, 3); const hasMoreSymbols = symbols.length > topLevel.length; const expandHint = formatExpandHint(theme, expanded, hasMoreSymbols); let output = `${icon} ${theme.fg("dim", `in ${fileName}`)}${expandHint}`; for (let i = 0; i < topLevel.length; i++) { const sym = topLevel[i]; const isLast = i === topLevel.length - 1 && topLevelCount <= 3; const branch = isLast ? theme.tree.last : theme.tree.branch; output += `\n ${theme.fg("dim", branch)} ${theme.fg("accent", sym.icon)} ${theme.fg("accent", sym.name)} ${theme.fg( "muted", `line ${sym.line}`, )}`; } if (topLevelCount > 3) { output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", `… ${topLevelCount - 3} more`)}`; } return output.split("\n"); } // ============================================================================= // Generic Rendering // ============================================================================= /** * Generic fallback rendering for unknown result types. */ function renderGeneric(text: string, lines: string[], expanded: boolean, theme: Theme): string[] { const hasError = text.includes("Error:") || text.includes(theme.status.error); const hasSuccess = text.includes(theme.status.success) || text.includes("Applied"); const icon = hasError && !hasSuccess ? theme.styledSymbol("status.error", "error") : hasSuccess && !hasError ? theme.styledSymbol("status.success", "success") : theme.styledSymbol("status.info", "accent"); if (expanded) { let output = `${icon} ${theme.fg("dim", "Output")}`; for (let i = 0; i < lines.length; i++) { const isLast = i === lines.length - 1; const branch = isLast ? theme.tree.last : theme.tree.branch; output += `\n ${theme.fg("dim", branch)} ${lines[i]}`; } return output.split("\n"); } const firstLine = lines[0] || "No output"; const expandHint = formatExpandHint(theme, expanded, lines.length > 1); let output = `${icon} ${theme.fg("dim", truncateToWidth(firstLine, TRUNCATE_LENGTHS.TITLE))}${expandHint}`; if (lines.length > 1) { const previewLines = lines.slice(1, 4); for (let i = 0; i < previewLines.length; i++) { const isLast = i === previewLines.length - 1 && lines.length <= 4; const branch = isLast ? theme.tree.last : theme.tree.branch; output += `\n ${theme.fg("dim", branch)} ${theme.fg( "dim", truncateToWidth(previewLines[i].trim(), TRUNCATE_LENGTHS.CONTENT), )}`; } if (lines.length > 4) { output += `\n ${theme.fg("dim", theme.tree.last)} ${theme.fg( "muted", formatMoreItems(lines.length - 4, "line"), )}`; } } return output.split("\n"); } // ============================================================================= // Parsing Helpers // ============================================================================= interface ParsedDiagnostic { file: string; line: string; col: string; severity: string; message: string; } interface RawDiagnostic { raw: string; } type DiagnosticItem = ParsedDiagnostic | RawDiagnostic; function sanitizeDiagnosticDisplayText(text: string): string { return replaceTabs(text); } function parseDiagnosticLine(line: string): ParsedDiagnostic | null { const match = line.trim().match(/^(.*):(\d+):(\d+)\s+\[(\w+)\]\s*(.*)$/); if (!match) return null; const [, file, lineNum, colNum, severity, message] = match; return { file: sanitizeDiagnosticDisplayText(file), line: lineNum, col: colNum, severity: severity.toLowerCase(), message: sanitizeDiagnosticDisplayText(message), }; } function severityToColor(severity: string): "error" | "warning" | "accent" | "dim" { switch (severity) { case "error": return "error"; case "warning": return "warning"; case "info": return "accent"; default: return "dim"; } } export const lspToolRenderer = { renderCall, renderResult, mergeCallAndResult: true, inline: true, };