Skip to content
Closed
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
155 changes: 155 additions & 0 deletions scripts/test-cursor-prompt-stream-batching.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
#!/usr/bin/env node

import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { importTs } from './lib/ts-import.mjs';

const CHUNK_COUNT = 500;
const { createCursorPromptResultBatcher } = await importTs(
path.resolve('src/renderer/src/hooks/cursorPromptResultBatcher.ts')
);

function makeChunks(count) {
return Array.from({ length: count }, (_, index) => `chunk-${index};`);
}

function simulateLegacyCursorPromptStream(chunks) {
let visibleResult = '';
let visibleUpdateCount = 0;

for (const chunk of chunks) {
visibleResult += chunk;
visibleUpdateCount += 1;
}

return { visibleResult, visibleUpdateCount };
}

function createManualFrameScheduler() {
let nextFrameId = 1;
const callbacks = new Map();

return {
requestFrame(callback) {
const id = nextFrameId;
nextFrameId += 1;
callbacks.set(id, callback);
return id;
},
cancelFrame(id) {
callbacks.delete(id);
},
flushFrames() {
const queuedCallbacks = Array.from(callbacks.values());
callbacks.clear();
for (const callback of queuedCallbacks) {
callback();
}
return queuedCallbacks.length;
},
pendingFrameCount() {
return callbacks.size;
},
};
}

function createBatcherHarness() {
const scheduler = createManualFrameScheduler();
const resultRef = { current: '' };
let visibleResult = '';
let visibleUpdateCount = 0;
const batcher = createCursorPromptResultBatcher({
resultRef,
setVisibleResult(nextResult) {
visibleResult = nextResult;
visibleUpdateCount += 1;
},
requestFrame: scheduler.requestFrame,
cancelFrame: scheduler.cancelFrame,
});

return {
batcher,
resultRef,
scheduler,
get visibleResult() {
return visibleResult;
},
get visibleUpdateCount() {
return visibleUpdateCount;
},
};
}

test('baseline cursor prompt streaming updates visible state once per chunk', () => {
const chunks = makeChunks(CHUNK_COUNT);
const expectedResult = chunks.join('');
const metrics = simulateLegacyCursorPromptStream(chunks);

assert.equal(metrics.visibleResult, expectedResult);
assert.equal(metrics.visibleUpdateCount, CHUNK_COUNT);
console.log(`baseline visible result updates: ${metrics.visibleUpdateCount} for ${CHUNK_COUNT} chunks`);
});

test('cursor prompt result batching coalesces many chunks into one visible update per frame', () => {
const chunks = makeChunks(CHUNK_COUNT);
const expectedResult = chunks.join('');
const harness = createBatcherHarness();

for (const chunk of chunks) {
harness.batcher.appendChunk(chunk);
}

assert.equal(harness.resultRef.current, expectedResult);
assert.equal(harness.visibleResult, '');
assert.equal(harness.visibleUpdateCount, 0);
assert.equal(harness.scheduler.pendingFrameCount(), 1);

assert.equal(harness.scheduler.flushFrames(), 1);
assert.equal(harness.visibleResult, expectedResult);
assert.equal(harness.visibleUpdateCount, 1);
console.log(`batched visible result updates: ${harness.visibleUpdateCount} for ${CHUNK_COUNT} chunks`);
});

test('cursor prompt result batching flushes the final pending stream synchronously', () => {
const harness = createBatcherHarness();

harness.batcher.appendChunk('final ');
harness.batcher.appendChunk('answer');
harness.batcher.flush();

assert.equal(harness.resultRef.current, 'final answer');
assert.equal(harness.visibleResult, 'final answer');
assert.equal(harness.visibleUpdateCount, 1);
assert.equal(harness.scheduler.pendingFrameCount(), 0);

assert.equal(harness.scheduler.flushFrames(), 0);
assert.equal(harness.visibleUpdateCount, 1);
});

test('cursor prompt result batching keeps apply text current before the visible frame flush', () => {
const harness = createBatcherHarness();

harness.batcher.appendChunk(' rewrite');
harness.batcher.appendChunk(' this ');

const textReadByApply = String(harness.resultRef.current || '').trim();
assert.equal(textReadByApply, 'rewrite this');
assert.equal(harness.visibleResult, '');
assert.equal(harness.visibleUpdateCount, 0);
assert.equal(harness.scheduler.pendingFrameCount(), 1);
});

