Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 42 additions & 22 deletions src/cursorWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -169,22 +169,37 @@ 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 <uuid>/<uuid>.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;
Comment on lines +173 to +183

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for handling existing JSONL transcripts (Format A) can be improved to avoid a redundant call to readNewContent for new files and to align with the more robust if/else structure you've used for new TXT transcripts (Format B). This change will prevent readNewContent from being called twice when a new transcript is detected.

Suggested change
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 (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 <uuid>.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) {
this.log.appendLine(`[scan] Error: ${e}`);
}
}

private watchFile(filePath: string) {
private watchFile(filePath: string, isFlatTxt = false) {
try {
const fd = fs.openSync(filePath, 'r');
const stat = fs.fstatSync(fd);
Expand All @@ -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;
Expand All @@ -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));
}
}
Comment on lines +246 to 252

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There's significant code duplication in how the parsed status is handled for both flat text and JSONL files. To improve maintainability and reduce redundancy, you can extract the status processing logic into a private helper method, for example processStatus(status: ParsedStatus | null).

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();
  }
}

Then you can simplify this block.

      if (isFlatTxt) {
        this.processStatus(parseFlatTxtChunk(text));
      } else {
        const lines = text.split('\n').filter(l => l.trim());
        for (const line of lines) {
          this.processStatus(parseTranscriptLine(line));
        }
      }

} catch (e) {
Expand All @@ -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(() => {
Expand Down
87 changes: 87 additions & 0 deletions src/transcriptParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
* <user_query>...</user_query>
*
* 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;
}