@esso0428/pi-subagents 0.15.16 → 0.16.0

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.
Files changed (94) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +1 -1
  3. package/dist/agent-history-list.d.ts.map +1 -1
  4. package/dist/agent-history-list.js.map +1 -1
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +47 -6
  7. package/dist/index.js.map +1 -1
  8. package/dist/output-file.js +3 -3
  9. package/dist/output-file.js.map +1 -1
  10. package/dist/ui/agent-widget.d.ts +11 -4
  11. package/dist/ui/agent-widget.d.ts.map +1 -1
  12. package/dist/ui/agent-widget.js +85 -19
  13. package/dist/ui/agent-widget.js.map +1 -1
  14. package/dist/ui/ccstyle/diff/ansi-utils.js +2 -2
  15. package/dist/ui/ccstyle/diff/ansi-utils.js.map +1 -1
  16. package/dist/ui/ccstyle/diff/diff-presentation.d.ts.map +1 -1
  17. package/dist/ui/ccstyle/diff/diff-presentation.js +0 -1
  18. package/dist/ui/ccstyle/diff/diff-presentation.js.map +1 -1
  19. package/dist/ui/ccstyle/diff/diff-renderer.d.ts +1 -1
  20. package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +1 -1
  21. package/dist/ui/ccstyle/diff/diff-renderer.js +15 -10
  22. package/dist/ui/ccstyle/diff/diff-renderer.js.map +1 -1
  23. package/dist/ui/ccstyle/diff/line-width-safety.d.ts.map +1 -1
  24. package/dist/ui/ccstyle/diff/line-width-safety.js +1 -2
  25. package/dist/ui/ccstyle/diff/line-width-safety.js.map +1 -1
  26. package/dist/ui/ccstyle/diff/shiki-highlight.d.ts +19 -0
  27. package/dist/ui/ccstyle/diff/shiki-highlight.d.ts.map +1 -0
  28. package/dist/ui/ccstyle/diff/shiki-highlight.js +85 -0
  29. package/dist/ui/ccstyle/diff/shiki-highlight.js.map +1 -0
  30. package/dist/ui/ccstyle/tool-renderer.d.ts +1 -1
  31. package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -1
  32. package/dist/ui/ccstyle/tool-renderer.js +1 -1
  33. package/dist/ui/ccstyle/tool-renderer.js.map +1 -1
  34. package/dist/ui/ccstyle/tool-result.d.ts +9 -1
  35. package/dist/ui/ccstyle/tool-result.d.ts.map +1 -1
  36. package/dist/ui/ccstyle/tool-result.js +137 -5
  37. package/dist/ui/ccstyle/tool-result.js.map +1 -1
  38. package/dist/ui/conversation-blocks.d.ts.map +1 -1
  39. package/dist/ui/conversation-blocks.js +14 -13
  40. package/dist/ui/conversation-blocks.js.map +1 -1
  41. package/dist/ui/conversation-role.d.ts +1 -1
  42. package/dist/ui/conversation-role.d.ts.map +1 -1
  43. package/dist/ui/conversation-search.js +4 -4
  44. package/dist/ui/conversation-search.js.map +1 -1
  45. package/dist/ui/conversation-timeline.d.ts +17 -1
  46. package/dist/ui/conversation-timeline.d.ts.map +1 -1
  47. package/dist/ui/conversation-timeline.js +116 -43
  48. package/dist/ui/conversation-timeline.js.map +1 -1
  49. package/dist/ui/conversation-viewer.d.ts +5 -1
  50. package/dist/ui/conversation-viewer.d.ts.map +1 -1
  51. package/dist/ui/conversation-viewer.js +67 -26
  52. package/dist/ui/conversation-viewer.js.map +1 -1
  53. package/dist/ui/deferred-ui-refresh.d.ts +15 -0
  54. package/dist/ui/deferred-ui-refresh.d.ts.map +1 -0
  55. package/dist/ui/deferred-ui-refresh.js +50 -0
  56. package/dist/ui/deferred-ui-refresh.js.map +1 -0
  57. package/dist/ui/fleet-list.d.ts +55 -14
  58. package/dist/ui/fleet-list.d.ts.map +1 -1
  59. package/dist/ui/fleet-list.js +178 -44
  60. package/dist/ui/fleet-list.js.map +1 -1
  61. package/dist/write-execution.d.ts.map +1 -1
  62. package/dist/write-execution.js +1 -1
  63. package/dist/write-execution.js.map +1 -1
  64. package/package.json +6 -5
  65. package/src/agent-history-list.ts +1 -1
  66. package/src/index.ts +49 -7
  67. package/src/output-file.ts +3 -3
  68. package/src/ui/agent-widget.ts +18 -10
  69. package/src/ui/ccstyle/diff/ansi-utils.ts +2 -2
  70. package/src/ui/ccstyle/diff/diff-presentation.ts +0 -1
  71. package/src/ui/ccstyle/diff/diff-renderer.ts +22 -21
  72. package/src/ui/ccstyle/diff/line-width-safety.ts +1 -2
  73. package/src/ui/ccstyle/diff/shiki-highlight.ts +97 -0
  74. package/src/ui/ccstyle/tool-renderer.ts +3 -3
  75. package/src/ui/ccstyle/tool-result.ts +175 -7
  76. package/src/ui/conversation-role.ts +1 -1
  77. package/src/ui/conversation-search.ts +4 -4
  78. package/src/ui/conversation-timeline.ts +7 -7
  79. package/src/ui/conversation-viewer.ts +16 -7
  80. package/src/ui/deferred-ui-refresh.ts +72 -0
  81. package/src/ui/fleet-list.ts +185 -50
  82. package/src/write-execution.ts +2 -2
  83. package/test/agent-history-list.test.ts +0 -121
  84. package/test/agent-manager-history.test.ts +0 -88
  85. package/test/agent-menu-history-navigation.test.ts +0 -86
  86. package/test/output-file-history.test.ts +0 -50
  87. package/test/ui/agent-widget.test.ts +0 -161
  88. package/test/ui/conversation-blocks.test.ts +0 -103
  89. package/test/ui/conversation-diff.test.ts +0 -91
  90. package/test/ui/conversation-search.test.ts +0 -55
  91. package/test/ui/conversation-viewer.test.ts +0 -438
  92. package/test/ui/fleet-list.test.ts +0 -135
  93. package/test/write-execution.test.ts +0 -83
  94. package/tsconfig.json +0 -16
