@esso0428/pi-subagents 0.15.15 → 0.15.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -59,10 +59,21 @@ export interface ConversationTimelineMouseResult {
59
59
  focusedBlockIndex?: number;
60
60
  }
61
61
 
62
+ export interface ConversationTimelineLineRange {
63
+ startLine: number;
64
+ height: number;
65
+ }
66
+
67
+ export interface ConversationTimelineChange {
68
+ kind: "content" | "interaction";
69
+ /** Lines patched in-place for an interaction-only change such as hover. */
70
+ changedRanges?: readonly ConversationTimelineLineRange[];
71
+ }
72
+
62
73
  export interface ConversationTimelineOptions {
63
74
  cwd?: string;
64
75
  record?: AgentRecord;
65
- onChange?: () => void;
76
+ onChange?: (change: ConversationTimelineChange) => void;
66
77
  }
67
78
 
68
79
  function safeWidth(width: number): number {
@@ -157,6 +168,7 @@ export class ConversationTimeline implements Component {
157
168
  private expandedTools = new Set<string>();
158
169
  private hoveredToolCallId: string | undefined;
159
170
  private hoveredIoView: any;
171
+ private hoveredIoToolCallId: string | undefined;
160
172
  private hoveredIoSection: "input" | "output" | null = null;
161
173
  private lastClickedToolCallId: string | undefined;
162
174
  private lastClickedPoint: { x: number; y: number } | undefined;
@@ -182,6 +194,12 @@ export class ConversationTimeline implements Component {
182
194
  const ids = new Set(this.blocks.filter((block) => block.kind === "tool" && block.toolCallId).map((block) => block.toolCallId!));
183
195
  if (this.focusedToolCallId && !ids.has(this.focusedToolCallId)) this.focusedToolCallId = undefined;
184
196
  if (this.hoveredToolCallId && !ids.has(this.hoveredToolCallId)) this.hoveredToolCallId = undefined;
197
+ if (this.hoveredIoToolCallId && !ids.has(this.hoveredIoToolCallId)) {
198
+ this.hoveredIoView?.setHoveredSection?.(null);
199
+ this.hoveredIoView = undefined;
200
+ this.hoveredIoToolCallId = undefined;
201
+ this.hoveredIoSection = null;
202
+ }
185
203
  for (const id of this.expandedTools) if (!ids.has(id)) this.expandedTools.delete(id);
186
204
  for (const id of this.toolComponents.keys()) if (!ids.has(id)) this.toolComponents.delete(id);
187
205
  this.invalidate();
@@ -193,6 +211,10 @@ export class ConversationTimeline implements Component {
193
211
  if (!show) {
194
212
  this.focusedToolCallId = undefined;
195
213
  this.hoveredToolCallId = undefined;
214
+ this.hoveredIoView?.setHoveredSection?.(null);
215
+ this.hoveredIoView = undefined;
216
+ this.hoveredIoToolCallId = undefined;
217
+ this.hoveredIoSection = null;
196
218
  }
197
219
  this.invalidate();
198
220
  }
@@ -202,8 +224,10 @@ export class ConversationTimeline implements Component {
202
224
  }
203
225
 
204
226
  setFocusedToolCallId(id: string | undefined): void {
227
+ // Focus changes are view state. The rendered rows do not change, so keep
228
+ // the snapshot and its Markdown/tool output cache intact.
229
+ if (this.focusedToolCallId === id) return;
205
230
  this.focusedToolCallId = id;
206
- this.invalidate();
207
231
  }
208
232
 
209
233
  getFocusedToolCallId(): string | undefined {
@@ -223,8 +247,7 @@ export class ConversationTimeline implements Component {
223
247
  ? direction > 0 ? 0 : ids.length - 1
224
248
  : (current + direction + ids.length) % ids.length;
225
249
  this.focusedToolCallId = ids[next];
226
- this.invalidate();
227
- this.options.onChange?.();
250
+ this.options.onChange?.({ kind: "interaction" });
228
251
  return true;
229
252
  }
230
253
 
@@ -234,7 +257,7 @@ export class ConversationTimeline implements Component {
234
257
  if (this.expandedTools.has(id)) this.expandedTools.delete(id);
235
258
  else this.expandedTools.add(id);
236
259
  this.invalidate();
237
- this.options.onChange?.();
260
+ this.options.onChange?.({ kind: "content" });
238
261
  return true;
239
262
  }
240
263
 
@@ -244,21 +267,24 @@ export class ConversationTimeline implements Component {
244
267
 
245
268
  render(width: number): string[] {
246
269
  const requestedWidth = safeWidth(width);
270
+ if (this.lastWidth === requestedWidth) return this.snapshot.lines.map((line) => line.text);
271
+
247
272
  const lines: TimelineRenderLine[] = [];
248
273
  const messageStarts: number[] = [];
249
274
  const messageBlocks: ConversationBlock[] = [];
250
275
  const interactiveToolIds: string[] = [];
251
276
  const blockSpans: TimelineBlockSpan[] = [];
252
277
  const toolSpans: TimelineToolSpan[] = [];
253
- const visibleBlocks = this.blocks.filter((block) => this.showTools || block.kind !== "tool");
254
278
 
255
- if (visibleBlocks.length === 0) {
279
+ if (this.blocks.length === 0 || (!this.showTools && this.blocks.every((block) => block.kind === "tool"))) {
256
280
  const text = this.blocks.length === 0 ? "(waiting for first message...)" : "(tools hidden)";
257
281
  lines.push({ text: this.theme.fg("dim", text), plain: text, blockIndex: -1 });
258
282
  }
259
283
 
260
- for (const block of visibleBlocks) {
261
- const blockIndex = this.blocks.indexOf(block);
284
+ for (let sourceBlockIndex = 0; sourceBlockIndex < this.blocks.length; sourceBlockIndex++) {
285
+ const block = this.blocks[sourceBlockIndex]!;
286
+ if (!this.showTools && block.kind === "tool") continue;
287
+ const blockIndex = sourceBlockIndex;
262
288
  if (lines.length > 0) {
263
289
  const separator = this.theme.fg("border", " " + "─ ".repeat(Math.max(1, Math.floor((requestedWidth - 6) / 2))).trimEnd());
264
290
  lines.push({ text: truncateToWidth(separator, requestedWidth), plain: stripAnsi(separator), blockIndex });
@@ -277,51 +303,11 @@ export class ConversationTimeline implements Component {
277
303
  if (block.kind === "tool") {
278
304
  const id = block.toolCallId;
279
305
  if (id) interactiveToolIds.push(id);
280
- let component: (Component & { handleMouse?: (event: TuiMouseEvent) => unknown }) | undefined;
281
- if (id) {
282
- const existing = this.toolComponents.get(id);
283
- if (existing) {
284
- applyToolComponentState(existing, block, this.expandedTools.has(id));
285
- component = existing;
286
- } else {
287
- component = createToolComponent(
288
- block,
289
- this.tui,
290
- this.options.cwd ?? process.cwd(),
291
- this.expandedTools.has(id),
292
- () => this.hoveredToolCallId === id,
293
- ) as (Component & { handleMouse?: (event: TuiMouseEvent) => unknown }) | undefined;
294
- if (component) this.toolComponents.set(id, component);
295
- }
296
- }
297
- let rendered: string[];
298
- try {
299
- rendered = component?.render(requestedWidth) ?? fallbackToolLines(
300
- block,
301
- requestedWidth,
302
- id ? this.expandedTools.has(id) : false,
303
- id ? this.hoveredToolCallId === id : false,
304
- this.theme,
305
- );
306
- } catch {
307
- rendered = fallbackToolLines(
308
- block,
309
- requestedWidth,
310
- id ? this.expandedTools.has(id) : false,
311
- id ? this.hoveredToolCallId === id : false,
312
- this.theme,
313
- );
314
- }
306
+ const { rendered, hintLine } = this.renderToolLines(block, requestedWidth);
315
307
  for (const line of rendered) {
316
308
  lines.push({ text: truncateToWidth(line, requestedWidth), plain: stripAnsi(line), blockIndex, railable: true });
317
309
  }
318
310
  if (id) {
319
- const hintLine = !this.expandedTools.has(id)
320
- ? rendered.findIndex((line) => {
321
- const plain = stripAnsi(line);
322
- return plain.includes(showMoreHintText()) || plain.includes("Enter to expand");
323
- })
324
- : -1;
325
311
  const blockHeight = lines.length - blockStartLine;
326
312
  blockSpans.push({ blockIndex: snapshotBlockIndex, startLine: blockStartLine, height: blockHeight });
327
313
  toolSpans.push({
@@ -363,6 +349,79 @@ export class ConversationTimeline implements Component {
363
349
  return lines.map((line) => line.text);
364
350
  }
365
351
 
352
+ private renderToolLines(block: ConversationBlock, width: number): { rendered: string[]; hintLine: number } {
353
+ const id = block.toolCallId;
354
+ let component: (Component & { handleMouse?: (event: TuiMouseEvent) => unknown }) | undefined;
355
+ if (id) {
356
+ const existing = this.toolComponents.get(id);
357
+ if (existing) {
358
+ applyToolComponentState(existing, block, this.expandedTools.has(id));
359
+ component = existing;
360
+ } else {
361
+ component = createToolComponent(
362
+ block,
363
+ this.tui,
364
+ this.options.cwd ?? process.cwd(),
365
+ this.expandedTools.has(id),
366
+ () => this.hoveredToolCallId === id,
367
+ ) as (Component & { handleMouse?: (event: TuiMouseEvent) => unknown }) | undefined;
368
+ if (component) this.toolComponents.set(id, component);
369
+ }
370
+ }
371
+
372
+ let rendered: string[];
373
+ try {
374
+ rendered = component?.render(width) ?? fallbackToolLines(
375
+ block,
376
+ width,
377
+ id ? this.expandedTools.has(id) : false,
378
+ id ? this.hoveredToolCallId === id : false,
379
+ this.theme,
380
+ );
381
+ } catch {
382
+ rendered = fallbackToolLines(
383
+ block,
384
+ width,
385
+ id ? this.expandedTools.has(id) : false,
386
+ id ? this.hoveredToolCallId === id : false,
387
+ this.theme,
388
+ );
389
+ }
390
+ const hintLine = id && !this.expandedTools.has(id)
391
+ ? rendered.findIndex((line) => {
392
+ const plain = stripAnsi(line);
393
+ return plain.includes(showMoreHintText()) || plain.includes("Enter to expand");
394
+ })
395
+ : -1;
396
+ return { rendered, hintLine };
397
+ }
398
+
399
+ /**
400
+ * Hover only changes styling in a tool card. Re-render those rows in place
401
+ * and leave the Markdown rows and their spans untouched.
402
+ */
403
+ private refreshToolSpan(span: TimelineToolSpan): boolean {
404
+ const block = this.snapshot.messageBlocks[span.blockIndex];
405
+ if (!block || block.kind !== "tool") return false;
406
+ const { rendered, hintLine } = this.renderToolLines(block, this.lastWidth);
407
+ if (rendered.length + (block.role === "user" || block.role === "assistant" ? 1 : 0) !== span.height) return false;
408
+
409
+ const headerOffset = block.role === "user" || block.role === "assistant" ? 1 : 0;
410
+ const sourceBlockIndex = this.snapshot.lines[span.startLine]?.blockIndex ?? -1;
411
+ for (let index = 0; index < rendered.length; index++) {
412
+ const line = rendered[index];
413
+ this.snapshot.lines[span.startLine + headerOffset + index] = {
414
+ text: truncateToWidth(line, this.lastWidth),
415
+ plain: stripAnsi(line),
416
+ blockIndex: sourceBlockIndex,
417
+ railable: true,
418
+ };
419
+ }
420
+ span.clickStartLine = hintLine >= 0 ? span.startLine + headerOffset + hintLine : undefined;
421
+ span.clickHeight = hintLine >= 0 ? 1 : undefined;
422
+ return true;
423
+ }
424
+
366
425
  handleMouse(event: TuiMouseEvent): ConversationTimelineMouseResult | undefined {
367
426
  if (event.type === "move") {
368
427
  const hovered = this.snapshot.toolSpans.find((span) =>
@@ -392,12 +451,35 @@ export class ConversationTimeline implements Component {
392
451
  if (this.hoveredIoView && this.hoveredIoView !== nextIoView && typeof this.hoveredIoView.setHoveredSection === "function") {
393
452
  this.hoveredIoView.setHoveredSection(null);
394
453
  }
454
+ const previousHoveredToolCallId = this.hoveredToolCallId;
455
+ const previousIoToolCallId = this.hoveredIoToolCallId;
456
+ const nextIoToolCallId = nextIoView ? fullSpan?.toolCallId : undefined;
395
457
  this.hoveredToolCallId = hovered;
396
458
  this.hoveredIoView = nextIoView;
459
+ this.hoveredIoToolCallId = nextIoToolCallId;
397
460
  this.hoveredIoSection = nextIoSection;
398
461
  if (nextIoView) nextIoView.setHoveredSection(nextIoSection);
399
- this.invalidate();
400
- this.options.onChange?.();
462
+
463
+ const changedIds = new Set(
464
+ [previousHoveredToolCallId, hovered, previousIoToolCallId, nextIoToolCallId]
465
+ .filter((id): id is string => id !== undefined),
466
+ );
467
+ const changedRanges: ConversationTimelineLineRange[] = [];
468
+ let patched = true;
469
+ for (const id of changedIds) {
470
+ const span = this.snapshot.toolSpans.find((candidate) => candidate.toolCallId === id);
471
+ if (!span || !this.refreshToolSpan(span)) {
472
+ patched = false;
473
+ break;
474
+ }
475
+ changedRanges.push({ startLine: span.startLine, height: span.height });
476
+ }
477
+ if (!patched) {
478
+ this.invalidate();
479
+ this.options.onChange?.({ kind: "content" });
480
+ } else {
481
+ this.options.onChange?.({ kind: "interaction", changedRanges });
482
+ }
401
483
  return { handled: true, render: true };
402
484
  }
403
485
 
@@ -448,8 +530,12 @@ export class ConversationTimeline implements Component {
448
530
  // A missing/older Pi mouse implementation must not break the viewer.
449
531
  }
450
532
  }
451
- this.invalidate();
452
- this.options.onChange?.();
533
+ if (shouldExpand || shouldCollapse) {
534
+ this.invalidate();
535
+ this.options.onChange?.({ kind: "content" });
536
+ } else {
537
+ this.options.onChange?.({ kind: "interaction" });
538
+ }
453
539
  return { handled: true, render: shouldExpand || shouldCollapse, focusedBlockIndex: span.blockIndex };
454
540
  }
455
541
 
@@ -458,8 +544,7 @@ export class ConversationTimeline implements Component {
458
544
  this.focusedToolCallId = undefined;
459
545
  this.lastClickedToolCallId = undefined;
460
546
  this.lastClickedPoint = undefined;
461
- this.invalidate();
462
- this.options.onChange?.();
547
+ this.options.onChange?.({ kind: "interaction" });
463
548
  return { handled: true, render: true, focusedBlockIndex: target.blockIndex };
464
549
  }
465
550
 
@@ -13,7 +13,7 @@ import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
13
13
  import type { Theme } from "./agent-widget.js";
14
14
  import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
15
15
  import { formatConversationMessages, type ConversationBlock } from "./conversation-blocks.js";
16
- import { ConversationTimeline, type TimelineRenderLine } from "./conversation-timeline.js";
16
+ import { ConversationTimeline, type ConversationTimelineChange, type TimelineRenderLine } from "./conversation-timeline.js";
17
17
  import { renderConversationRoleHeader } from "./conversation-role.js";
18
18
  import { editLiveAssistantBlockInNvim, viewConversationBlockInNvim } from "./conversation-nvim.js";
19
19
  import { findConversationMatches, highlightConversationLine, nearestConversationMatchAfter, nearestConversationMatchBefore, stripAnsi, type ConversationMatch } from "./conversation-search.js";
@@ -156,6 +156,8 @@ export class ConversationViewer implements Component {
156
156
  private cachedMessageBlocks: ConversationBlock[] = [];
157
157
  private cacheVersion = 0;
158
158
  private cachedVersion = -1;
159
+ private sessionRefreshQueued = false;
160
+ private selectedBlockId: string | undefined;
159
161
  private scrollOffset = 0;
160
162
  private autoScroll = true;
161
163
  private unsubscribe: (() => void) | undefined;
@@ -192,21 +194,10 @@ export class ConversationViewer implements Component {
192
194
  this.timeline = new ConversationTimeline(tui, theme, {
193
195
  cwd: operations?.ctx.cwd,
194
196
  record,
195
- onChange: () => {
196
- this.cacheVersion++;
197
- this.invalidate();
198
- this.tui.requestRender();
199
- },
197
+ onChange: (change) => this.handleTimelineChange(change),
200
198
  });
201
199
  this.timeline.setBlocks(this.blocks);
202
- this.unsubscribe = session.subscribe(() => {
203
- if (this.closed) return;
204
- this.blocks = formatConversationMessages(this.session.messages);
205
- this.timeline.setBlocks(this.blocks);
206
- this.cacheVersion++;
207
- this.invalidate();
208
- this.tui.requestRender();
209
- });
200
+ this.unsubscribe = session.subscribe(() => this.scheduleSessionRefresh());
210
201
  }
211
202
 
212
203
  handleInput(data: string): void {
@@ -365,7 +356,6 @@ export class ConversationViewer implements Component {
365
356
  this.setFocusFromMessageIndex(selected);
366
357
  this.autoScroll = false;
367
358
  this.scrollTargetIntoView(selected);
368
- this.invalidate();
369
359
  this.tui.requestRender();
370
360
  }
371
361
  }
@@ -504,6 +494,39 @@ export class ConversationViewer implements Component {
504
494
  this.cachedVersion = -1;
505
495
  }
506
496
 
497
+ private scheduleSessionRefresh(): void {
498
+ if (this.closed || this.sessionRefreshQueued) return;
499
+ this.sessionRefreshQueued = true;
500
+ queueMicrotask(() => {
501
+ this.sessionRefreshQueued = false;
502
+ if (this.closed) return;
503
+ if (this.hasFocusedBlock) this.selectedBlockId = this.currentBlock()?.id;
504
+ this.blocks = formatConversationMessages(this.session.messages);
505
+ this.timeline.setBlocks(this.blocks);
506
+ this.cacheVersion++;
507
+ this.invalidate();
508
+ this.tui.requestRender();
509
+ });
510
+ }
511
+
512
+ private handleTimelineChange(change: ConversationTimelineChange): void {
513
+ if (change.kind === "content") {
514
+ this.cacheVersion++;
515
+ this.invalidate();
516
+ } else if (change.changedRanges && this.cachedWidth > 0 && this.cachedVersion === this.cacheVersion) {
517
+ const snapshotLines = this.timeline.getSnapshot().lines;
518
+ for (const range of change.changedRanges) {
519
+ const start = Math.max(0, range.startLine);
520
+ const end = Math.min(this.cachedLines.length, start + Math.max(0, range.height));
521
+ for (let index = start; index < end; index++) {
522
+ const line = snapshotLines[index];
523
+ if (line) this.cachedLines[index] = { ...line };
524
+ }
525
+ }
526
+ }
527
+ this.tui.requestRender();
528
+ }
529
+
507
530
  dispose(): void {
508
531
  this.closed = true;
509
532
  this.unsubscribe?.();
@@ -549,6 +572,19 @@ export class ConversationViewer implements Component {
549
572
  const lines: RenderLine[] = timelineSnapshot.lines.map((line) => ({ ...line }));
550
573
  this.cachedMessageStarts = [...timelineSnapshot.messageStarts];
551
574
  this.cachedMessageBlocks = [...timelineSnapshot.messageBlocks];
575
+ if (this.hasFocusedBlock) {
576
+ const selectedIndex = this.selectedBlockId
577
+ ? this.cachedMessageBlocks.findIndex((block) => block.id === this.selectedBlockId)
578
+ : -1;
579
+ const focusedToolIndex = this.timeline.getFocusedToolCallId()
580
+ ? this.cachedMessageBlocks.findIndex((block) => block.toolCallId === this.timeline.getFocusedToolCallId())
581
+ : -1;
582
+ const restoredIndex = selectedIndex >= 0 ? selectedIndex : focusedToolIndex;
583
+ if (restoredIndex >= 0) {
584
+ this.selectedMessageIdx = restoredIndex;
585
+ this.selectedBlockId = this.cachedMessageBlocks[restoredIndex]?.id;
586
+ }
587
+ }
552
588
  if (this.record.status === "running" && this.activity) {
553
589
  const act = describeActivity(this.activity.activeTools, this.activity.responseText);
554
590
  const text = `▍ ${act}`;
@@ -571,6 +607,7 @@ export class ConversationViewer implements Component {
571
607
  this.selectedMessageIdx = index;
572
608
  this.hasFocusedBlock = true;
573
609
  const block = this.cachedMessageBlocks[index];
610
+ this.selectedBlockId = block.id;
574
611
  this.timeline.setFocusedToolCallId(block.kind === "tool" ? block.toolCallId : undefined);
575
612
  return true;
576
613
  }
@@ -617,7 +654,6 @@ export class ConversationViewer implements Component {
617
654
  this.setFocusFromMessageIndex(target);
618
655
  this.scrollOffset = Math.min(this.cachedMessageStarts[target], Math.max(0, this.cachedLines.length - this.viewportHeight()));
619
656
  this.autoScroll = false;
620
- this.invalidate();
621
657
  this.tui.requestRender();
622
658
  }
623
659
 
@@ -631,7 +667,6 @@ export class ConversationViewer implements Component {
631
667
  this.autoScroll = false;
632
668
  this.scrollTargetIntoView(index);
633
669
  }
634
- this.invalidate();
635
670
  this.tui.requestRender();
636
671
  }
637
672
 
@@ -653,7 +688,6 @@ export class ConversationViewer implements Component {
653
688
  this.setFocusFromMessageIndex(resolvedTarget);
654
689
  this.autoScroll = false;
655
690
  this.scrollTargetIntoView(resolvedTarget);
656
- this.invalidate();
657
691
  this.tui.requestRender();
658
692
  }
659
693
 
@@ -701,7 +735,6 @@ export class ConversationViewer implements Component {
701
735
  const selected = this.messageIndexForOffset(this.scrollOffset);
702
736
  this.setFocusFromMessageIndex(selected);
703
737
  this.autoScroll = this.scrollOffset >= max;
704
- this.invalidate();
705
738
  this.tui.requestRender();
706
739
  }
707
740
 
@@ -709,7 +742,6 @@ export class ConversationViewer implements Component {
709
742
  this.scrollOffset = 0;
710
743
  this.setFocusFromMessageIndex(0);
711
744
  this.autoScroll = false;
712
- this.invalidate();
713
745
  this.tui.requestRender();
714
746
  }
715
747
 
@@ -717,7 +749,6 @@ export class ConversationViewer implements Component {
717
749
  this.scrollOffset = Math.max(0, this.cachedLines.length - this.viewportHeight());
718
750
  this.setFocusFromMessageIndex(Math.max(0, this.cachedMessageStarts.length - 1));
719
751
  this.autoScroll = true;
720
- this.invalidate();
721
752
  this.tui.requestRender();
722
753
  }
723
754
 
@@ -769,7 +800,6 @@ export class ConversationViewer implements Component {
769
800
  this.scrollOffset = Math.max(0, Math.min(max, match.lineIdx - Math.floor(this.viewportHeight() / 2)));
770
801
  this.setFocusFromMessageIndex(this.messageIndexForOffset(this.scrollOffset));
771
802
  this.autoScroll = false;
772
- this.invalidate();
773
803
  }
774
804
 
775
805
  private async copyCurrentMessage(): Promise<void> {
@@ -0,0 +1,72 @@
1
+ /** Coalesced, deferred render requests for structural UI changes. */
2
+
3
+ const DEFAULT_DELAY_MS = 50;
4
+ const DEFAULT_MAX_WAIT_MS = 200;
5
+
6
+ export interface DeferredUiRefresh {
7
+ /** Schedule one render request after the debounce/max-wait window. */
8
+ schedule(): void;
9
+ /** Cancel the current pending render request, if any. */
10
+ cancel(): void;
11
+ /** Cancel and permanently disable this scheduler. */
12
+ dispose(): void;
13
+ }
14
+
15
+ export function createDeferredUiRefresh(
16
+ requestRender: () => void,
17
+ options: {
18
+ delayMs?: number;
19
+ maxWaitMs?: number;
20
+ now?: () => number;
21
+ } = {},
22
+ ): DeferredUiRefresh {
23
+ const delayMs = Math.max(0, options.delayMs ?? DEFAULT_DELAY_MS);
24
+ const maxWaitMs = Math.max(0, options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS);
25
+ const now = options.now ?? Date.now;
26
+
27
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
28
+ let firstScheduledAt: number | undefined;
29
+ let disposed = false;
30
+
31
+ const clearPendingTimer = () => {
32
+ if (debounceTimer !== undefined) {
33
+ clearTimeout(debounceTimer);
34
+ debounceTimer = undefined;
35
+ }
36
+ };
37
+
38
+ const flush = () => {
39
+ // Clear all state before invoking user code. A render callback may schedule
40
+ // another refresh, which should begin a fresh debounce window.
41
+ clearPendingTimer();
42
+ firstScheduledAt = undefined;
43
+ if (disposed) return;
44
+ requestRender();
45
+ };
46
+
47
+ const schedule = () => {
48
+ if (disposed) return;
49
+
50
+ const current = now();
51
+ if (firstScheduledAt === undefined) firstScheduledAt = current;
52
+
53
+ clearPendingTimer();
54
+ const maxDeadline = firstScheduledAt + maxWaitMs;
55
+ const remainingMaxWait = Math.max(0, maxDeadline - current);
56
+ const wait = Math.min(delayMs, remainingMaxWait);
57
+ debounceTimer = setTimeout(flush, wait);
58
+ };
59
+
60
+ const cancel = () => {
61
+ clearPendingTimer();
62
+ firstScheduledAt = undefined;
63
+ };
64
+
65
+ const dispose = () => {
66
+ if (disposed) return;
67
+ cancel();
68
+ disposed = true;
69
+ };
70
+
71
+ return { schedule, cancel, dispose };
72
+ }