diff --git a/src/cursorWatcher.ts b/src/cursorWatcher.ts index 1abf84e..15cf099 100644 --- a/src/cursorWatcher.ts +++ b/src/cursorWatcher.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as vscode from 'vscode'; -import { parseTranscriptLine, ParsedStatus } from './transcriptParser'; +import { parseTranscriptLine, parseFlatTxtChunk, ParsedStatus } from './transcriptParser'; import { isHooksInstalled, getStateFilePath } from './hooksInstaller'; export class CursorWatcher implements vscode.Disposable { @@ -169,14 +169,29 @@ export class CursorWatcher implements vscode.Disposable { try { const entries = fs.readdirSync(this.transcriptsDir, { withFileTypes: true }); for (const entry of entries) { - if (!entry.isDirectory()) continue; - const jsonlPath = path.join(this.transcriptsDir, entry.name, entry.name + '.jsonl'); - if (fs.existsSync(jsonlPath) && !this.filePositions.has(jsonlPath)) { - this.log.appendLine(`[scan] New transcript: ${entry.name}`); - this.watchFile(jsonlPath); + // Format A: sub-directory with /.jsonl (Linux/Mac) + if (entry.isDirectory()) { + const jsonlPath = path.join(this.transcriptsDir, entry.name, entry.name + '.jsonl'); + if (fs.existsSync(jsonlPath)) { + if (!this.filePositions.has(jsonlPath)) { + this.log.appendLine(`[scan] New JSONL transcript: ${entry.name}`); + this.watchFile(jsonlPath); + } else { + this.readNewContent(jsonlPath); + } + } + continue; } - if (this.filePositions.has(jsonlPath)) { - this.readNewContent(jsonlPath); + + // Format B: flat .txt file (Windows / Cursor ≥ 0.47) + if (entry.isFile() && entry.name.endsWith('.txt')) { + const txtPath = path.join(this.transcriptsDir, entry.name); + if (!this.filePositions.has(txtPath)) { + this.log.appendLine(`[scan] New TXT transcript: ${entry.name}`); + this.watchFile(txtPath, true); + } else { + this.readNewContent(txtPath, true); + } } } } catch (e) { @@ -184,7 +199,7 @@ export class CursorWatcher implements vscode.Disposable { } } - private watchFile(filePath: string) { + private watchFile(filePath: string, isFlatTxt = false) { try { const fd = fs.openSync(filePath, 'r'); const stat = fs.fstatSync(fd); @@ -193,17 +208,17 @@ export class CursorWatcher implements vscode.Disposable { this.log.appendLine(`[watch] ${path.basename(filePath)} from pos ${this.filePositions.get(filePath)}`); const watcher = fs.watch(filePath, { persistent: false }, () => { - this.readNewContent(filePath); + this.readNewContent(filePath, isFlatTxt); }); this.watchers.push(watcher); - this.readNewContent(filePath); + this.readNewContent(filePath, isFlatTxt); } catch (e) { this.log.appendLine(`[watch] Error: ${filePath} ${e}`); } } - private readNewContent(filePath: string) { + private readNewContent(filePath: string, isFlatTxt = false) { const prevPos = this.filePositions.get(filePath) ?? 0; let fd: number; @@ -227,16 +242,12 @@ export class CursorWatcher implements vscode.Disposable { this.log.appendLine(`[read] ${path.basename(filePath)} +${bytesToRead} bytes (${prevPos} → ${stat.size})`); const text = buf.toString('utf-8'); - const lines = text.split('\n').filter(l => l.trim()); - - for (const line of lines) { - const status = parseTranscriptLine(line); - if (status) { - this.log.appendLine(`[activity] ${status.activity}: ${status.statusText}`); - this.onStatusChange(status); - if (status.activity !== 'idle') { - this.resetIdleTimer(); - } + + if (isFlatTxt) { + this.processStatus(parseFlatTxtChunk(text)); + } else { + for (const line of text.split('\n').filter(l => l.trim())) { + this.processStatus(parseTranscriptLine(line)); } } } catch (e) { @@ -245,6 +256,15 @@ export class CursorWatcher implements vscode.Disposable { } } + private processStatus(status: ParsedStatus | null) { + if (!status) return; + this.log.appendLine(`[activity] ${status.activity}: ${status.statusText}`); + this.onStatusChange(status); + if (status.activity !== 'idle') { + this.resetIdleTimer(); + } + } + private resetIdleTimer() { if (this.idleTimer) clearTimeout(this.idleTimer); this.idleTimer = setTimeout(() => { diff --git a/src/transcriptParser.ts b/src/transcriptParser.ts index 0c2814d..4494e54 100644 --- a/src/transcriptParser.ts +++ b/src/transcriptParser.ts @@ -94,3 +94,90 @@ export function parseTranscriptLine(line: string): ParsedStatus | null { return null; } } + +/** + * Parses a flat-text transcript block (Cursor on Windows / recent versions). + * + * Cursor stores transcripts as plain-text `.txt` files directly inside + * `agent-transcripts/`, using a block format like: + * + * user: + * ... + * + * assistant: + * [Thinking] ... + * [Tool call] Read + * [Tool result] ... + * + * This function receives a multi-line chunk of new content appended to the + * file and returns the first meaningful status inferred from it. + */ +export function parseFlatTxtChunk(chunk: string): ParsedStatus | null { + const lines = chunk.split('\n'); + let currentRole: 'user' | 'assistant' | null = null; + let assistantBuffer = ''; + + for (const raw of lines) { + const line = raw.trimEnd(); + + if (line === 'user:') { + if (assistantBuffer.trim()) { + const status = inferActivityFromText(assistantBuffer.trim()); + if (status) return status; + } + currentRole = 'user'; + assistantBuffer = ''; + continue; + } + + if (line === 'assistant:') { + currentRole = 'assistant'; + assistantBuffer = ''; + continue; + } + + if (currentRole === 'user' && line.trim()) { + return { activity: 'idle', statusText: null }; + } + + if (currentRole === 'assistant' && line.trim()) { + // [Tool call] lines give rich activity signals + const toolCallMatch = line.match(/^\[Tool call\]\s+(\w+)/); + if (toolCallMatch) { + switch (toolCallMatch[1]!.toLowerCase()) { + case 'read': + case 'glob': + case 'grep': + case 'semanticsearch': + return { activity: 'reading', statusText: 'Working...' }; + case 'shell': + case 'bash': + return { activity: 'running', statusText: 'Working...' }; + case 'strreplace': + case 'write': + case 'editnotebook': + case 'delete': + return { activity: 'editing', statusText: 'Working...' }; + case 'task': + return { activity: 'phoning', statusText: 'Delegating...' }; + default: + return { activity: 'typing', statusText: 'Working...' }; + } + } + + // Accumulate [Thinking] and plain assistant text for inference + const thinkingMatch = line.match(/^\[Thinking\]\s*(.*)/); + if (thinkingMatch) { + assistantBuffer += (thinkingMatch[1] ?? '') + ' '; + } else if (!line.startsWith('[Tool result]') && !line.startsWith('[Tool')) { + assistantBuffer += line + ' '; + } + } + } + + if (assistantBuffer.trim()) { + return inferActivityFromText(assistantBuffer.trim()); + } + + return null; +}