@@ -1,11 +1,13 @@
1
- import { Text, truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
1
+ import { getLanguageFromPath, highlightCode } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
3
  import {
4
+ type DisplayConfigInput,
3
5
  renderEditDiffResult,
4
6
  renderWriteDiffResult,
5
- type DisplayConfigInput,
6
7
  } from "./diff/diff-renderer.js";
8
+ import { sanitizeAnsiForThemedOutput, sanitizeToolResultText, showMoreHintText } from "./diff/render-utils.js";
9
+ import { MAX_HL_CHARS, shikiHighlightCache } from "./diff/shiki-highlight.js";
7
10
  import { DEFAULT_TOOL_DISPLAY_CONFIG, type PersistedDiff, type ViewerDiffConfig } from "./diff/types.js";
8
- import { sanitizeToolResultText, showMoreHintText } from "./diff/render-utils.js";
9
11
 
10
12
  export function toolViewportWidth(width: number): number {
11
13
  return Math.max(1, Math.floor(Number.isFinite(width) ? width : 1));
@@ -96,6 +98,97 @@ export function formatToolInputArgs(args: unknown, maxChars = 8_000): string {
96
98
  return rendered.length > maxChars ? `${rendered.slice(0, maxChars)}…` : rendered;
97
99
  }
98
100
 
101
+ type FencedCodeRegion = {
102
+ bodyStart: number;
103
+ bodyEnd: number;
104
+ language?: string;
105
+ };
106
+
107
+ type OutputHighlightPlan = {
108
+ lines: string[];
109
+ code: string;
110
+ codeLineIndexes: number[];
111
+ };
112
+
113
+ function parseFenceOpening(line: string): { marker: string; language?: string } | undefined {
114
+ const match = line.match(/^\s*(`{3,}|~{3,})(?:\s*([A-Za-z][\w+.-]*))?(?:\s+.*)?\s*$/);
115
+ if (!match) return undefined;
116
+ return { marker: match[1]!, language: match[2] };
117
+ }
118
+
119
+ function isFenceClosing(line: string, marker: string): boolean {
120
+ const trimmed = line.trim();
121
+ return trimmed.length >= marker.length &&
122
+ trimmed[0] === marker[0] &&
123
+ [...trimmed].every((character) => character === marker[0]);
124
+ }
125
+
126
+ function findFencedCodeRegion(lines: readonly string[]): FencedCodeRegion | undefined {
127
+ for (let openingIndex = 0; openingIndex < lines.length; openingIndex++) {
128
+ const opening = parseFenceOpening(lines[openingIndex]!);
129
+ if (!opening) continue;
130
+ let bodyEnd = lines.length;
131
+ for (let index = openingIndex + 1; index < lines.length; index++) {
132
+ if (isFenceClosing(lines[index]!, opening.marker)) {
133
+ bodyEnd = index;
134
+ break;
135
+ }
136
+ }
137
+ return {
138
+ bodyStart: openingIndex + 1,
139
+ bodyEnd,
140
+ ...(opening.language ? { language: opening.language } : {}),
141
+ };
142
+ }
143
+ return undefined;
144
+ }
145
+
146
+ function pathFromToolArgs(args: unknown): string | undefined {
147
+ if (!args || typeof args !== "object" || Array.isArray(args)) return undefined;
148
+ const record = args as Record<string, unknown>;
149
+ const path = record.file_path ?? record.path;
150
+ return typeof path === "string" && path.trim() ? path : undefined;
151
+ }
152
+
153
+ /** Resolve only explicit tool/path or fenced metadata; no command/log heuristics. */
154
+ export function resolveToolOutputLanguage(args: unknown, output: string): string | undefined {
155
+ const path = pathFromToolArgs(args)?.replace(/^@/, "").trim();
156
+ if (path) {
157
+ try {
158
+ const language = getLanguageFromPath(path);
159
+ if (language) return language;
160
+ } catch {
161
+ // Fall through to an explicit fence when the path mapping is unavailable.
162
+ }
163
+ }
164
+ const lines = output.replace(/\t/g, " ").replace(/\n+$/, "").split("\n");
165
+ return findFencedCodeRegion(lines)?.language;
166
+ }
167
+
168
+ function outputHighlightPlan(output: string, language: string | undefined): OutputHighlightPlan | undefined {
169
+ if (!language || !output.trim() || output.length > MAX_HL_CHARS) return undefined;
170
+ const lines = output.replace(/\t/g, " ").replace(/\n+$/, "").split("\n");
171
+ // A path language follows Pi's read renderer and applies to the complete
172
+ // output. A fence-only language is also highlighted as a normal code body;
173
+ // keeping one line-to-line mapping preserves fences and wrapping safely.
174
+ const codeLineIndexes = lines.map((_, index) => index);
175
+ const codeLines = codeLineIndexes.map((index) => sanitizeToolResultText(lines[index] ?? "").replace(/\n/g, ""));
176
+ const code = codeLines.join("\n");
177
+ if (!code || code.length > MAX_HL_CHARS) return undefined;
178
+ return { lines, code, codeLineIndexes };
179
+ }
180
+
181
+ function syncHighlight(code: string, language: string, fallback: readonly string[]): string[] {
182
+ try {
183
+ const highlighted = highlightCode(code, language).map(sanitizeAnsiForThemedOutput);
184
+ return highlighted.length === fallback.length
185
+ ? highlighted
186
+ : fallback.map((line, index) => highlighted[index] ?? line);
187
+ } catch {
188
+ return fallback.map(sanitizeAnsiForThemedOutput);
189
+ }
190
+ }
191
+
99
192
  function hasExpandableDetail(text: string, args: unknown): boolean {
100
193
  return countLines(text) > 1 || formatToolInputArgs(args).trim().length > 0;
101
194
  }
@@ -109,6 +202,19 @@ export class ExpandedToolIoView implements Component {
109
202
  private lineSections: Array<ToolIoSection | null> = [];
110
203
  private hoveredSection: ToolIoSection | null = null;
111
204
  private showMoreRows: Partial<Record<ToolIoSection, number>> = {};
205
+ private readonly outputLanguage?: string;
206
+ private readonly invalidateCard?: () => void;
207
+ private outputHighlightKey: string | undefined;
208
+ private outputHighlightState: {
209
+ plan: OutputHighlightPlan;
210
+ fallback: string[];
211
+ fallbackReady: boolean;
212
+ shiki?: string[];
213
+ } | undefined;
214
+ private readonly onHighlightReady = (): void => {
215
+ this.invalidate();
216
+ this.invalidateCard?.();
217
+ };
112
218
 
113
219
  constructor(
114
220
  private input: string,
@@ -117,11 +223,56 @@ export class ExpandedToolIoView implements Component {
117
223
  private readonly maxOutputLines = DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines,
118
224
  private readonly maxInputLines = DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines,
119
225
  private readonly theme?: any,
120
- ) {}
226
+ outputLanguage?: string,
227
+ invalidateCard?: () => void,
228
+ ) {
229
+ this.outputLanguage = outputLanguage;
230
+ this.invalidateCard = invalidateCard;
231
+ }
121
232
 
122
233
  getInputBody(): string { return this.input; }
123
234
  getOutputBody(): string { return this.output.trim() ? this.output : "Done"; }
124
235
 
236
+ private highlightedOutputLines(body: string): string[] {
237
+ const plainLines = body.split("\n");
238
+ const plan = outputHighlightPlan(body, this.outputLanguage);
239
+ if (!plan) return plainLines;
240
+
241
+ const key = `${this.outputLanguage ?? ""}\0${plan.code}\0${plan.lines.join("\n")}`;
242
+ if (this.outputHighlightKey !== key || !this.outputHighlightState) {
243
+ this.outputHighlightKey = key;
244
+ this.outputHighlightState = {
245
+ plan,
246
+ // A plain fallback lets a cache hit avoid running the synchronous
247
+ // highlighter again. On a miss it is replaced below before paint.
248
+ fallback: plan.code.split("\n"),
249
+ fallbackReady: false,
250
+ };
251
+ }
252
+
253
+ const state = this.outputHighlightState;
254
+ const shikiLines = state.shiki ?? shikiHighlightCache.get(
255
+ plan.code,
256
+ this.outputLanguage,
257
+ process.env.DIFF_THEME || "github-dark",
258
+ state.fallback,
259
+ this.onHighlightReady,
260
+ );
261
+ if (shikiLines) {
262
+ state.shiki = shikiLines.map(sanitizeAnsiForThemedOutput);
263
+ } else if (!state.fallbackReady) {
264
+ state.fallback = syncHighlight(plan.code, this.outputLanguage!, state.fallback);
265
+ state.fallbackReady = true;
266
+ }
267
+
268
+ const highlightedCode = state.shiki ?? state.fallback;
269
+ const lines = plan.lines.slice();
270
+ for (const [codeIndex, lineIndex] of plan.codeLineIndexes.entries()) {
271
+ lines[lineIndex] = highlightedCode[codeIndex] ?? lines[lineIndex] ?? "";
272
+ }
273
+ return lines;
274
+ }
275
+
125
276
  setHoveredSection(section: ToolIoSection | null): void {
126
277
  if (this.hoveredSection === section) return;
127
278
  this.hoveredSection = section;
@@ -160,6 +311,7 @@ export class ExpandedToolIoView implements Component {
160
311
  return;
161
312
  }
162
313
  const sources = raw.split("\n");
314
+ const highlighted = input ? undefined : this.highlightedOutputLines(raw);
163
315
  const wrapped: string[] = [];
164
316
  const styleInput = (line: string): string => {
165
317
  const match = line.match(/^([A-Za-z_][\w.-]*)(:\s*)(.*)$/);
@@ -167,8 +319,14 @@ export class ExpandedToolIoView implements Component {
167
319
  ? color("dim", `${match[1]}${match[2]}`) + color("muted", match[3] ?? "")
168
320
  : color("muted", line);
169
321
  };
170
- for (const source of sources) {
171
- const styled = input ? styleInput(source) : color(bodyColor, source);
322
+ for (const [index, source] of sources.entries()) {
323
+ const outputLine = highlighted?.[index] ?? source;
324
+ // Shiki/core ANSI carries the actual token colors. Do not wrap it in
325
+ // theme.fg(), whose closing reset would erase nested token styles.
326
+ const hasAnsi = /\x1b\[[0-?]*[ -/]*[@-~]/.test(outputLine);
327
+ const styled = input
328
+ ? styleInput(source)
329
+ : hasAnsi ? sanitizeAnsiForThemedOutput(outputLine) : color(bodyColor, outputLine);
172
330
  const lines = wrapTextWithAnsi(styled, contentWidth);
173
331
  wrapped.push(...(lines.length ? lines : [styled]));
174
332
  }
@@ -284,7 +442,17 @@ function renderFallbackResult(result: any, options: any, theme: any, context: an
284
442
  const maxLines = Number.isFinite(context?.diffConfig?.expandedPreviewMaxLines)
285
443
  ? Math.max(1, Math.floor(context.diffConfig.expandedPreviewMaxLines))
286
444
  : DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines;
287
- return new ExpandedToolIoView(input, text, Boolean(error), maxLines, maxLines, theme);
445
+ const language = resolveToolOutputLanguage(context?.args, text);
446
+ return new ExpandedToolIoView(
447
+ input,
448
+ text,
449
+ Boolean(error),
450
+ maxLines,
451
+ maxLines,
452
+ theme,
453
+ language,
454
+ context?.invalidate,
455
+ );
288
456
  }
289
457
  const count = text ? text.split("\n").length : 0;
290
458
  const summary = error ? oneLine(text) || "Failed" : count ? `${count} ${count === 1 ? "line" : "lines"} returned` : "Done";
@@ -1,6 +1,6 @@
1
1
  import type { Component } from "@earendil-works/pi-tui";
2
- import type { ConversationBlock } from "./conversation-blocks.js";
3
2
  import type { Theme } from "./agent-widget.js";
3
+ import type { ConversationBlock } from "./conversation-blocks.js";
4
4
 
5
5
  export type ConversationRoleTheme = Pick<Theme, "fg">;
6
6
 
@@ -105,10 +105,10 @@ export function highlightConversationLine(
105
105
 
106
106
  while (i < text.length) {
107
107
  if (text[i] === "\x1b") {
108
- const escape = text.slice(i).match(ANSI_ESCAPE_RE);
109
- if (escape) {
110
- result += escape[0];
111
- i += escape[0].length;
108
+ const ansiEscape = text.slice(i).match(ANSI_ESCAPE_RE);
109
+ if (ansiEscape) {
110
+ result += ansiEscape[0];
111
+ i += ansiEscape[0].length;
112
112
  continue;
113
113
  }
114
114
  }
@@ -1,26 +1,26 @@
1
1
  import * as CodingAgent from "@earendil-works/pi-coding-agent";
2
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
2
3
  import {
4
+ type Component,
3
5
  Markdown,
4
6
  Text,
5
- type Component,
6
7
  type TUI,
7
8
  type TuiMouseEvent,
8
9
  truncateToWidth,
9
10
  } from "@earendil-works/pi-tui";
10
- import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
11
11
  import type { AgentRecord } from "../types.js";
12
12
  import type { Theme } from "./agent-widget.js";
13
+ import { showMoreHintText } from "./ccstyle/diff/render-utils.js";
14
+ import {
15
+ createViewerCcstyleTool,
16
+ type ViewerToolRenderers,
17
+ } from "./ccstyle/tool-renderer.js";
13
18
  import {
14
19
  type ConversationBlock,
15
20
  type ConversationToolResultSnapshot,
16
21
  } from "./conversation-blocks.js";
17
22
  import { renderConversationRoleHeader } from "./conversation-role.js";
18
23
  import { stripAnsi } from "./conversation-search.js";
19
- import {
20
- createViewerCcstyleTool,
21
- type ViewerToolRenderers,
22
- } from "./ccstyle/tool-renderer.js";
23
- import { showMoreHintText } from "./ccstyle/diff/render-utils.js";
24
24
 
25
25
  export interface TimelineRenderLine {
26
26
  text: string;
@@ -5,20 +5,20 @@
5
5
  * blocks are formatted data, while Markdown/ANSI lines are a bounded cache.
6
6
  */
7
7
 
8
- import type { ExtensionAPI, ExtensionContext, AgentSession } from "@earendil-works/pi-coding-agent";
8
+ import type { AgentSession, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
9
  import { copyToClipboard } from "@earendil-works/pi-coding-agent";
10
10
  import { type Component, Input, Key, matchesKey, type TUI, type TuiMouseEvent, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
11
  import type { AgentRecord } from "../types.js";
12
12
  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
- import { formatConversationMessages, type ConversationBlock } from "./conversation-blocks.js";
16
- import { ConversationTimeline, type ConversationTimelineChange, type TimelineRenderLine } from "./conversation-timeline.js";
17
- import { renderConversationRoleHeader } from "./conversation-role.js";
15
+ import { createViewerCcstyleResult } from "./ccstyle/tool-result.js";
16
+ import { type ConversationBlock, formatConversationMessages } from "./conversation-blocks.js";
18
17
  import { editLiveAssistantBlockInNvim, viewConversationBlockInNvim } from "./conversation-nvim.js";
19
- import { findConversationMatches, highlightConversationLine, nearestConversationMatchAfter, nearestConversationMatchBefore, stripAnsi, type ConversationMatch } from "./conversation-search.js";
18
+ import { renderConversationRoleHeader } from "./conversation-role.js";
19
+ import { type ConversationMatch, findConversationMatches, highlightConversationLine, nearestConversationMatchAfter, nearestConversationMatchBefore, stripAnsi } from "./conversation-search.js";
20
+ import { ConversationTimeline, type ConversationTimelineChange, type TimelineRenderLine } from "./conversation-timeline.js";
20
21
  import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
21
- import { createViewerCcstyleResult } from "./ccstyle/tool-result.js";
22
22
 
23
23
  /** Base lines consumed by the overlay: borders, header, separator and footer. */
24
24
  const CHROME_LINES_BASE = 6;
@@ -569,7 +569,16 @@ export class ConversationViewer implements Component {
569
569
  this.timeline.setShowTools(this.showTools);
570
570
  this.timeline.render(width);
571
571
  const timelineSnapshot = this.timeline.getSnapshot();
572
- const lines: RenderLine[] = timelineSnapshot.lines.map((line) => ({ ...line }));
572
+ const lines: RenderLine[] = timelineSnapshot.lines.map((line) => {
573
+ const rawText = typeof line?.text === "string" ? line.text : "";
574
+ const text = truncateToWidth(rawText, width, "", true);
575
+ return {
576
+ text,
577
+ plain: stripAnsi(text),
578
+ blockIndex: typeof line?.blockIndex === "number" ? line.blockIndex : -1,
579
+ ...(line?.railable ? { railable: true } : {}),
580
+ };
581
+ });
573
582
  this.cachedMessageStarts = [...timelineSnapshot.messageStarts];
574
583
  this.cachedMessageBlocks = [...timelineSnapshot.messageBlocks];
575
584
  if (this.hasFocusedBlock) {
@@ -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
+ }