From 08b76cdb6013ad9e0681af04075655ece90aabaf Mon Sep 17 00:00:00 2001 From: arkon Date: Wed, 21 Jan 2026 05:41:30 +0100 Subject: [PATCH] perf: add event debouncing to InnerLoopTracker - Add EVENT_DEBOUNCE_MS constant (50ms) for batching rapid updates - Add debounce timers and pending flags for todo/loop updates - Add emitTodoUpdateDebounced() and emitLoopUpdateDebounced() methods - Add flushPendingEvents() for testing/immediate sync needs - Update reset()/fullReset() to clear debounce timers - Replace rapid-fire emit calls with debounced versions - Reduces UI jitter from rapid consecutive updates - Tests updated to use flushPendingEvents() for synchronous testing Co-Authored-By: Claude Opus 4.5 --- src/inner-loop-tracker.ts | 124 ++++++++++++++++++++++++++++++-- test/inner-loop-tracker.test.ts | 3 + 2 files changed, 122 insertions(+), 5 deletions(-) diff --git a/src/inner-loop-tracker.ts b/src/inner-loop-tracker.ts index 99003f58..86466505 100644 --- a/src/inner-loop-tracker.ts +++ b/src/inner-loop-tracker.ts @@ -42,6 +42,13 @@ const TODO_EXPIRY_MS = 60 * 60 * 1000; */ const CLEANUP_THROTTLE_MS = 30 * 1000; +/** + * Debounce interval for event emissions (milliseconds). + * Prevents UI jitter from rapid consecutive updates. + * Default: 50ms + */ +const EVENT_DEBOUNCE_MS = 50; + // ========== Pre-compiled Regex Patterns ========== // Pre-compiled for performance (avoid re-compilation on each call) @@ -266,6 +273,18 @@ export class InnerLoopTracker extends EventEmitter { /** Timestamp of last cleanup check for throttling */ private _lastCleanupTime: number = 0; + /** Debounce timer for todoUpdate events */ + private _todoUpdateTimer: NodeJS.Timeout | null = null; + + /** Debounce timer for loopUpdate events */ + private _loopUpdateTimer: NodeJS.Timeout | null = null; + + /** Flag indicating pending todoUpdate emission */ + private _todoUpdatePending: boolean = false; + + /** Flag indicating pending loopUpdate emission */ + private _loopUpdatePending: boolean = false; + /** * Creates a new InnerLoopTracker instance. * Starts in disabled state until Ralph patterns are detected. @@ -330,12 +349,16 @@ export class InnerLoopTracker extends EventEmitter { * @fires todoUpdate */ reset(): void { + // Clear debounce timers + this.clearDebounceTimers(); + const wasEnabled = this._loopState.enabled; this._loopState = createInitialInnerLoopState(); this._loopState.enabled = wasEnabled; // Keep enabled status this._todos.clear(); this._completionPhraseCount.clear(); this._lineBuffer = ''; + // Emit immediately on reset (no debounce) this.emit('loopUpdate', this.loopState); this.emit('todoUpdate', this.todos); } @@ -348,14 +371,100 @@ export class InnerLoopTracker extends EventEmitter { * @fires todoUpdate */ fullReset(): void { + // Clear debounce timers + this.clearDebounceTimers(); + this._loopState = createInitialInnerLoopState(); this._todos.clear(); this._completionPhraseCount.clear(); this._lineBuffer = ''; + // Emit immediately on reset (no debounce) this.emit('loopUpdate', this.loopState); this.emit('todoUpdate', this.todos); } + /** + * Clear all debounce timers. + * Called during reset/fullReset to prevent stale emissions. + */ + private clearDebounceTimers(): void { + if (this._todoUpdateTimer) { + clearTimeout(this._todoUpdateTimer); + this._todoUpdateTimer = null; + } + if (this._loopUpdateTimer) { + clearTimeout(this._loopUpdateTimer); + this._loopUpdateTimer = null; + } + this._todoUpdatePending = false; + this._loopUpdatePending = false; + } + + /** + * Emit todoUpdate event with debouncing. + * Batches rapid consecutive calls to reduce UI jitter. + * The event fires after EVENT_DEBOUNCE_MS of inactivity. + */ + private emitTodoUpdateDebounced(): void { + this._todoUpdatePending = true; + + if (this._todoUpdateTimer) { + clearTimeout(this._todoUpdateTimer); + } + + this._todoUpdateTimer = setTimeout(() => { + if (this._todoUpdatePending) { + this._todoUpdatePending = false; + this._todoUpdateTimer = null; + this.emit('todoUpdate', this.todos); + } + }, EVENT_DEBOUNCE_MS); + } + + /** + * Emit loopUpdate event with debouncing. + * Batches rapid consecutive calls to reduce UI jitter. + * The event fires after EVENT_DEBOUNCE_MS of inactivity. + */ + private emitLoopUpdateDebounced(): void { + this._loopUpdatePending = true; + + if (this._loopUpdateTimer) { + clearTimeout(this._loopUpdateTimer); + } + + this._loopUpdateTimer = setTimeout(() => { + if (this._loopUpdatePending) { + this._loopUpdatePending = false; + this._loopUpdateTimer = null; + this.emit('loopUpdate', this.loopState); + } + }, EVENT_DEBOUNCE_MS); + } + + /** + * Flush all pending debounced events immediately. + * Useful for testing or when immediate state sync is needed. + */ + flushPendingEvents(): void { + if (this._todoUpdatePending) { + this._todoUpdatePending = false; + if (this._todoUpdateTimer) { + clearTimeout(this._todoUpdateTimer); + this._todoUpdateTimer = null; + } + this.emit('todoUpdate', this.todos); + } + if (this._loopUpdatePending) { + this._loopUpdatePending = false; + if (this._loopUpdateTimer) { + clearTimeout(this._loopUpdateTimer); + this._loopUpdateTimer = null; + } + this.emit('loopUpdate', this.loopState); + } + } + /** * Get a copy of the current loop state. * @returns Shallow copy of loop state (safe to modify) @@ -806,7 +915,8 @@ export class InnerLoopTracker extends EventEmitter { if (!isNaN(maxIter) && maxIter > 0) { this._loopState.maxIterations = maxIter; this._loopState.lastActivity = Date.now(); - this.emit('loopUpdate', this.loopState); + // Use debounced emit for settings changes + this.emitLoopUpdateDebounced(); } } @@ -824,7 +934,8 @@ export class InnerLoopTracker extends EventEmitter { this._loopState.maxIterations = maxIter; } this._loopState.lastActivity = Date.now(); - this.emit('loopUpdate', this.loopState); + // Use debounced emit for rapid iteration updates + this.emitLoopUpdateDebounced(); } } @@ -833,7 +944,8 @@ export class InnerLoopTracker extends EventEmitter { if (elapsedMatch) { this._loopState.elapsedHours = parseFloat(elapsedMatch[1]); this._loopState.lastActivity = Date.now(); - this.emit('loopUpdate', this.loopState); + // Use debounced emit for elapsed time updates + this.emitLoopUpdateDebounced(); } // Check for cycle count (legacy pattern) @@ -843,7 +955,8 @@ export class InnerLoopTracker extends EventEmitter { if (!isNaN(cycleNum) && cycleNum > this._loopState.cycleCount) { this._loopState.cycleCount = cycleNum; this._loopState.lastActivity = Date.now(); - this.emit('loopUpdate', this.loopState); + // Use debounced emit for cycle updates + this.emitLoopUpdateDebounced(); } } @@ -929,7 +1042,8 @@ export class InnerLoopTracker extends EventEmitter { } if (updated) { - this.emit('todoUpdate', this.todos); + // Use debounced emit to batch rapid todo updates and reduce UI jitter + this.emitTodoUpdateDebounced(); } } diff --git a/test/inner-loop-tracker.test.ts b/test/inner-loop-tracker.test.ts index 8c3db484..4a43cefc 100644 --- a/test/inner-loop-tracker.test.ts +++ b/test/inner-loop-tracker.test.ts @@ -205,6 +205,7 @@ describe('InnerLoopTracker', () => { tracker.on('todoUpdate', todoHandler); tracker.processTerminalData('- [ ] First task\n'); + tracker.flushPendingEvents(); // Flush debounced events expect(todoHandler).toHaveBeenCalled(); const todos = tracker.todos; @@ -380,7 +381,9 @@ describe('InnerLoopTracker', () => { tracker.on('todoUpdate', todoHandler); tracker.processTerminalData('- [ ] Task 1\n'); + tracker.flushPendingEvents(); // Flush debounced events tracker.processTerminalData('- [ ] Task 2\n'); + tracker.flushPendingEvents(); // Flush debounced events expect(todoHandler).toHaveBeenCalledTimes(2); });