test('cursor prompt result batching can cancel a pending repaint without changing authoritative text', () => {
const harness = createBatcherHarness();

harness.batcher.appendChunk('partial');
harness.batcher.cancelPendingFlush();

assert.equal(harness.resultRef.current, 'partial');
assert.equal(harness.scheduler.pendingFrameCount(), 0);
assert.equal(harness.scheduler.flushFrames(), 0);
assert.equal(harness.visibleResult, '');
assert.equal(harness.visibleUpdateCount, 0);
});
106 changes: 106 additions & 0 deletions src/renderer/src/hooks/cursorPromptResultBatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
export const CURSOR_PROMPT_RESULT_FLUSH_INTERVAL_MS = 32;

export interface CursorPromptResultRef {
current: string;
}

export interface CursorPromptResultBatcherOptions {
resultRef: CursorPromptResultRef;
setVisibleResult: (value: string) => void;
requestFrame?: (callback: () => void) => number;
cancelFrame?: (handle: number) => void;
setTimer?: (callback: () => void, delayMs: number) => ReturnType<typeof globalThis.setTimeout>;
clearTimer?: (handle: ReturnType<typeof globalThis.setTimeout>) => void;
flushDelayMs?: number;
}

export interface CursorPromptResultBatcher {
appendChunk: (chunk: string) => void;
flush: () => void;
reset: (nextResult?: string) => void;
cancelPendingFlush: () => void;
dispose: () => void;
}

function createDefaultRequestFrame(): ((callback: () => void) => number) | undefined {
if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') {
return undefined;
}
return (callback) => window.requestAnimationFrame(() => callback());
}

function createDefaultCancelFrame(): ((handle: number) => void) | undefined {
if (typeof window === 'undefined' || typeof window.cancelAnimationFrame !== 'function') {
return undefined;
}
return (handle) => window.cancelAnimationFrame(handle);
}

export function createCursorPromptResultBatcher({
resultRef,
setVisibleResult,
requestFrame = createDefaultRequestFrame(),
cancelFrame = createDefaultCancelFrame(),
setTimer = globalThis.setTimeout.bind(globalThis),
clearTimer = globalThis.clearTimeout.bind(globalThis),
flushDelayMs = CURSOR_PROMPT_RESULT_FLUSH_INTERVAL_MS,
}: CursorPromptResultBatcherOptions): CursorPromptResultBatcher {
let visibleResult = resultRef.current;
let pendingFrame: number | null = null;
let pendingTimer: ReturnType<typeof globalThis.setTimeout> | null = null;

const publishVisibleResult = () => {
const nextResult = resultRef.current;
if (nextResult === visibleResult) return;
visibleResult = nextResult;
setVisibleResult(nextResult);
};

const cancelPendingFlush = () => {
if (pendingFrame !== null) {
cancelFrame?.(pendingFrame);
pendingFrame = null;
}
if (pendingTimer !== null) {
clearTimer(pendingTimer);
pendingTimer = null;
}
};

const runScheduledFlush = () => {
pendingFrame = null;
pendingTimer = null;
publishVisibleResult();
};

const scheduleFlush = () => {
if (pendingFrame !== null || pendingTimer !== null) return;

if (requestFrame) {
pendingFrame = requestFrame(runScheduledFlush);
return;
}

pendingTimer = setTimer(runScheduledFlush, flushDelayMs);
};

return {
appendChunk(chunk: string) {
if (!chunk) return;
resultRef.current += chunk;
scheduleFlush();
},
flush() {
cancelPendingFlush();
publishVisibleResult();
},
reset(nextResult = '') {
cancelPendingFlush();
resultRef.current = nextResult;
visibleResult = nextResult;
setVisibleResult(nextResult);
},
cancelPendingFlush,
dispose: cancelPendingFlush,
};
}
35 changes: 30 additions & 5 deletions src/renderer/src/hooks/useCursorPrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@

