Spaces:
Paused
Paused
| /** | |
| * Log Parser Utility | |
| * Parses agent output into structured sections for display | |
| */ | |
| import type { | |
| CursorStreamEvent, | |
| CursorSystemEvent, | |
| CursorAssistantEvent, | |
| CursorToolCallEvent, | |
| CursorResultEvent, | |
| } from '@automaker/types'; | |
| /** | |
| * Cleans up fragmented streaming text by removing spurious newlines | |
| * This handles cases where streaming providers send partial text chunks | |
| * that got separated by newlines during accumulation | |
| */ | |
| function cleanFragmentedText(content: string): string { | |
| // Remove newlines that break up words (newline between letters) | |
| // e.g., "sum\n\nmary" -> "summary" | |
| let cleaned = content.replace(/([a-zA-Z])\n+([a-zA-Z])/g, '$1$2'); | |
| // Also clean up fragmented XML-like tags | |
| // e.g., "<sum\n\nmary>" -> "<summary>" | |
| cleaned = cleaned.replace(/<([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '<$1$2>'); | |
| cleaned = cleaned.replace(/<\/([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '</$1$2>'); | |
| return cleaned; | |
| } | |
| export type LogEntryType = | |
| | 'prompt' | |
| | 'tool_call' | |
| | 'tool_result' | |
| | 'phase' | |
| | 'error' | |
| | 'success' | |
| | 'info' | |
| | 'debug' | |
| | 'warning' | |
| | 'thinking'; | |
| export type ToolCategory = | |
| | 'read' | |
| | 'edit' | |
| | 'write' | |
| | 'bash' | |
| | 'search' | |
| | 'todo' | |
| | 'task' | |
| | 'other'; | |
| const TOOL_CATEGORIES: Record<string, ToolCategory> = { | |
| Read: 'read', | |
| Edit: 'edit', | |
| Write: 'write', | |
| Bash: 'bash', | |
| Grep: 'search', | |
| Glob: 'search', | |
| Ls: 'read', | |
| Delete: 'write', | |
| WebSearch: 'search', | |
| WebFetch: 'read', | |
| TodoWrite: 'todo', | |
| Task: 'task', | |
| NotebookEdit: 'edit', | |
| KillShell: 'bash', | |
| SemanticSearch: 'search', | |
| ReadLints: 'read', | |
| }; | |
| /** | |
| * Categorizes a tool name into a predefined category | |
| */ | |
| export function categorizeToolName(toolName: string): ToolCategory { | |
| return TOOL_CATEGORIES[toolName] || 'other'; | |
| } | |
| export interface LogEntryMetadata { | |
| toolName?: string; | |
| toolCategory?: ToolCategory; | |
| filePath?: string; | |
| summary?: string; | |
| phase?: string; | |
| } | |
| export interface LogEntry { | |
| id: string; | |
| type: LogEntryType; | |
| title: string; | |
| content: string; | |
| timestamp?: string; | |
| collapsed?: boolean; | |
| metadata?: LogEntryMetadata; | |
| } | |
| /** | |
| * Generates a deterministic ID based on content and position | |
| * This ensures the same log entry always gets the same ID, | |
| * preserving expanded/collapsed state when new logs stream in | |
| * | |
| * Uses only the first 200 characters of content to ensure stability | |
| * even when entries are merged (which appends content at the end) | |
| */ | |
| const generateDeterministicId = (content: string, lineIndex: number): string => { | |
| // Use first 200 chars to ensure stability when entries are merged | |
| const stableContent = content.slice(0, 200); | |
| // Simple hash function for the content | |
| let hash = 0; | |
| const str = stableContent + '|' + lineIndex.toString(); | |
| for (let i = 0; i < str.length; i++) { | |
| const char = str.charCodeAt(i); | |
| hash = (hash << 5) - hash + char; | |
| hash = hash & hash; // Convert to 32bit integer | |
| } | |
| return 'log_' + Math.abs(hash).toString(36); | |
| }; | |
| /** | |
| * Detects the type of log entry based on content patterns | |
| */ | |
| function detectEntryType(content: string): LogEntryType { | |
| const trimmed = content.trim(); | |
| // Clean fragmented text for pattern matching | |
| const cleaned = cleanFragmentedText(trimmed); | |
| // Tool calls | |
| if (trimmed.startsWith('🔧 Tool:') || trimmed.match(/^Tool:\s*/)) { | |
| return 'tool_call'; | |
| } | |
| // Tool results / Input | |
| if ( | |
| trimmed.startsWith('Input:') || | |
| trimmed.startsWith('Result:') || | |
| trimmed.startsWith('Output:') | |
| ) { | |
| return 'tool_result'; | |
| } | |
| // Phase changes | |
| if ( | |
| trimmed.startsWith('📋') || | |
| trimmed.startsWith('⚡') || | |
| trimmed.startsWith('✅') || | |
| trimmed.match(/^(Planning|Action|Verification)/i) || | |
| trimmed.match(/\[Phase:\s*([^\]]+)\]/) || | |
| trimmed.match(/Phase:\s*\w+/i) | |
| ) { | |
| return 'phase'; | |
| } | |
| // Feature creation events | |
| if ( | |
| trimmed.match(/\[Feature Creation\]/i) || | |
| trimmed.match(/Feature Creation/i) || | |
| trimmed.match(/Creating feature/i) | |
| ) { | |
| return 'success'; | |
| } | |
| // Errors | |
| if (trimmed.startsWith('❌') || trimmed.toLowerCase().includes('error:')) { | |
| return 'error'; | |
| } | |
| // Success messages and summary sections | |
| // Check both raw and cleaned content for summary tags (handles fragmented streaming) | |
| if ( | |
| trimmed.startsWith('✅') || | |
| trimmed.toLowerCase().includes('success') || | |
| trimmed.toLowerCase().includes('completed') || | |
| // Summary tags (preferred format from agent) - check both raw and cleaned | |
| trimmed.startsWith('<summary>') || | |
| cleaned.startsWith('<summary>') || | |
| // Markdown summary headers (fallback) | |
| trimmed.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) || | |
| cleaned.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) || | |
| trimmed.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i) | |
| ) { | |
| return 'success'; | |
| } | |
| // Warnings | |
| if (trimmed.startsWith('⚠️') || trimmed.toLowerCase().includes('warning:')) { | |
| return 'warning'; | |
| } | |
| // Thinking/Preparation info (be specific to avoid matching summary content) | |
| if ( | |
| trimmed.toLowerCase().includes('ultrathink') || | |
| trimmed.match(/thinking level[:\s]*(low|medium|high|none|\d)/i) || | |
| trimmed.match(/^thinking level\s*$/i) || | |
| trimmed.toLowerCase().includes('estimated cost') || | |
| trimmed.toLowerCase().includes('estimated time') || | |
| trimmed.toLowerCase().includes('budget tokens') || | |
| trimmed.match(/thinking.*preparation/i) | |
| ) { | |
| return 'thinking'; | |
| } | |
| // Debug info (JSON, stack traces, etc.) | |
| if ( | |
| trimmed.startsWith('{') || | |
| trimmed.startsWith('[') || | |
| trimmed.includes('at ') || | |
| trimmed.match(/^\s*\d+\s*\|/) | |
| ) { | |
| return 'debug'; | |
| } | |
| // Default to info | |
| return 'info'; | |
| } | |
| /** | |
| * Extracts tool name from a tool call entry | |
| * Matches both "🔧 Tool: Name" and "Tool: Name" formats | |
| */ | |
| function extractToolName(content: string): string | undefined { | |
| // Try emoji format first, then plain format | |
| const match = content.match(/(?:🔧\s*)?Tool:\s*(\S+)/); | |
| return match?.[1]; | |
| } | |
| /** | |
| * Extracts phase name from a phase entry | |
| */ | |
| function extractPhase(content: string): string | undefined { | |
| if (content.includes('📋')) return 'planning'; | |
| if (content.includes('⚡')) return 'action'; | |
| if (content.includes('✅')) return 'verification'; | |
| // Extract from [Phase: ...] format | |
| const phaseMatch = content.match(/\[Phase:\s*([^\]]+)\]/); | |
| if (phaseMatch) { | |
| return phaseMatch[1].toLowerCase(); | |
| } | |
| const match = content.match(/^(Planning|Action|Verification)/i); | |
| return match?.[1]?.toLowerCase(); | |
| } | |
| /** | |
| * Extracts file path from tool input JSON | |
| */ | |
| function extractFilePath(content: string): string | undefined { | |
| try { | |
| const inputMatch = content.match(/Input:\s*([\s\S]*)/); | |
| if (!inputMatch) return undefined; | |
| const jsonStr = inputMatch[1].trim(); | |
| const parsed = JSON.parse(jsonStr) as Record<string, unknown>; | |
| if (typeof parsed.file_path === 'string') return parsed.file_path; | |
| if (typeof parsed.path === 'string') return parsed.path; | |
| if (typeof parsed.notebook_path === 'string') return parsed.notebook_path; | |
| return undefined; | |
| } catch { | |
| return undefined; | |
| } | |
| } | |
| /** | |
| * Generates a smart summary for tool calls based on the tool name and input | |
| */ | |
| export function generateToolSummary(toolName: string, content: string): string | undefined { | |
| try { | |
| // Try to parse JSON input | |
| const inputMatch = content.match(/Input:\s*([\s\S]*)/); | |
| if (!inputMatch) return undefined; | |
| const jsonStr = inputMatch[1].trim(); | |
| const parsed = JSON.parse(jsonStr) as Record<string, unknown>; | |
| switch (toolName) { | |
| case 'Read': { | |
| const filePath = parsed.file_path as string | undefined; | |
| return `Reading ${filePath?.split('/').pop() || 'file'}`; | |
| } | |
| case 'Edit': { | |
| const filePath = parsed.file_path as string | undefined; | |
| const fileName = filePath?.split('/').pop() || 'file'; | |
| return `Editing ${fileName}`; | |
| } | |
| case 'Write': { | |
| const filePath = parsed.file_path as string | undefined; | |
| return `Writing ${filePath?.split('/').pop() || 'file'}`; | |
| } | |
| case 'Bash': { | |
| const command = parsed.command as string | undefined; | |
| const cmd = command?.slice(0, 50) || ''; | |
| return `Running: ${cmd}${(command?.length || 0) > 50 ? '...' : ''}`; | |
| } | |
| case 'Grep': { | |
| const pattern = parsed.pattern as string | undefined; | |
| return `Searching for "${pattern?.slice(0, 30) || ''}"`; | |
| } | |
| case 'Glob': { | |
| const pattern = parsed.pattern as string | undefined; | |
| return `Finding files: ${pattern || ''}`; | |
| } | |
| case 'TodoWrite': { | |
| const todos = parsed.todos as unknown[] | undefined; | |
| const todoCount = todos?.length || 0; | |
| return `${todoCount} todo item${todoCount !== 1 ? 's' : ''}`; | |
| } | |
| case 'Task': { | |
| const subagentType = parsed.subagent_type as string | undefined; | |
| const description = parsed.description as string | undefined; | |
| return `${subagentType || 'Agent'}: ${description || ''}`; | |
| } | |
| case 'WebSearch': { | |
| const query = parsed.query as string | undefined; | |
| return `Searching: "${query?.slice(0, 40) || ''}"`; | |
| } | |
| case 'WebFetch': { | |
| const url = parsed.url as string | undefined; | |
| return `Fetching: ${url?.slice(0, 40) || ''}`; | |
| } | |
| case 'NotebookEdit': { | |
| const notebookPath = parsed.notebook_path as string | undefined; | |
| return `Editing notebook: ${notebookPath?.split('/').pop() || 'notebook'}`; | |
| } | |
| case 'KillShell': { | |
| return 'Terminating shell session'; | |
| } | |
| case 'SemanticSearch': { | |
| const query = parsed.query as string | undefined; | |
| return `Semantic search: "${query?.slice(0, 30) || ''}"`; | |
| } | |
| case 'ReadLints': { | |
| const paths = parsed.paths as string[] | undefined; | |
| const pathCount = paths?.length || 0; | |
| return `Reading lints for ${pathCount} file(s)`; | |
| } | |
| default: | |
| return undefined; | |
| } | |
| } catch { | |
| return undefined; | |
| } | |
| } | |
| // ============================================================================ | |
| // Cursor Event Parsing | |
| // ============================================================================ | |
| /** | |
| * Detect if a parsed JSON object is a Cursor stream event | |
| */ | |
| function isCursorEvent(obj: unknown): obj is CursorStreamEvent { | |
| return ( | |
| obj !== null && | |
| typeof obj === 'object' && | |
| 'type' in obj && | |
| 'session_id' in obj && | |
| ['system', 'user', 'assistant', 'tool_call', 'result'].includes( | |
| (obj as Record<string, unknown>).type as string | |
| ) | |
| ); | |
| } | |
| /** | |
| * Normalize Cursor tool call event to log entry | |
| */ | |
| function normalizeCursorToolCall( | |
| event: CursorToolCallEvent, | |
| baseEntry: { id: string; timestamp: string } | |
| ): LogEntry | null { | |
| const toolCall = event.tool_call; | |
| const isStarted = event.subtype === 'started'; | |
| const isCompleted = event.subtype === 'completed'; | |
| // Read tool | |
| if (toolCall.readToolCall) { | |
| const path = toolCall.readToolCall.args?.path || 'unknown'; | |
| const result = toolCall.readToolCall.result?.success; | |
| return { | |
| ...baseEntry, | |
| id: `${baseEntry.id}-${event.call_id}`, | |
| type: 'tool_call' as LogEntryType, | |
| title: isStarted ? `Reading ${path}` : `Read ${path}`, | |
| content: | |
| isCompleted && result | |
| ? `${result.totalLines} lines, ${result.totalChars} chars` | |
| : `Path: ${path}`, | |
| collapsed: true, | |
| metadata: { | |
| toolName: 'Read', | |
| toolCategory: 'read' as ToolCategory, | |
| filePath: path, | |
| summary: isCompleted ? `Read ${result?.totalLines || 0} lines` : `Reading file...`, | |
| }, | |
| }; | |
| } | |
| // Write tool | |
| if (toolCall.writeToolCall) { | |
| const path = | |
| toolCall.writeToolCall.args?.path || | |
| toolCall.writeToolCall.result?.success?.path || | |
| 'unknown'; | |
| const result = toolCall.writeToolCall.result?.success; | |
| return { | |
| ...baseEntry, | |
| id: `${baseEntry.id}-${event.call_id}`, | |
| type: 'tool_call' as LogEntryType, | |
| title: isStarted ? `Writing ${path}` : `Wrote ${path}`, | |
| content: | |
| isCompleted && result | |
| ? `${result.linesCreated} lines, ${result.fileSize} bytes` | |
| : `Path: ${path}`, | |
| collapsed: true, | |
| metadata: { | |
| toolName: 'Write', | |
| toolCategory: 'write' as ToolCategory, | |
| filePath: path, | |
| summary: isCompleted ? `Wrote ${result?.linesCreated || 0} lines` : `Writing file...`, | |
| }, | |
| }; | |
| } | |
| // Edit tool | |
| if (toolCall.editToolCall) { | |
| const path = toolCall.editToolCall.args?.path || 'unknown'; | |
| return { | |
| ...baseEntry, | |
| id: `${baseEntry.id}-${event.call_id}`, | |
| type: 'tool_call' as LogEntryType, | |
| title: isStarted ? `Editing ${path}` : `Edited ${path}`, | |
| content: `Path: ${path}`, | |
| collapsed: true, | |
| metadata: { | |
| toolName: 'Edit', | |
| toolCategory: 'edit' as ToolCategory, | |
| filePath: path, | |
| summary: isCompleted ? `Edited file` : `Editing file...`, | |
| }, | |
| }; | |
| } | |
| // Shell/Bash tool | |
| if (toolCall.shellToolCall) { | |
| const command = toolCall.shellToolCall.args?.command || ''; | |
| const result = toolCall.shellToolCall.result; | |
| const shortCmd = command.length > 50 ? command.slice(0, 50) + '...' : command; | |
| let content = `Command: ${command}`; | |
| if (isCompleted && result?.success) { | |
| content += `\nExit code: ${result.success.exitCode}`; | |
| } else if (isCompleted && result?.rejected) { | |
| content += `\nRejected: ${result.rejected.reason}`; | |
| } | |
| return { | |
| ...baseEntry, | |
| id: `${baseEntry.id}-${event.call_id}`, | |
| type: 'tool_call' as LogEntryType, | |
| title: isStarted ? `Running: ${shortCmd}` : `Ran: ${shortCmd}`, | |
| content, | |
| collapsed: true, | |
| metadata: { | |
| toolName: 'Bash', | |
| toolCategory: 'bash' as ToolCategory, | |
| summary: isCompleted | |
| ? result?.success | |
| ? `Exit ${result.success.exitCode}` | |
| : result?.rejected | |
| ? 'Rejected' | |
| : 'Completed' | |
| : `Running...`, | |
| }, | |
| }; | |
| } | |
| // Delete tool | |
| if (toolCall.deleteToolCall) { | |
| const path = toolCall.deleteToolCall.args?.path || 'unknown'; | |
| const result = toolCall.deleteToolCall.result; | |
| let content = `Path: ${path}`; | |
| if (isCompleted && result?.rejected) { | |
| content += `\nRejected: ${result.rejected.reason}`; | |
| } | |
| return { | |
| ...baseEntry, | |
| id: `${baseEntry.id}-${event.call_id}`, | |
| type: 'tool_call' as LogEntryType, | |
| title: isStarted ? `Deleting ${path}` : `Deleted ${path}`, | |
| content, | |
| collapsed: true, | |
| metadata: { | |
| toolName: 'Delete', | |
| toolCategory: 'write' as ToolCategory, | |
| filePath: path, | |
| summary: isCompleted ? (result?.rejected ? 'Rejected' : 'Deleted') : `Deleting...`, | |
| }, | |
| }; | |
| } | |
| // Grep tool | |
| if (toolCall.grepToolCall) { | |
| const pattern = toolCall.grepToolCall.args?.pattern || ''; | |
| const searchPath = toolCall.grepToolCall.args?.path; | |
| const result = toolCall.grepToolCall.result?.success; | |
| return { | |
| ...baseEntry, | |
| id: `${baseEntry.id}-${event.call_id}`, | |
| type: 'tool_call' as LogEntryType, | |
| title: isStarted ? `Searching: "${pattern}"` : `Searched: "${pattern}"`, | |
| content: `Pattern: ${pattern}${searchPath ? `\nPath: ${searchPath}` : ''}${ | |
| isCompleted && result ? `\nMatched ${result.matchedLines} lines` : '' | |
| }`, | |
| collapsed: true, | |
| metadata: { | |
| toolName: 'Grep', | |
| toolCategory: 'search' as ToolCategory, | |
| summary: isCompleted ? `Found ${result?.matchedLines || 0} matches` : `Searching...`, | |
| }, | |
| }; | |
| } | |
| // Ls tool | |
| if (toolCall.lsToolCall) { | |
| const path = toolCall.lsToolCall.args?.path || '.'; | |
| const result = toolCall.lsToolCall.result?.success; | |
| return { | |
| ...baseEntry, | |
| id: `${baseEntry.id}-${event.call_id}`, | |
| type: 'tool_call' as LogEntryType, | |
| title: isStarted ? `Listing ${path}` : `Listed ${path}`, | |
| content: `Path: ${path}${ | |
| isCompleted && result | |
| ? `\n${result.childrenFiles} files, ${result.childrenDirs} directories` | |
| : '' | |
| }`, | |
| collapsed: true, | |
| metadata: { | |
| toolName: 'Ls', | |
| toolCategory: 'read' as ToolCategory, | |
| filePath: path, | |
| summary: isCompleted | |
| ? `${result?.childrenFiles || 0} files, ${result?.childrenDirs || 0} dirs` | |
| : `Listing...`, | |
| }, | |
| }; | |
| } | |
| // Glob tool | |
| if (toolCall.globToolCall) { | |
| const pattern = toolCall.globToolCall.args?.globPattern || ''; | |
| const targetDir = toolCall.globToolCall.args?.targetDirectory; | |
| const result = toolCall.globToolCall.result?.success; | |
| return { | |
| ...baseEntry, | |
| id: `${baseEntry.id}-${event.call_id}`, | |
| type: 'tool_call' as LogEntryType, | |
| title: isStarted ? `Finding: ${pattern}` : `Found: ${pattern}`, | |
| content: `Pattern: ${pattern}${targetDir ? `\nDirectory: ${targetDir}` : ''}${ | |
| isCompleted && result ? `\nFound ${result.totalFiles} files` : '' | |
| }`, | |
| collapsed: true, | |
| metadata: { | |
| toolName: 'Glob', | |
| toolCategory: 'search' as ToolCategory, | |
| summary: isCompleted ? `Found ${result?.totalFiles || 0} files` : `Finding...`, | |
| }, | |
| }; | |
| } | |
| // Semantic Search tool | |
| if (toolCall.semSearchToolCall) { | |
| const query = toolCall.semSearchToolCall.args?.query || ''; | |
| const targetDirs = toolCall.semSearchToolCall.args?.targetDirectories; | |
| const result = toolCall.semSearchToolCall.result?.success; | |
| const shortQuery = query.length > 40 ? query.slice(0, 40) + '...' : query; | |
| const resultCount = result?.codeResults?.length || 0; | |
| return { | |
| ...baseEntry, | |
| id: `${baseEntry.id}-${event.call_id}`, | |
| type: 'tool_call' as LogEntryType, | |
| title: isStarted ? `Semantic search: "${shortQuery}"` : `Searched: "${shortQuery}"`, | |
| content: `Query: ${query}${targetDirs?.length ? `\nDirectories: ${targetDirs.join(', ')}` : ''}${ | |
| isCompleted | |
| ? `\n${resultCount > 0 ? `Found ${resultCount} result(s)` : result?.results || 'No results'}` | |
| : '' | |
| }`, | |
| collapsed: true, | |
| metadata: { | |
| toolName: 'SemanticSearch', | |
| toolCategory: 'search' as ToolCategory, | |
| summary: isCompleted | |
| ? resultCount > 0 | |
| ? `Found ${resultCount} result(s)` | |
| : 'No results' | |
| : `Searching...`, | |
| }, | |
| }; | |
| } | |
| // Read Lints tool | |
| if (toolCall.readLintsToolCall) { | |
| const paths = toolCall.readLintsToolCall.args?.paths || []; | |
| const result = toolCall.readLintsToolCall.result?.success; | |
| const pathCount = paths.length; | |
| return { | |
| ...baseEntry, | |
| id: `${baseEntry.id}-${event.call_id}`, | |
| type: 'tool_call' as LogEntryType, | |
| title: isStarted ? `Reading lints for ${pathCount} file(s)` : `Read lints`, | |
| content: `Paths: ${paths.join(', ')}${ | |
| isCompleted && result | |
| ? `\nFound ${result.totalDiagnostics} diagnostic(s) in ${result.totalFiles} file(s)` | |
| : '' | |
| }`, | |
| collapsed: true, | |
| metadata: { | |
| toolName: 'ReadLints', | |
| toolCategory: 'read' as ToolCategory, | |
| summary: isCompleted | |
| ? `${result?.totalDiagnostics || 0} diagnostic(s)` | |
| : `Reading lints...`, | |
| }, | |
| }; | |
| } | |
| // Generic function tool (fallback) | |
| if (toolCall.function) { | |
| const name = toolCall.function.name; | |
| const args = toolCall.function.arguments; | |
| // Determine category based on tool name | |
| const category = categorizeToolName(name); | |
| return { | |
| ...baseEntry, | |
| id: `${baseEntry.id}-${event.call_id}`, | |
| type: 'tool_call' as LogEntryType, | |
| title: `${name} ${isStarted ? 'started' : 'completed'}`, | |
| content: args || '', | |
| collapsed: true, | |
| metadata: { | |
| toolName: name, | |
| toolCategory: category, | |
| summary: `${name} ${event.subtype}`, | |
| }, | |
| }; | |
| } | |
| return null; | |
| } | |
| /** | |
| * Normalize Cursor stream event to log entry | |
| */ | |
| export function normalizeCursorEvent(event: CursorStreamEvent): LogEntry | null { | |
| const timestamp = new Date().toISOString(); | |
| const baseEntry = { | |
| id: `cursor-${event.session_id}-${Date.now()}`, | |
| timestamp, | |
| }; | |
| switch (event.type) { | |
| case 'system': { | |
| const sysEvent = event as CursorSystemEvent; | |
| return { | |
| ...baseEntry, | |
| type: 'info' as LogEntryType, | |
| title: 'Session Started', | |
| content: `Model: ${sysEvent.model}\nAuth: ${sysEvent.apiKeySource}\nCWD: ${sysEvent.cwd}`, | |
| collapsed: true, | |
| metadata: { | |
| phase: 'init', | |
| }, | |
| }; | |
| } | |
| case 'assistant': { | |
| const assistEvent = event as CursorAssistantEvent; | |
| const text = assistEvent.message.content | |
| .filter((c) => c.type === 'text') | |
| .map((c) => c.text) | |
| .join(''); | |
| if (!text.trim()) return null; | |
| return { | |
| ...baseEntry, | |
| type: 'info' as LogEntryType, | |
| title: 'Assistant', | |
| content: text, | |
| collapsed: false, | |
| }; | |
| } | |
| case 'tool_call': { | |
| const toolEvent = event as CursorToolCallEvent; | |
| return normalizeCursorToolCall(toolEvent, baseEntry); | |
| } | |
| case 'result': { | |
| const resultEvent = event as CursorResultEvent; | |
| if (resultEvent.is_error) { | |
| return { | |
| ...baseEntry, | |
| type: 'error' as LogEntryType, | |
| title: 'Error', | |
| content: resultEvent.error || resultEvent.result || 'Unknown error', | |
| collapsed: false, | |
| }; | |
| } | |
| return { | |
| ...baseEntry, | |
| type: 'success' as LogEntryType, | |
| title: 'Completed', | |
| content: `Duration: ${resultEvent.duration_ms}ms`, | |
| collapsed: true, | |
| }; | |
| } | |
| default: | |
| return null; | |
| } | |
| } | |
| /** | |
| * Parse a single log line into a structured entry | |
| * Handles both Cursor JSON events and plain text | |
| */ | |
| export function parseLogLine(line: string): LogEntry | null { | |
| if (!line.trim()) return null; | |
| try { | |
| const parsed = JSON.parse(line); | |
| // Check if it's a Cursor stream event | |
| if (isCursorEvent(parsed)) { | |
| return normalizeCursorEvent(parsed); | |
| } | |
| // For other JSON, treat as debug info | |
| return { | |
| id: `json-${Date.now()}-${Math.random().toString(36).slice(2)}`, | |
| type: 'debug', | |
| title: 'Debug Info', | |
| content: line, | |
| timestamp: new Date().toISOString(), | |
| collapsed: true, | |
| }; | |
| } catch { | |
| // Non-JSON line - treat as plain text | |
| return { | |
| id: `text-${Date.now()}-${Math.random().toString(36).slice(2)}`, | |
| type: 'info', | |
| title: 'Output', | |
| content: line, | |
| timestamp: new Date().toISOString(), | |
| collapsed: false, | |
| }; | |
| } | |
| } | |
| /** | |
| * Get provider-specific styling for log entries | |
| */ | |
| export function getProviderStyle(entry: LogEntry): { badge?: string; icon?: string } { | |
| // Check if entry has Cursor session ID pattern | |
| if (entry.id.startsWith('cursor-')) { | |
| return { | |
| badge: 'Cursor', | |
| icon: 'terminal', | |
| }; | |
| } | |
| // Default (Claude/AutoMaker) | |
| return { | |
| badge: 'Claude', | |
| icon: 'bot', | |
| }; | |
| } | |
| /** | |
| * Determines if an entry should be collapsed by default | |
| */ | |
| export function shouldCollapseByDefault(entry: LogEntry): boolean { | |
| // Collapse if content is long | |
| if (entry.content.length > 200) return true; | |
| // Collapse if contains multi-line JSON (> 5 lines) | |
| const lineCount = entry.content.split('\n').length; | |
| if (lineCount > 5 && (entry.content.includes('{') || entry.content.includes('['))) { | |
| return true; | |
| } | |
| // Collapse TodoWrite with multiple items | |
| if (entry.metadata?.toolName === 'TodoWrite') { | |
| try { | |
| const inputMatch = entry.content.match(/Input:\s*([\s\S]*)/); | |
| if (inputMatch) { | |
| const parsed = JSON.parse(inputMatch[1].trim()) as Record<string, unknown>; | |
| const todos = parsed.todos as unknown[] | undefined; | |
| if (todos && todos.length > 1) return true; | |
| } | |
| } catch { | |
| // Ignore parse errors | |
| } | |
| } | |
| // Collapse Edit with code blocks | |
| if (entry.metadata?.toolName === 'Edit' && entry.content.includes('old_string')) { | |
| return true; | |
| } | |
| return false; | |
| } | |
| /** | |
| * Generates a title for a log entry | |
| */ | |
| function generateTitle(type: LogEntryType, content: string): string { | |
| // Clean content for pattern matching | |
| const cleaned = cleanFragmentedText(content); | |
| switch (type) { | |
| case 'tool_call': { | |
| const toolName = extractToolName(content); | |
| return toolName ? `Tool Call: ${toolName}` : 'Tool Call'; | |
| } | |
| case 'tool_result': | |
| return 'Tool Input/Result'; | |
| case 'phase': { | |
| const phase = extractPhase(content); | |
| if (phase) { | |
| // Capitalize first letter of each word | |
| const formatted = phase | |
| .split(/\s+/) | |
| .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) | |
| .join(' '); | |
| return `Phase: ${formatted}`; | |
| } | |
| return 'Phase Change'; | |
| } | |
| case 'error': | |
| return 'Error'; | |
| case 'success': { | |
| // Check if it's a summary section (check both raw and cleaned) | |
| if ( | |
| content.startsWith('<summary>') || | |
| content.includes('<summary>') || | |
| cleaned.startsWith('<summary>') || | |
| cleaned.includes('<summary>') | |
| ) { | |
| return 'Summary'; | |
| } | |
| if ( | |
| content.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) || | |
| cleaned.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) | |
| ) { | |
| return 'Summary'; | |
| } | |
| if ( | |
| content.match(/^All tasks completed/i) || | |
| content.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i) | |
| ) { | |
| return 'Summary'; | |
| } | |
| return 'Success'; | |
| } | |
| case 'warning': | |
| return 'Warning'; | |
| case 'thinking': | |
| return 'Thinking Level'; | |
| case 'debug': | |
| return 'Debug Info'; | |
| case 'prompt': | |
| return 'Prompt'; | |
| default: | |
| return 'Info'; | |
| } | |
| } | |
| /** | |
| * Tracks bracket depth for JSON accumulation | |
| */ | |
| function calculateBracketDepth(line: string): { braceChange: number; bracketChange: number } { | |
| let braceChange = 0; | |
| let bracketChange = 0; | |
| let inString = false; | |
| let escapeNext = false; | |
| for (const char of line) { | |
| if (escapeNext) { | |
| escapeNext = false; | |
| continue; | |
| } | |
| if (char === '\\') { | |
| escapeNext = true; | |
| continue; | |
| } | |
| if (char === '"') { | |
| inString = !inString; | |
| continue; | |
| } | |
| if (inString) continue; | |
| if (char === '{') braceChange++; | |
| else if (char === '}') braceChange--; | |
| else if (char === '[') bracketChange++; | |
| else if (char === ']') bracketChange--; | |
| } | |
| return { braceChange, bracketChange }; | |
| } | |
| /** | |
| * Parses raw log output into structured entries | |
| */ | |
| export function parseLogOutput(rawOutput: string): LogEntry[] { | |
| if (!rawOutput || !rawOutput.trim()) { | |
| return []; | |
| } | |
| const entries: LogEntry[] = []; | |
| const lines = rawOutput.split('\n'); | |
| let currentEntry: (Omit<LogEntry, 'id'> & { id?: string }) | null = null; | |
| let currentContent: string[] = []; | |
| let entryStartLine = 0; // Track the starting line for deterministic ID generation | |
| // JSON accumulation state | |
| let inJsonAccumulation = false; | |
| let jsonBraceDepth = 0; | |
| let jsonBracketDepth = 0; | |
| // Summary tag accumulation state | |
| let inSummaryAccumulation = false; | |
| const finalizeEntry = () => { | |
| if (currentEntry && currentContent.length > 0) { | |
| currentEntry.content = currentContent.join('\n').trim(); | |
| if (currentEntry.content) { | |
| // Populate enhanced metadata for tool calls | |
| const toolName = currentEntry.metadata?.toolName; | |
| if (toolName && currentEntry.type === 'tool_call') { | |
| const toolCategory = categorizeToolName(toolName); | |
| const filePath = extractFilePath(currentEntry.content); | |
| const summary = generateToolSummary(toolName, currentEntry.content); | |
| currentEntry.metadata = { | |
| ...currentEntry.metadata, | |
| toolCategory, | |
| filePath, | |
| summary, | |
| }; | |
| } | |
| // Generate deterministic ID based on content and position | |
| const entryWithId: LogEntry = { | |
| ...(currentEntry as Omit<LogEntry, 'id'>), | |
| id: generateDeterministicId(currentEntry.content, entryStartLine), | |
| }; | |
| entries.push(entryWithId); | |
| } | |
| } | |
| currentContent = []; | |
| inJsonAccumulation = false; | |
| jsonBraceDepth = 0; | |
| jsonBracketDepth = 0; | |
| inSummaryAccumulation = false; | |
| }; | |
| let lineIndex = 0; | |
| for (const line of lines) { | |
| const trimmedLine = line.trim(); | |
| // Skip empty lines at the beginning | |
| if (!trimmedLine && !currentEntry) { | |
| lineIndex++; | |
| continue; | |
| } | |
| // Check for Cursor stream events (NDJSON lines) | |
| // These are complete JSON objects on a single line | |
| if (trimmedLine.startsWith('{') && trimmedLine.endsWith('}')) { | |
| try { | |
| const parsed = JSON.parse(trimmedLine); | |
| if (isCursorEvent(parsed)) { | |
| // Finalize any pending entry before adding Cursor event | |
| finalizeEntry(); | |
| const cursorEntry = normalizeCursorEvent(parsed); | |
| if (cursorEntry) { | |
| entries.push(cursorEntry); | |
| } | |
| lineIndex++; | |
| continue; | |
| } | |
| } catch { | |
| // Not valid JSON, continue with normal parsing | |
| } | |
| } | |
| // If we're in JSON accumulation mode, keep accumulating until depth returns to 0 | |
| if (inJsonAccumulation) { | |
| currentContent.push(line); | |
| const { braceChange, bracketChange } = calculateBracketDepth(trimmedLine); | |
| jsonBraceDepth += braceChange; | |
| jsonBracketDepth += bracketChange; | |
| // JSON is complete when depth returns to 0 | |
| if (jsonBraceDepth <= 0 && jsonBracketDepth <= 0) { | |
| inJsonAccumulation = false; | |
| jsonBraceDepth = 0; | |
| jsonBracketDepth = 0; | |
| } | |
| lineIndex++; | |
| continue; | |
| } | |
| // If we're in summary accumulation mode, keep accumulating until </summary> | |
| if (inSummaryAccumulation) { | |
| currentContent.push(line); | |
| // Summary is complete when we see closing tag | |
| if (trimmedLine.includes('</summary>')) { | |
| inSummaryAccumulation = false; | |
| // Don't finalize here - let normal flow handle it | |
| } | |
| lineIndex++; | |
| continue; | |
| } | |
| // Detect if this line starts a new entry | |
| const lineType = detectEntryType(trimmedLine); | |
| const isNewEntry = | |
| trimmedLine.startsWith('🔧') || | |
| trimmedLine.startsWith('📋') || | |
| trimmedLine.startsWith('⚡') || | |
| trimmedLine.startsWith('✅') || | |
| trimmedLine.startsWith('❌') || | |
| trimmedLine.startsWith('⚠️') || | |
| trimmedLine.startsWith('🧠') || | |
| trimmedLine.match(/\[Phase:\s*([^\]]+)\]/) || | |
| trimmedLine.match(/\[Feature Creation\]/i) || | |
| trimmedLine.match(/\[Tool\]/i) || | |
| trimmedLine.match(/\[Agent\]/i) || | |
| trimmedLine.match(/\[Complete\]/i) || | |
| trimmedLine.match(/\[ERROR\]/i) || | |
| trimmedLine.match(/\[Status\]/i) || | |
| trimmedLine.toLowerCase().includes('ultrathink preparation') || | |
| trimmedLine.match(/thinking level[:\s]*(low|medium|high|none|\d)/i) || | |
| // Summary tags (preferred format from agent) - check both raw and cleaned for fragmented streaming | |
| trimmedLine.startsWith('<summary>') || | |
| cleanFragmentedText(trimmedLine).startsWith('<summary>') || | |
| // Agent summary sections (markdown headers - fallback) | |
| trimmedLine.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) || | |
| cleanFragmentedText(trimmedLine).match(/^##\s+(Summary|Feature|Changes|Implementation)/i) || | |
| // Summary introduction lines | |
| trimmedLine.match(/^All tasks completed/i) || | |
| trimmedLine.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i); | |
| // Check if this is an Input: line that should trigger JSON accumulation | |
| const isInputLine = trimmedLine.startsWith('Input:') && currentEntry?.type === 'tool_call'; | |
| if (isNewEntry) { | |
| // Finalize previous entry | |
| finalizeEntry(); | |
| // Track starting line for deterministic ID | |
| entryStartLine = lineIndex; | |
| // Start new entry (ID will be generated when finalizing) | |
| currentEntry = { | |
| type: lineType, | |
| title: generateTitle(lineType, trimmedLine), | |
| content: '', | |
| metadata: { | |
| toolName: extractToolName(trimmedLine), | |
| phase: extractPhase(trimmedLine), | |
| }, | |
| }; | |
| currentContent.push(trimmedLine); | |
| // If this is a <summary> tag, start summary accumulation mode | |
| // Check both raw and cleaned for fragmented streaming | |
| const cleanedTrimmed = cleanFragmentedText(trimmedLine); | |
| if ( | |
| (trimmedLine.startsWith('<summary>') || cleanedTrimmed.startsWith('<summary>')) && | |
| !trimmedLine.includes('</summary>') && | |
| !cleanedTrimmed.includes('</summary>') | |
| ) { | |
| inSummaryAccumulation = true; | |
| } | |
| } else if (isInputLine && currentEntry) { | |
| // Start JSON accumulation mode | |
| currentContent.push(trimmedLine); | |
| // Check if there's JSON on the same line after "Input:" | |
| const inputContent = trimmedLine.replace(/^Input:\s*/, ''); | |
| if (inputContent) { | |
| const { braceChange, bracketChange } = calculateBracketDepth(inputContent); | |
| jsonBraceDepth = braceChange; | |
| jsonBracketDepth = bracketChange; | |
| // Only enter accumulation mode if JSON is incomplete | |
| if (jsonBraceDepth > 0 || jsonBracketDepth > 0) { | |
| inJsonAccumulation = true; | |
| } | |
| } else { | |
| // Input: line with JSON starting on next line | |
| inJsonAccumulation = true; | |
| } | |
| } else if (currentEntry) { | |
| // Continue current entry | |
| currentContent.push(line); | |
| // Check if this line starts a JSON block | |
| if (trimmedLine.startsWith('{') || trimmedLine.startsWith('[')) { | |
| const { braceChange, bracketChange } = calculateBracketDepth(trimmedLine); | |
| if (braceChange > 0 || bracketChange > 0) { | |
| jsonBraceDepth = braceChange; | |
| jsonBracketDepth = bracketChange; | |
| if (jsonBraceDepth > 0 || jsonBracketDepth > 0) { | |
| inJsonAccumulation = true; | |
| } | |
| } | |
| } | |
| } else { | |
| // Track starting line for deterministic ID | |
| entryStartLine = lineIndex; | |
| // No current entry, create a default info entry | |
| currentEntry = { | |
| type: 'info', | |
| title: 'Info', | |
| content: '', | |
| }; | |
| currentContent.push(line); | |
| } | |
| lineIndex++; | |
| } | |
| // Finalize last entry | |
| finalizeEntry(); | |
| // Merge consecutive entries of the same type if they're both debug or info | |
| const mergedEntries = mergeConsecutiveEntries(entries); | |
| return mergedEntries; | |
| } | |
| /** | |
| * Merges consecutive entries of the same type for cleaner display | |
| */ | |
| function mergeConsecutiveEntries(entries: LogEntry[]): LogEntry[] { | |
| if (entries.length <= 1) return entries; | |
| const merged: LogEntry[] = []; | |
| let current: LogEntry | null = null; | |
| let mergeIndex = 0; | |
| for (const entry of entries) { | |
| if ( | |
| current && | |
| (current.type === 'debug' || current.type === 'info') && | |
| current.type === entry.type | |
| ) { | |
| // Merge into current - regenerate ID based on merged content | |
| current.content += '\n\n' + entry.content; | |
| current.id = generateDeterministicId(current.content, mergeIndex); | |
| } else { | |
| if (current) { | |
| merged.push(current); | |
| } | |
| current = { ...entry }; | |
| mergeIndex = merged.length; | |
| } | |
| } | |
| if (current) { | |
| merged.push(current); | |
| } | |
| return merged; | |
| } | |
| /** | |
| * Extracts summary content from raw log output | |
| * Returns the LAST summary text if found, or null if no summary exists | |
| * This ensures we get the most recent/updated summary when multiple exist | |
| */ | |
| export function extractSummary(rawOutput: string): string | null { | |
| if (!rawOutput || !rawOutput.trim()) { | |
| return null; | |
| } | |
| // First, clean up any fragmented text from streaming | |
| // This handles cases where streaming providers send partial text chunks | |
| // that got separated by newlines during accumulation (e.g., "<sum\n\nmary>") | |
| const cleanedOutput = cleanFragmentedText(rawOutput); | |
| // Define regex patterns to try in order of priority | |
| // Each pattern specifies a processor function to extract the summary from the match | |
| const regexesToTry: Array<{ | |
| regex: RegExp; | |
| processor: (m: RegExpMatchArray) => string; | |
| }> = [ | |
| { regex: /<summary>([\s\S]*?)<\/summary>/gi, processor: (m) => m[1] }, | |
| { regex: /^##\s+Summary[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, processor: (m) => m[1] }, | |
| { | |
| regex: /^##\s+(Feature|Changes|Implementation)[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, | |
| processor: (m) => `## ${m[1]}\n${m[2]}`, | |
| }, | |
| { | |
| regex: /(^|\n)(All tasks completed[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g, | |
| processor: (m) => m[2], | |
| }, | |
| { | |
| regex: | |
| /(^|\n)((I've|I have) (successfully |now )?(completed|finished|implemented)[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g, | |
| processor: (m) => m[2], | |
| }, | |
| ]; | |
| for (const { regex, processor } of regexesToTry) { | |
| const matches = [...cleanedOutput.matchAll(regex)]; | |
| if (matches.length > 0) { | |
| const lastMatch = matches[matches.length - 1]; | |
| return cleanFragmentedText(processor(lastMatch)).trim(); | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * Parses an accumulated summary string into individual phase summaries. | |
| * | |
| * The accumulated summary format uses markdown headers with `###` for phase names | |
| * and `---` as separators between phases: | |
| * | |
| * ``` | |
| * ### Implementation | |
| * | |
| * [content] | |
| * | |
| * --- | |
| * | |
| * ### Testing | |
| * | |
| * [content] | |
| * ``` | |
| * | |
| * @param summary - The accumulated summary string to parse | |
| * @returns A map of phase names (lowercase) to their content, or empty map if not parseable | |
| */ | |
| const PHASE_SEPARATOR = '\n\n---\n\n'; | |
| const PHASE_SEPARATOR_REGEX = /\n\n---\n\n/; | |
| const PHASE_HEADER_REGEX = /^###\s+(.+?)(?:\n|$)/; | |
| const PHASE_HEADER_WITH_PREFIX_REGEX = /^(###\s+)(.+?)(?:\n|$)/; | |
| function getPhaseSections(summary: string): { | |
| sections: string[]; | |
| leadingImplementationSection: string | null; | |
| } { | |
| const sections = summary.split(PHASE_SEPARATOR_REGEX); | |
| const hasSeparator = summary.includes(PHASE_SEPARATOR); | |
| const hasAnyHeader = sections.some((section) => PHASE_HEADER_REGEX.test(section.trim())); | |
| const firstSection = sections[0]?.trim() ?? ''; | |
| const leadingImplementationSection = | |
| hasSeparator && hasAnyHeader && firstSection && !PHASE_HEADER_REGEX.test(firstSection) | |
| ? firstSection | |
| : null; | |
| return { sections, leadingImplementationSection }; | |
| } | |
| export function parsePhaseSummaries(summary: string | undefined): Map<string, string> { | |
| const phaseSummaries = new Map<string, string>(); | |
| if (!summary || !summary.trim()) { | |
| return phaseSummaries; | |
| } | |
| const { sections, leadingImplementationSection } = getPhaseSections(summary); | |
| // Backward compatibility for mixed format: | |
| // [implementation summary without header] + --- + [### Pipeline Step ...] | |
| // Treat the leading headerless section as "Implementation". | |
| if (leadingImplementationSection) { | |
| phaseSummaries.set('implementation', leadingImplementationSection); | |
| } | |
| for (const section of sections) { | |
| // Match the phase header pattern: ### Phase Name | |
| const headerMatch = section.match(PHASE_HEADER_REGEX); | |
| if (headerMatch) { | |
| const phaseName = headerMatch[1].trim().toLowerCase(); | |
| // Extract content after the header (skip the header line and leading newlines) | |
| const content = section.substring(headerMatch[0].length).trim(); | |
| phaseSummaries.set(phaseName, content); | |
| } | |
| } | |
| return phaseSummaries; | |
| } | |
| /** | |
| * Extracts a specific phase summary from an accumulated summary string. | |
| * | |
| * @param summary - The accumulated summary string | |
| * @param phaseName - The phase name to extract (case-insensitive, e.g., "Implementation", "implementation") | |
| * @returns The content for the specified phase, or null if not found | |
| */ | |
| export function extractPhaseSummary(summary: string | undefined, phaseName: string): string | null { | |
| const phaseSummaries = parsePhaseSummaries(summary); | |
| const normalizedPhaseName = phaseName.toLowerCase(); | |
| return phaseSummaries.get(normalizedPhaseName) || null; | |
| } | |
| /** | |
| * Gets the implementation phase summary from an accumulated summary string. | |
| * | |
| * This is a convenience function that handles various naming conventions: | |
| * - "implementation" | |
| * - "Implementation" | |
| * - Any phase that contains "implement" in its name | |
| * | |
| * @param summary - The accumulated summary string | |
| * @returns The implementation phase content, or null if not found | |
| */ | |
| export function extractImplementationSummary(summary: string | undefined): string | null { | |
| if (!summary || !summary.trim()) { | |
| return null; | |
| } | |
| const phaseSummaries = parsePhaseSummaries(summary); | |
| // Try exact match first | |
| const implementationContent = phaseSummaries.get('implementation'); | |
| if (implementationContent) { | |
| return implementationContent; | |
| } | |
| // Fallback: find any phase containing "implement" | |
| for (const [phaseName, content] of phaseSummaries) { | |
| if (phaseName.includes('implement')) { | |
| return content; | |
| } | |
| } | |
| // If no phase summaries found, the summary might not be in accumulated format | |
| // (legacy or non-pipeline feature). In this case, return the whole summary | |
| // if it looks like a single summary (no phase headers). | |
| if (!summary.includes('### ') && !summary.includes(PHASE_SEPARATOR)) { | |
| return summary; | |
| } | |
| return null; | |
| } | |
| /** | |
| * Checks if a summary string is in the accumulated multi-phase format. | |
| * | |
| * @param summary - The summary string to check | |
| * @returns True if the summary has multiple phases, false otherwise | |
| */ | |
| export function isAccumulatedSummary(summary: string | undefined): boolean { | |
| if (!summary || !summary.trim()) { | |
| return false; | |
| } | |
| // Check for the presence of phase headers with separator | |
| const hasMultiplePhases = | |
| summary.includes(PHASE_SEPARATOR) && (summary.match(/###\s+.+/g)?.length ?? 0) > 0; | |
| return hasMultiplePhases; | |
| } | |
| /** | |
| * Represents a single phase entry in an accumulated summary. | |
| */ | |
| export interface PhaseSummaryEntry { | |
| /** The phase name (e.g., "Implementation", "Testing", "Code Review") */ | |
| phaseName: string; | |
| /** The content of this phase's summary */ | |
| content: string; | |
| /** The original header line (e.g., "### Implementation") */ | |
| header: string; | |
| } | |
| /** Default phase name used for non-accumulated summaries */ | |
| const DEFAULT_PHASE_NAME = 'Summary'; | |
| /** | |
| * Parses an accumulated summary into individual phase entries. | |
| * Returns phases in the order they appear in the summary. | |
| * | |
| * The accumulated summary format: | |
| * ``` | |
| * ### Implementation | |
| * | |
| * [content] | |
| * | |
| * --- | |
| * | |
| * ### Testing | |
| * | |
| * [content] | |
| * ``` | |
| * | |
| * @param summary - The accumulated summary string to parse | |
| * @returns Array of PhaseSummaryEntry objects, or empty array if not parseable | |
| */ | |
| export function parseAllPhaseSummaries(summary: string | undefined): PhaseSummaryEntry[] { | |
| const entries: PhaseSummaryEntry[] = []; | |
| if (!summary || !summary.trim()) { | |
| return entries; | |
| } | |
| // Check if this is an accumulated summary (has phase headers at line starts) | |
| // Use a more precise check: ### must be at the start of a line (not just anywhere in content) | |
| const hasPhaseHeaders = /^###\s+/m.test(summary); | |
| if (!hasPhaseHeaders) { | |
| // Not an accumulated summary - return as single entry with generic name | |
| return [ | |
| { phaseName: DEFAULT_PHASE_NAME, content: summary, header: `### ${DEFAULT_PHASE_NAME}` }, | |
| ]; | |
| } | |
| const { sections, leadingImplementationSection } = getPhaseSections(summary); | |
| // Backward compatibility for mixed format: | |
| // [implementation summary without header] + --- + [### Pipeline Step ...] | |
| if (leadingImplementationSection) { | |
| entries.push({ | |
| phaseName: 'Implementation', | |
| content: leadingImplementationSection, | |
| header: '### Implementation', | |
| }); | |
| } | |
| for (const section of sections) { | |
| // Match the phase header pattern: ### Phase Name | |
| const headerMatch = section.match(PHASE_HEADER_WITH_PREFIX_REGEX); | |
| if (headerMatch) { | |
| const header = headerMatch[0].trim(); | |
| const phaseName = headerMatch[2].trim(); | |
| // Extract content after the header (skip the header line and leading newlines) | |
| const content = section.substring(headerMatch[0].length).trim(); | |
| entries.push({ phaseName, content, header }); | |
| } | |
| } | |
| // Fallback: if we detected phase headers but couldn't parse any entries, | |
| // treat the entire summary as a single entry to avoid showing "No summary available" | |
| if (entries.length === 0) { | |
| return [ | |
| { phaseName: DEFAULT_PHASE_NAME, content: summary, header: `### ${DEFAULT_PHASE_NAME}` }, | |
| ]; | |
| } | |
| return entries; | |
| } | |
| export function getLogTypeColors(type: LogEntryType): { | |
| bg: string; | |
| border: string; | |
| text: string; | |
| icon: string; | |
| badge: string; | |
| } { | |
| switch (type) { | |
| case 'prompt': | |
| return { | |
| bg: 'bg-blue-500/10', | |
| border: 'border-blue-500/30', | |
| text: 'text-blue-300', | |
| icon: 'text-blue-400', | |
| badge: 'bg-blue-500/20 text-blue-300', | |
| }; | |
| case 'tool_call': | |
| return { | |
| bg: 'bg-amber-500/10', | |
| border: 'border-amber-500/30', | |
| text: 'text-amber-300', | |
| icon: 'text-amber-400', | |
| badge: 'bg-amber-500/20 text-amber-300', | |
| }; | |
| case 'tool_result': | |
| return { | |
| bg: 'bg-slate-500/10', | |
| border: 'border-slate-400/30', | |
| text: 'text-slate-300', | |
| icon: 'text-slate-400', | |
| badge: 'bg-slate-500/20 text-slate-300', | |
| }; | |
| case 'phase': | |
| return { | |
| bg: 'bg-cyan-500/10', | |
| border: 'border-cyan-500/30', | |
| text: 'text-cyan-300', | |
| icon: 'text-cyan-400', | |
| badge: 'bg-cyan-500/20 text-cyan-300', | |
| }; | |
| case 'error': | |
| return { | |
| bg: 'bg-red-500/10', | |
| border: 'border-red-500/30', | |
| text: 'text-red-300', | |
| icon: 'text-red-400', | |
| badge: 'bg-red-500/20 text-red-300', | |
| }; | |
| case 'success': | |
| return { | |
| bg: 'bg-emerald-500/20', | |
| border: 'border-emerald-500/40', | |
| text: 'text-emerald-200', | |
| icon: 'text-emerald-400', | |
| badge: 'bg-emerald-500/30 text-emerald-200', | |
| }; | |
| case 'warning': | |
| return { | |
| bg: 'bg-orange-500/10', | |
| border: 'border-orange-500/30', | |
| text: 'text-orange-300', | |
| icon: 'text-orange-400', | |
| badge: 'bg-orange-500/20 text-orange-300', | |
| }; | |
| case 'thinking': | |
| return { | |
| bg: 'bg-indigo-500/10', | |
| border: 'border-indigo-500/30', | |
| text: 'text-indigo-300', | |
| icon: 'text-indigo-400', | |
| badge: 'bg-indigo-500/20 text-indigo-300', | |
| }; | |
| case 'debug': | |
| return { | |
| bg: 'bg-primary/10', | |
| border: 'border-primary/30', | |
| text: 'text-primary', | |
| icon: 'text-primary', | |
| badge: 'bg-primary/20 text-primary', | |
| }; | |
| case 'info': | |
| return { | |
| bg: 'bg-zinc-500/10', | |
| border: 'border-zinc-500/30', | |
| text: 'text-primary', | |
| icon: 'text-zinc-400', | |
| badge: 'bg-zinc-500/20 text-primary', | |
| }; | |
| default: | |
| return { | |
| bg: 'bg-zinc-500/10', | |
| border: 'border-zinc-500/30', | |
| text: 'text-black', | |
| icon: 'text-zinc-400', | |
| badge: 'bg-zinc-500/20 text-black', | |
| }; | |
| } | |
| } | |