From 6b03b3cd0a97af07c10ceec331db34d77fc437b1 Mon Sep 17 00:00:00 2001 From: Shirone Date: Sun, 28 Dec 2025 00:58:32 +0100 Subject: [PATCH] feat: Enhance log parser to support Cursor CLI events - Added functions to detect and normalize Cursor stream events, including tool calls and system messages. - Updated `parseLogLine` to handle Cursor events and integrate them into the log entry structure. - Marked completion of the log parser integration phase in the project plan. --- apps/ui/src/lib/log-parser.ts | 266 ++++++++++++++++++ plan/cursor-cli-integration/README.md | 2 +- .../phases/phase-5-log-parser.md | 22 +- 3 files changed, 278 insertions(+), 12 deletions(-) diff --git a/apps/ui/src/lib/log-parser.ts b/apps/ui/src/lib/log-parser.ts index b7a86abe..4a162a5f 100644 --- a/apps/ui/src/lib/log-parser.ts +++ b/apps/ui/src/lib/log-parser.ts @@ -3,6 +3,14 @@ * Parses agent output into structured sections for display */ +import type { + CursorStreamEvent, + CursorSystemEvent, + CursorAssistantEvent, + CursorToolCallEvent, + CursorResultEvent, +} from '@automaker/types'; + export type LogEntryType = | 'prompt' | 'tool_call' @@ -300,6 +308,244 @@ export function generateToolSummary(toolName: string, content: string): string | } } +// ============================================================================ +// 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).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; + 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...`, + }, + }; + } + + // Generic function tool + 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 */ @@ -489,6 +735,26 @@ export function parseLogOutput(rawOutput: string): LogEntry[] { 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); diff --git a/plan/cursor-cli-integration/README.md b/plan/cursor-cli-integration/README.md index f06ed881..1f26cc3b 100644 --- a/plan/cursor-cli-integration/README.md +++ b/plan/cursor-cli-integration/README.md @@ -11,7 +11,7 @@ | 2 | [Cursor Provider Implementation](phases/phase-2-provider.md) | `completed` | ✅ | | 3 | [Provider Factory Integration](phases/phase-3-factory.md) | `completed` | ✅ | | 4 | [Setup Routes & Status Endpoints](phases/phase-4-routes.md) | `completed` | ✅ | -| 5 | [Log Parser Integration](phases/phase-5-log-parser.md) | `pending` | - | +| 5 | [Log Parser Integration](phases/phase-5-log-parser.md) | `completed` | ✅ | | 6 | [UI Setup Wizard](phases/phase-6-setup-wizard.md) | `pending` | - | | 7 | [Settings View Provider Tabs](phases/phase-7-settings.md) | `pending` | - | | 8 | [AI Profiles Integration](phases/phase-8-profiles.md) | `pending` | - | diff --git a/plan/cursor-cli-integration/phases/phase-5-log-parser.md b/plan/cursor-cli-integration/phases/phase-5-log-parser.md index 9ba54b53..7c28da6f 100644 --- a/plan/cursor-cli-integration/phases/phase-5-log-parser.md +++ b/plan/cursor-cli-integration/phases/phase-5-log-parser.md @@ -1,6 +1,6 @@ # Phase 5: Log Parser Integration -**Status:** `pending` +**Status:** `completed` **Dependencies:** Phase 2 (Provider), Phase 3 (Factory) **Estimated Effort:** Small (parser extension) @@ -16,7 +16,7 @@ Update the log parser to recognize and normalize Cursor CLI stream events for di ### Task 5.1: Add Cursor Event Type Detection -**Status:** `pending` +**Status:** `completed` **File:** `apps/ui/src/lib/log-parser.ts` @@ -216,7 +216,7 @@ function normalizeCursorToolCall( ### Task 5.2: Update parseLogLine Function -**Status:** `pending` +**Status:** `completed` **File:** `apps/ui/src/lib/log-parser.ts` @@ -255,7 +255,7 @@ export function parseLogLine(line: string): LogEntry | null { ### Task 5.3: Add Cursor-Specific Styling (Optional) -**Status:** `pending` +**Status:** `completed` **File:** `apps/ui/src/lib/log-parser.ts` @@ -348,13 +348,13 @@ console.log('Parsed entries:', entries.length); Before marking this phase complete: -- [ ] `isCursorEvent()` correctly identifies Cursor events -- [ ] `normalizeCursorEvent()` handles all event types -- [ ] Tool calls are categorized correctly -- [ ] File paths extracted for Read/Write tools -- [ ] Existing Claude event parsing not broken -- [ ] Log viewer displays Cursor events correctly -- [ ] No runtime errors with malformed events +- [x] `isCursorEvent()` correctly identifies Cursor events +- [x] `normalizeCursorEvent()` handles all event types +- [x] Tool calls are categorized correctly +- [x] File paths extracted for Read/Write tools +- [x] Existing Claude event parsing not broken +- [x] Log viewer displays Cursor events correctly +- [x] No runtime errors with malformed events ---