import { cn } from '@/lib/utils'; import { typography } from '@/lib/typography'; import { formatToolInput, detectToolOutputLanguage } from '@/lib/toolHelpers'; import { SimpleMarkdownRenderer } from '../MarkdownRenderer'; import { Icon } from "@/components/icon/Icon"; const cleanOutput = (output: string) => { let cleaned = output.replace(/^\s*\n?/, '').replace(/\n?<\/file>\s*$/, ''); cleaned = cleaned.replace(/^\s*\d{5}\|\s?/gm, ''); return cleaned.trim(); }; export const hasLspDiagnostics = (output: string): boolean => { if (!output) return false; return output.includes('') || output.includes('LSP errors detected') || output.includes('This file has errors'); }; const stripLspDiagnostics = (output: string): string => { if (!output) return ''; return output .replace(/\n{0,2}LSP errors detected[\s\S]*?]*>[\s\S]*?<\/diagnostics>/g, '') .replace(/\n{0,2}This file has errors[\s\S]*?<\/file_diagnostics>/g, '') .replace(/]*>[\s\S]*?<\/diagnostics>/g, '') .replace(/[\s\S]*?<\/file_diagnostics>/g, '') .trim(); }; const formatInputForDisplay = (input: Record, toolName?: string) => { if (!input || typeof input !== 'object') { return String(input); } return formatToolInput(input, toolName || ''); }; const getPatchText = (value: unknown): string | undefined => { if (typeof value === 'string') { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } if (value && typeof value === 'object') { const patch = (value as { patch?: unknown }).patch; if (typeof patch === 'string') { const trimmed = patch.trim(); return trimmed.length > 0 ? trimmed : undefined; } } return undefined; }; const getToolMetadataPatch = (metadata?: Record): string | undefined => { if (!metadata || typeof metadata !== 'object') { return undefined; } const topLevelPatch = getPatchText((metadata as { patch?: unknown }).patch) ?? getPatchText(metadata.diff); if (topLevelPatch) { return topLevelPatch; } const files = Array.isArray((metadata as { files?: unknown }).files) ? (metadata as { files: unknown[] }).files : []; for (const file of files) { if (!file || typeof file !== 'object') { continue; } const patch = getPatchText((file as { patch?: unknown }).patch) ?? getPatchText((file as { diff?: unknown }).diff); if (patch) { return patch; } } return undefined; }; export const tryParseJsonOutput = (output: string): { data: unknown; isJson: boolean } => { if (!output || typeof output !== 'string') { return { data: null, isJson: false }; } const trimmed = output.trim(); if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { return { data: null, isJson: false }; } if (!trimmed.endsWith('}') && !trimmed.endsWith(']')) { return { data: null, isJson: false }; } if (trimmed.length < 2) { return { data: null, isJson: false }; } try { const parsed = JSON.parse(trimmed); if (parsed !== null && typeof parsed === 'object') { return { data: parsed, isJson: true }; } return { data: null, isJson: false }; } catch { return { data: null, isJson: false }; } }; export const formatEditOutput = (output: string, toolName: string, metadata?: Record): string => { let cleaned = cleanOutput(output); if ((toolName === 'edit' || toolName === 'multiedit' || toolName === 'write' || toolName === 'apply_patch') && hasLspDiagnostics(cleaned)) { cleaned = stripLspDiagnostics(cleaned); } if ((toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch') && cleaned.trim().length === 0) { const diff = getToolMetadataPatch(metadata); if (diff) { return diff; } } return cleaned; }; export interface ParsedReadOutputLine { text: string; lineNumber: number | null; isInfo: boolean; } export interface ParsedReadToolOutput { type: 'file' | 'directory' | 'unknown'; lines: ParsedReadOutputLine[]; } export const parseReadToolOutput = (output: string): ParsedReadToolOutput => { const typeMatch = output.match(/(file|directory)<\/type>/i); const detectedType = (typeMatch?.[1]?.toLowerCase() ?? 'unknown') as ParsedReadToolOutput['type']; const contentMatch = output.match(/([\s\S]*?)<\/content>/i); const rawContent = contentMatch?.[1] ?? output; const normalizedContent = rawContent.replace(/\r\n/g, '\n'); const rawLines = normalizedContent.split('\n'); const isTruncationInfoLine = (text: string): boolean => { return /\(\s*File has more lines\..*offset.*\)/i.test(text.trim()); }; const parsedLines = rawLines.map((line): ParsedReadOutputLine => { const trimmed = line.trim(); const isInfo = (trimmed.startsWith('(') && trimmed.endsWith(')')) || isTruncationInfoLine(trimmed); if (detectedType !== 'directory') { const numberedMatch = line.match(/^(\d+):\s?(.*)$/); if (numberedMatch) { const numberedText = numberedMatch[2]; const numberedTrimmed = numberedText.trim(); const numberedIsInfo = (numberedTrimmed.startsWith('(') && numberedTrimmed.endsWith(')')) || isTruncationInfoLine(numberedTrimmed); return { lineNumber: numberedIsInfo ? null : Number(numberedMatch[1]), text: numberedText, isInfo: numberedIsInfo, }; } } return { lineNumber: null, text: line, isInfo, }; }); const lines = parsedLines.filter((line, index, arr) => { if (line.text.trim().length > 0) { return true; } const prev = arr[index - 1]; const next = arr[index + 1]; const adjacentToInfo = Boolean(prev?.isInfo || next?.isInfo); const hasNumber = line.lineNumber !== null; // Drop numbered blank lines wrapped around helper/info rows. if (adjacentToInfo && hasNumber) { return false; } return true; }); return { type: detectedType, lines, }; }; export const renderListOutput = (output: string, options?: { unstyled?: boolean }) => { try { const lines = output.trim().split('\n').filter(Boolean); if (lines.length === 0) return null; const items: Array<{ name: string; depth: number; isFile: boolean }> = []; lines.forEach((line) => { const match = line.match(/^(\s*)(.+)$/); if (match) { const [, spaces, name] = match; const depth = Math.floor(spaces.length / 2); const isFile = !name.endsWith('/'); items.push({ name: name.replace(/\/$/, ''), depth, isFile, }); } }); return (
{items.map((item, idx) => (
{item.isFile ? ( {item.name} ) : ( {item.name}/ )}
))}
); } catch { return null; } }; const GREP_DOT_STYLE = { backgroundColor: 'var(--status-info)', opacity: 0.6 }; export const renderGrepOutput = (output: string, isMobile: boolean, options?: { unstyled?: boolean }) => { try { const lines = output.trim().split('\n').filter(Boolean); if (lines.length === 0) return null; const fileGroups: Record> = {}; lines.forEach((line) => { const match = line.match(/^(.+?):(\d+):(.*)$/) || line.match(/^(.+?):(.*)$/); if (match) { const [, filepath, lineNumOrContent, content] = match; const lineNum = content !== undefined ? lineNumOrContent : ''; const actualContent = content !== undefined ? content : lineNumOrContent; if (!fileGroups[filepath]) { fileGroups[filepath] = []; } fileGroups[filepath].push({ lineNum, content: actualContent }); } }); return (
Found {lines.length} match{lines.length !== 1 ? 'es' : ''}
{Object.entries(fileGroups).map(([filepath, matches]) => (
{filepath}
{matches.map((match, idx) => { if (!match.lineNum && !match.content) { return null; } return (
{match.lineNum && ( Line {match.lineNum}: )} {match.content || '\u00A0'}
); })}
))}
); } catch { return null; } }; const GLOB_DOT_STYLE = { backgroundColor: 'var(--status-info)', opacity: 0.6 }; export const renderGlobOutput = (output: string, isMobile: boolean, options?: { unstyled?: boolean }) => { try { const paths = output.trim().split('\n').filter(Boolean); if (paths.length === 0) return null; const groups: Record = {}; paths.forEach((path) => { const lastSlash = path.lastIndexOf('/'); const dir = lastSlash > 0 ? path.substring(0, lastSlash) : '/'; const filename = lastSlash >= 0 ? path.substring(lastSlash + 1) : path; if (!groups[dir]) { groups[dir] = []; } groups[dir].push(filename); }); const sortedDirs = Object.keys(groups).sort(); return (
Found {paths.length} file{paths.length !== 1 ? 's' : ''}
{sortedDirs.map((dir) => (
{dir}/
{groups[dir].sort().map((filename) => (
{filename}
))}
))}
); } catch { return null; } }; type Todo = { id?: string; content: string; status: 'in_progress' | 'pending' | 'completed' | 'cancelled'; priority?: 'high' | 'medium' | 'low'; }; export const renderTodoOutput = ( output: string, labels: { total: string; inProgress: string; pending: string; completed: string; cancelled: string; }, options?: { unstyled?: boolean }, ) => { try { const todos = JSON.parse(output) as Todo[]; if (!Array.isArray(todos)) { return null; } const todosByStatus = todos.reduce((acc, t) => { const status = t.status as keyof typeof acc; if (status in acc) acc[status].push(t); return acc; }, { in_progress: [] as Todo[], pending: [] as Todo[], completed: [] as Todo[], cancelled: [] as Todo[] }); const getPriorityDot = (priority?: string) => { const baseClasses = 'w-2 h-2 rounded-full flex-shrink-0 mt-1'; switch (priority) { case 'high': return
; case 'medium': return
; case 'low': return
; default: return
; } }; return (
{labels.total}: {todos.length} {todosByStatus.in_progress.length > 0 && ( {labels.inProgress}: {todosByStatus.in_progress.length} )} {todosByStatus.pending.length > 0 && ( {labels.pending}: {todosByStatus.pending.length} )} {todosByStatus.completed.length > 0 && ( {labels.completed}: {todosByStatus.completed.length} )} {todosByStatus.cancelled.length > 0 && ( {labels.cancelled}: {todosByStatus.cancelled.length} )}
{todosByStatus.in_progress.length > 0 && (
{labels.inProgress}
{todosByStatus.in_progress.map((todo, idx) => (
{getPriorityDot(todo.priority)} {todo.content}
))}
)} {todosByStatus.pending.length > 0 && (
{labels.pending}
{todosByStatus.pending.map((todo, idx) => (
{getPriorityDot(todo.priority)} {todo.content}
))}
)} {todosByStatus.completed.length > 0 && (
{labels.completed}
{todosByStatus.completed.map((todo, idx) => (
{todo.content}
))}
)} {todosByStatus.cancelled.length > 0 && (
× {labels.cancelled}
{todosByStatus.cancelled.map((todo, idx) => (
× {todo.content}
))}
)}
); } catch { return null; } }; export const renderWebSearchOutput = (output: string, options?: { unstyled?: boolean }) => { try { return (
); } catch { return null; } }; export type DiffLineType = 'context' | 'added' | 'removed'; export interface UnifiedDiffLine { type: DiffLineType; lineNumber: number | null; content: string; } export interface UnifiedDiffHunk { file: string; oldStart: number; newStart: number; lines: UnifiedDiffLine[]; } export interface SideBySideDiffLine { leftLine: { type: 'context' | 'removed' | 'empty'; lineNumber: number | null; content: string }; rightLine: { type: 'context' | 'added' | 'empty'; lineNumber: number | null; content: string }; } export interface SideBySideDiffHunk { file: string; oldStart: number; newStart: number; lines: SideBySideDiffLine[]; } export const parseDiffToUnified = (diffText: string): UnifiedDiffHunk[] => { const lines = diffText.split('\n'); let currentFile = ''; const hunks: UnifiedDiffHunk[] = []; let i = 0; while (i < lines.length) { const line = lines[i]; if (line.startsWith('Index:') || line.startsWith('===') || line.startsWith('---') || line.startsWith('+++')) { if (line.startsWith('Index:')) { currentFile = line.split(' ')[1].split('/').pop() || 'file'; } i++; continue; } if (line.startsWith('@@')) { const match = line.match(/@@ -(\d+),\d+ \+(\d+),\d+ @@/); const oldStart = match ? parseInt(match[1]) : 0; const newStart = match ? parseInt(match[2]) : 0; const unifiedLines: UnifiedDiffLine[] = []; let oldLineNum = oldStart; let newLineNum = newStart; let j = i + 1; while (j < lines.length && !lines[j].startsWith('@@') && !lines[j].startsWith('Index:')) { const contentLine = lines[j]; if (contentLine.startsWith('+')) { unifiedLines.push({ type: 'added', lineNumber: newLineNum, content: contentLine.substring(1) }); newLineNum++; } else if (contentLine.startsWith('-')) { unifiedLines.push({ type: 'removed', lineNumber: oldLineNum, content: contentLine.substring(1) }); oldLineNum++; } else if (contentLine.startsWith(' ')) { unifiedLines.push({ type: 'context', lineNumber: newLineNum, content: contentLine.substring(1) }); oldLineNum++; newLineNum++; } j++; } hunks.push({ file: currentFile, oldStart, newStart, lines: unifiedLines, }); i = j; continue; } i++; } return hunks; }; export const parseDiffToLines = (diffText: string): SideBySideDiffHunk[] => { const lines = diffText.split('\n'); let currentFile = ''; const hunks: SideBySideDiffHunk[] = []; let i = 0; while (i < lines.length) { const line = lines[i]; if (line.startsWith('Index:') || line.startsWith('===') || line.startsWith('---') || line.startsWith('+++')) { if (line.startsWith('Index:')) { currentFile = line.split(' ')[1].split('/').pop() || 'file'; } i++; continue; } if (line.startsWith('@@')) { const match = line.match(/@@ -(\d+),\d+ \+(\d+),\d+ @@/); const oldStart = match ? parseInt(match[1]) : 0; const newStart = match ? parseInt(match[2]) : 0; const changes: Array<{ type: 'context' | 'added' | 'removed'; content: string; oldLine?: number; newLine?: number; }> = []; let oldLineNum = oldStart; let newLineNum = newStart; let j = i + 1; while (j < lines.length && !lines[j].startsWith('@@') && !lines[j].startsWith('Index:')) { const contentLine = lines[j]; if (contentLine.startsWith('+')) { changes.push({ type: 'added', content: contentLine.substring(1), newLine: newLineNum }); newLineNum++; } else if (contentLine.startsWith('-')) { changes.push({ type: 'removed', content: contentLine.substring(1), oldLine: oldLineNum }); oldLineNum++; } else if (contentLine.startsWith(' ')) { changes.push({ type: 'context', content: contentLine.substring(1), oldLine: oldLineNum, newLine: newLineNum, }); oldLineNum++; newLineNum++; } j++; } const alignedLines: Array<{ leftLine: { type: 'context' | 'removed' | 'empty'; lineNumber: number | null; content: string }; rightLine: { type: 'context' | 'added' | 'empty'; lineNumber: number | null; content: string }; }> = []; const leftSide: Array<{ type: 'context' | 'removed'; lineNumber: number; content: string }> = []; const rightSide: Array<{ type: 'context' | 'added'; lineNumber: number; content: string }> = []; changes.forEach((change) => { if (change.type === 'context') { leftSide.push({ type: 'context', lineNumber: change.oldLine!, content: change.content }); rightSide.push({ type: 'context', lineNumber: change.newLine!, content: change.content }); } else if (change.type === 'removed') { leftSide.push({ type: 'removed', lineNumber: change.oldLine!, content: change.content }); } else if (change.type === 'added') { rightSide.push({ type: 'added', lineNumber: change.newLine!, content: change.content }); } }); const alignmentPoints: Array<{ leftIdx: number; rightIdx: number }> = []; leftSide.forEach((leftItem, leftIdx) => { if (leftItem.type === 'context') { const rightIdx = rightSide.findIndex((rightItem, rIdx) => rightItem.type === 'context' && rightItem.content === leftItem.content && !alignmentPoints.some((ap) => ap.rightIdx === rIdx) ); if (rightIdx >= 0) { alignmentPoints.push({ leftIdx, rightIdx }); } } }); alignmentPoints.sort((a, b) => a.leftIdx - b.leftIdx); let leftIdx = 0; let rightIdx = 0; let alignIdx = 0; while (leftIdx < leftSide.length || rightIdx < rightSide.length) { const nextAlign = alignIdx < alignmentPoints.length ? alignmentPoints[alignIdx] : null; if (nextAlign && leftIdx === nextAlign.leftIdx && rightIdx === nextAlign.rightIdx) { const leftItem = leftSide[leftIdx]; const rightItem = rightSide[rightIdx]; alignedLines.push({ leftLine: { type: 'context', lineNumber: leftItem.lineNumber, content: leftItem.content, }, rightLine: { type: 'context', lineNumber: rightItem.lineNumber, content: rightItem.content, }, }); leftIdx++; rightIdx++; alignIdx++; } else { const needProcessLeft = leftIdx < leftSide.length && (!nextAlign || leftIdx < nextAlign.leftIdx); const needProcessRight = rightIdx < rightSide.length && (!nextAlign || rightIdx < nextAlign.rightIdx); if (needProcessLeft && needProcessRight) { const leftItem = leftSide[leftIdx]; const rightItem = rightSide[rightIdx]; alignedLines.push({ leftLine: { type: leftItem.type, lineNumber: leftItem.lineNumber, content: leftItem.content, }, rightLine: { type: rightItem.type, lineNumber: rightItem.lineNumber, content: rightItem.content, }, }); leftIdx++; rightIdx++; } else if (needProcessLeft) { const leftItem = leftSide[leftIdx]; alignedLines.push({ leftLine: { type: leftItem.type, lineNumber: leftItem.lineNumber, content: leftItem.content, }, rightLine: { type: 'empty', lineNumber: null, content: '', }, }); leftIdx++; } else if (needProcessRight) { const rightItem = rightSide[rightIdx]; alignedLines.push({ leftLine: { type: 'empty', lineNumber: null, content: '', }, rightLine: { type: rightItem.type, lineNumber: rightItem.lineNumber, content: rightItem.content, }, }); rightIdx++; } else { break; } } } hunks.push({ file: currentFile, oldStart, newStart, lines: alignedLines, }); i = j; continue; } i++; } return hunks; }; export const detectLanguageFromOutput = (output: string, toolName: string, input?: Record) => { return detectToolOutputLanguage(toolName, output, input); }; export { formatInputForDisplay };