From 2bfa87796d89188af46feba7da805411bfda6c84 Mon Sep 17 00:00:00 2001 From: Hulkito Date: Mon, 2 Mar 2026 09:58:16 +0100 Subject: [PATCH 1/2] fix: support flat .txt transcripts on Windows (Cursor >= 0.47) On Windows with recent versions of Cursor, agent transcripts are stored as plain-text .txt files directly inside agent-transcripts/ rather than the /.jsonl sub-directory structure used on Linux/Mac. Changes: - cursorWatcher.ts: scanAll() now detects both formats: - Format A (existing): sub-directory with /.jsonl - Format B (new): flat .txt file at root of agent-transcripts/ watchFile() and readNewContent() accept an isFlatTxt flag so each format uses the appropriate parser. - transcriptParser.ts: adds parseFlatTxtChunk(chunk) which parses the plain-text block format (user: / assistant: / [Tool call] / [Thinking] sections) and maps tool names to activity types (Read->reading, Shell->running, StrReplace->editing, Task->phoning). --- src/cursorWatcher.ts | 55 +++++++++++++++++++-------- src/transcriptParser.ts | 82 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 15 deletions(-) diff --git a/src/cursorWatcher.ts b/src/cursorWatcher.ts index 1abf84e..7738e46 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,28 @@ 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) && !this.filePositions.has(jsonlPath)) { + this.log.appendLine(`[scan] New JSONL transcript: ${entry.name}`); + this.watchFile(jsonlPath); + } + if (this.filePositions.has(jsonlPath)) { + 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 +198,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 +207,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,10 +241,9 @@ 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 (isFlatTxt) { + const status = parseFlatTxtChunk(text); if (status) { this.log.appendLine(`[activity] ${status.activity}: ${status.statusText}`); this.onStatusChange(status); @@ -238,6 +251,18 @@ export class CursorWatcher implements vscode.Disposable { this.resetIdleTimer(); } } + } else { + 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(); + } + } + } } } catch (e) { try { fs.closeSync(fd); } catch {} diff --git a/src/transcriptParser.ts b/src/transcriptParser.ts index 0c2814d..d7fe21e 100644 --- a/src/transcriptParser.ts +++ b/src/transcriptParser.ts @@ -94,3 +94,85 @@ 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) { + const tool = toolCallMatch[1]!.toLowerCase(); + if (/^(read|glob|grep|semanticsearch)$/.test(tool)) { + return { activity: 'reading', statusText: 'Working...' }; + } + if (/^(shell|bash)$/.test(tool)) { + return { activity: 'running', statusText: 'Working...' }; + } + if (/^(strreplace|write|editnotebook|delete)$/.test(tool)) { + return { activity: 'editing', statusText: 'Working...' }; + } + if (/^(task)$/.test(tool)) { + return { activity: 'phoning', statusText: 'Delegating...' }; + } + 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; +} From 3f5f83fc358da701c944b3d03bb8a9d3741cb7b6 Mon Sep 17 00:00:00 2001 From: Hulkito Date: Mon, 2 Mar 2026 10:10:06 +0100 Subject: [PATCH 2/2] refactor: address Gemini code review suggestions - cursorWatcher.ts: fix double readNewContent call in Format A (JSONL) by using if/else instead of two separate if blocks - cursorWatcher.ts: extract processStatus() helper to eliminate duplicated status-handling logic in readNewContent() - transcriptParser.ts: replace regex if-chain with switch statement for tool-to-activity mapping in parseFlatTxtChunk() --- src/cursorWatcher.ts | 43 ++++++++++++++++++----------------------- src/transcriptParser.ts | 35 +++++++++++++++++++-------------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/src/cursorWatcher.ts b/src/cursorWatcher.ts index 7738e46..15cf099 100644 --- a/src/cursorWatcher.ts +++ b/src/cursorWatcher.ts @@ -172,12 +172,13 @@ export class CursorWatcher implements vscode.Disposable { // 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) && !this.filePositions.has(jsonlPath)) { - this.log.appendLine(`[scan] New JSONL transcript: ${entry.name}`); - this.watchFile(jsonlPath); - } - if (this.filePositions.has(jsonlPath)) { - this.readNewContent(jsonlPath); + 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; } @@ -243,25 +244,10 @@ export class CursorWatcher implements vscode.Disposable { const text = buf.toString('utf-8'); if (isFlatTxt) { - const status = parseFlatTxtChunk(text); - if (status) { - this.log.appendLine(`[activity] ${status.activity}: ${status.statusText}`); - this.onStatusChange(status); - if (status.activity !== 'idle') { - this.resetIdleTimer(); - } - } + this.processStatus(parseFlatTxtChunk(text)); } else { - 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(); - } - } + for (const line of text.split('\n').filter(l => l.trim())) { + this.processStatus(parseTranscriptLine(line)); } } } catch (e) { @@ -270,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 d7fe21e..4494e54 100644 --- a/src/transcriptParser.ts +++ b/src/transcriptParser.ts @@ -143,22 +143,27 @@ export function parseFlatTxtChunk(chunk: string): ParsedStatus | null { if (currentRole === 'assistant' && line.trim()) { // [Tool call] lines give rich activity signals const toolCallMatch = line.match(/^\[Tool call\]\s+(\w+)/); - if (toolCallMatch) { - const tool = toolCallMatch[1]!.toLowerCase(); - if (/^(read|glob|grep|semanticsearch)$/.test(tool)) { - return { activity: 'reading', statusText: 'Working...' }; - } - if (/^(shell|bash)$/.test(tool)) { - return { activity: 'running', statusText: 'Working...' }; - } - if (/^(strreplace|write|editnotebook|delete)$/.test(tool)) { - return { activity: 'editing', statusText: 'Working...' }; - } - if (/^(task)$/.test(tool)) { - return { activity: 'phoning', statusText: 'Delegating...' }; + 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...' }; + } } - return { activity: 'typing', statusText: 'Working...' }; - } // Accumulate [Thinking] and plain assistant text for inference const thinkingMatch = line.match(/^\[Thinking\]\s*(.*)/);