import { useState, useRef, useCallback, useEffect } from 'react';
import { NO_AI_MODEL_ERROR } from '../utils/constants';
import {
createCursorPromptResultBatcher,
type CursorPromptResultBatcher,
} from './cursorPromptResultBatcher';

// ─── Interfaces ──────────────────────────────────────────────────────

Expand Down Expand Up @@ -53,14 +57,26 @@ export function useCursorPrompt({
}: UseCursorPromptOptions): UseCursorPromptReturn {
const [cursorPromptText, setCursorPromptText] = useState('');
const [cursorPromptStatus, setCursorPromptStatus] = useState<'idle' | 'processing' | 'ready' | 'error'>('idle');
const [cursorPromptResult, setCursorPromptResult] = useState('');
const [cursorPromptResult, setCursorPromptResultState] = useState('');
const [cursorPromptError, setCursorPromptError] = useState('');
const [cursorPromptSourceText, setCursorPromptSourceText] = useState('');

const cursorPromptRequestIdRef = useRef<string | null>(null);
const cursorPromptResultRef = useRef('');
const cursorPromptSourceTextRef = useRef('');
const cursorPromptInputRef = useRef<HTMLTextAreaElement>(null);
const cursorPromptResultBatcherRef = useRef<CursorPromptResultBatcher | null>(null);

if (!cursorPromptResultBatcherRef.current) {
cursorPromptResultBatcherRef.current = createCursorPromptResultBatcher({
resultRef: cursorPromptResultRef,
setVisibleResult: setCursorPromptResultState,
});
}

const setCursorPromptResult = useCallback((value: string) => {
cursorPromptResultBatcherRef.current?.reset(value);
}, []);

// ── Apply result to the editor ──────────────────────────────────

Expand Down Expand Up @@ -89,18 +105,19 @@ export function useCursorPrompt({
useEffect(() => {
const handleChunk = (data: { requestId: string; chunk: string }) => {
if (data.requestId === cursorPromptRequestIdRef.current) {
cursorPromptResultRef.current += data.chunk;
setCursorPromptResult((prev) => prev + data.chunk);
cursorPromptResultBatcherRef.current?.appendChunk(data.chunk);
}
};
const handleDone = (data: { requestId: string }) => {
if (data.requestId === cursorPromptRequestIdRef.current) {
cursorPromptResultBatcherRef.current?.flush();
cursorPromptRequestIdRef.current = null;
void applyCursorPromptResultToEditor();
}
};
const handleError = (data: { requestId: string; error: string }) => {
if (data.requestId === cursorPromptRequestIdRef.current) {
cursorPromptResultBatcherRef.current?.flush();
cursorPromptRequestIdRef.current = null;
setCursorPromptStatus('error');
setCursorPromptError(data.error || 'Failed to process this prompt.');
Expand All @@ -118,6 +135,12 @@ export function useCursorPrompt({
};
}, [applyCursorPromptResultToEditor]);

useEffect(() => {
return () => {
cursorPromptResultBatcherRef.current?.dispose();
};
}, []);

// ── Focus cursor prompt input when shown ────────────────────────

useEffect(() => {
Expand Down Expand Up @@ -203,7 +226,7 @@ export function useCursorPrompt({
`Instruction: ${instruction}`,
].join('\n');
await window.electron.aiAsk(requestId, compositePrompt);
}, [cursorPromptStatus, cursorPromptText, setAiAvailable]);
}, [cursorPromptStatus, cursorPromptText, setAiAvailable, setCursorPromptResult]);

const closeCursorPrompt = useCallback(async () => {
if (cursorPromptRequestIdRef.current) {
Expand All @@ -212,6 +235,7 @@ export function useCursorPrompt({
} catch {}
cursorPromptRequestIdRef.current = null;
}
cursorPromptResultBatcherRef.current?.cancelPendingFlush();
setShowCursorPrompt(false);
window.electron.hideWindow();
}, [setShowCursorPrompt]);
Expand All @@ -223,7 +247,8 @@ export function useCursorPrompt({
setCursorPromptError('');
setCursorPromptSourceText('');
cursorPromptRequestIdRef.current = null;
}, []);
cursorPromptSourceTextRef.current = '';
}, [setCursorPromptResult]);

return {
cursorPromptText,
Expand Down
Loading