From 715401d962d77591298fd64524e9fbc88a824404 Mon Sep 17 00:00:00 2001 From: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Date: Fri, 3 Jul 2026 21:20:34 +0200 Subject: [PATCH] Batch cursor prompt stream updates --- .../test-cursor-prompt-stream-batching.mjs | 155 ++++++++++++++++++ .../src/hooks/cursorPromptResultBatcher.ts | 106 ++++++++++++ src/renderer/src/hooks/useCursorPrompt.ts | 35 +++- 3 files changed, 291 insertions(+), 5 deletions(-) create mode 100644 scripts/test-cursor-prompt-stream-batching.mjs create mode 100644 src/renderer/src/hooks/cursorPromptResultBatcher.ts diff --git a/scripts/test-cursor-prompt-stream-batching.mjs b/scripts/test-cursor-prompt-stream-batching.mjs new file mode 100644 index 00000000..b9241522 --- /dev/null +++ b/scripts/test-cursor-prompt-stream-batching.mjs @@ -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); +}); diff --git a/src/renderer/src/hooks/cursorPromptResultBatcher.ts b/src/renderer/src/hooks/cursorPromptResultBatcher.ts new file mode 100644 index 00000000..0cf68fe3 --- /dev/null +++ b/src/renderer/src/hooks/cursorPromptResultBatcher.ts @@ -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; + clearTimer?: (handle: ReturnType) => 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 | 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, + }; +} diff --git a/src/renderer/src/hooks/useCursorPrompt.ts b/src/renderer/src/hooks/useCursorPrompt.ts index a601bf1e..27e0647b 100644 --- a/src/renderer/src/hooks/useCursorPrompt.ts +++ b/src/renderer/src/hooks/useCursorPrompt.ts @@ -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 ────────────────────────────────────────────────────── @@ -53,7 +57,7 @@ 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(''); @@ -61,6 +65,18 @@ export function useCursorPrompt({ const cursorPromptResultRef = useRef(''); const cursorPromptSourceTextRef = useRef(''); const cursorPromptInputRef = useRef(null); + const cursorPromptResultBatcherRef = useRef(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 ────────────────────────────────── @@ -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.'); @@ -118,6 +135,12 @@ export function useCursorPrompt({ }; }, [applyCursorPromptResultToEditor]); + useEffect(() => { + return () => { + cursorPromptResultBatcherRef.current?.dispose(); + }; + }, []); + // ── Focus cursor prompt input when shown ──────────────────────── useEffect(() => { @@ -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) { @@ -212,6 +235,7 @@ export function useCursorPrompt({ } catch {} cursorPromptRequestIdRef.current = null; } + cursorPromptResultBatcherRef.current?.cancelPendingFlush(); setShowCursorPrompt(false); window.electron.hideWindow(); }, [setShowCursorPrompt]); @@ -223,7 +247,8 @@ export function useCursorPrompt({ setCursorPromptError(''); setCursorPromptSourceText(''); cursorPromptRequestIdRef.current = null; - }, []); + cursorPromptSourceTextRef.current = ''; + }, [setCursorPromptResult]); return { cursorPromptText,