@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
package/src/index.ts CHANGED
@@ -15,6 +15,8 @@ import { join } from "node:path";
15
15
  import { defineTool, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir, getSettingsListTheme } from "@earendil-works/pi-coding-agent";
16
16
  import { Container, Key, matchesKey, type SettingItem, SettingsList, Spacer, Text } from "@earendil-works/pi-tui";
17
17
  import { Type } from "@sinclair/typebox";
18
+ import { agentHistoryLocator, createAgentHistoryPath, readAgentHistory } from "./agent-history.js";
19
+ import { buildAgentStatusMenuEntries, canOpenActiveAgent, canOpenAgentHistory, formatAgentHistoryOption, splitAgentRecords } from "./agent-history-list.js";
18
20
  import { AgentManager } from "./agent-manager.js";
19
21
  import { getAgentConversation, getDefaultMaxTurns, getGraceTurns, normalizeMaxTurns, SUBAGENT_TOOL_NAMES, setDefaultMaxTurns, setGraceTurns, steerAgent } from "./agent-runner.js";
20
22
  import { applyNicoOverrides, BUILTIN_TOOL_NAMES, getAgentConfig, getAllTypes, getAvailableTypes, isDefaultsDisabled, registerAgents, resolveType, setDefaultsDisabled } from "./agent-types.js";
@@ -24,8 +26,6 @@ import { isModelInScope, readEnabledModels, resolveEnabledModels } from "./enabl
24
26
  import { GroupJoinManager } from "./group-join.js";
25
27
  import { resolveAgentInvocationConfig, resolveJoinMode } from "./invocation-config.js";
26
28
  import { type ModelRegistry, resolveModel } from "./model-resolver.js";
27
- import { agentHistoryLocator, createAgentHistoryPath, readAgentHistory } from "./agent-history.js";
28
- import { buildAgentStatusMenuEntries, canOpenActiveAgent, canOpenAgentHistory, formatAgentHistoryOption, splitAgentRecords } from "./agent-history-list.js";
29
29
  import { createOutputFilePath, streamToOutputFile, writeInitialEntry } from "./output-file.js";
30
30
  import { SubagentScheduler } from "./schedule.js";
31
31
  import { resolveStorePath, ScheduleStore } from "./schedule-store.js";
@@ -49,6 +49,7 @@ import {
49
49
  type Theme,
50
50
  type UICtx,
51
51
  } from "./ui/agent-widget.js";
52
+ import { createDeferredUiRefresh } from "./ui/deferred-ui-refresh.js";
52
53
  import { FleetList, type FleetUICtx } from "./ui/fleet-list.js";
53
54
  import { showSchedulesMenu } from "./ui/schedule-menu.js";
54
55
  import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js";
@@ -374,6 +375,7 @@ export default function (pi: ExtensionAPI) {
374
375
  fleet.onAgentFinished(record.id);
375
376
  scheduleNudge(record.id, () => emitIndividualNudge(record));
376
377
  widget.update();
378
+ scheduleUiRefresh();
377
379
  }
378
380
 
379
381
  // ---- Group join manager ----
@@ -385,7 +387,11 @@ export default function (pi: ExtensionAPI) {
385
387
  scheduleNudge(groupKey, () => {
386
388
  // Re-check at send time
387
389
  const unconsumed = records.filter(r => !r.resultConsumed);
388
- if (unconsumed.length === 0) { widget.update(); return; }
390
+ if (unconsumed.length === 0) {
391
+ widget.update();
392
+ scheduleUiRefresh();
393
+ return;
394
+ }
389
395
 
390
396
  const notifications = unconsumed.map(r => formatTaskNotification(r, 300)).join('\n\n');
391
397
  const label = partial
@@ -406,6 +412,7 @@ export default function (pi: ExtensionAPI) {
406
412
  }, { deliverAs: "followUp", triggerTurn: true });
407
413
  });
408
414
  widget.update();
415
+ scheduleUiRefresh();
409
416
  },
410
417
  30_000,
411
418
  );
@@ -463,6 +470,7 @@ export default function (pi: ExtensionAPI) {
463
470
  widget.markFinished(record.id);
464
471
  fleet.onAgentFinished(record.id);
465
472
  widget.update();
473
+ scheduleUiRefresh();
466
474
  return;
467
475
  }
468
476
 
@@ -470,6 +478,7 @@ export default function (pi: ExtensionAPI) {
470
478
  // don't send an individual nudge — finalizeBatch will pick it up retroactively.
471
479
  if (currentBatchAgents.some(a => a.id === record.id)) {
472
480
  widget.update();
481
+ scheduleUiRefresh();
473
482
  return;
474
483
  }
475
484
 
@@ -480,6 +489,7 @@ export default function (pi: ExtensionAPI) {
480
489
  // 'held' → do nothing, group will fire later
481
490
  // 'delivered' → group callback already fired
482
491
  widget.update();
492
+ scheduleUiRefresh();
483
493
  }, undefined, (record) => {
484
494
  // Emit started event when agent transitions to running (including from queue)
485
495
  pi.events.emit("subagents:started", {
@@ -487,6 +497,11 @@ export default function (pi: ExtensionAPI) {
487
497
  type: record.type,
488
498
  description: record.description,
489
499
  });
500
+ widget.ensureTimer();
501
+ widget.update();
502
+ fleet.ensureTimer();
503
+ fleet.update();
504
+ scheduleUiRefresh();
490
505
  }, (record, info) => {
491
506
  // Emit compacted event when agent's session compacts (preserves count on record).
492
507
  pi.events.emit("subagents:compacted", {
@@ -581,6 +596,7 @@ export default function (pi: ExtensionAPI) {
581
596
  });
582
597
 
583
598
  pi.on("session_before_switch", () => {
599
+ deferredUiRefresh.cancel();
584
600
  manager.clearCompleted(true);
585
601
  scheduler.stop();
586
602
  });
@@ -602,6 +618,7 @@ export default function (pi: ExtensionAPI) {
602
618
  manager.abortAll();
603
619
  for (const timer of pendingNudges.values()) clearTimeout(timer);
604
620
  pendingNudges.clear();
621
+ deferredUiRefresh.dispose();
605
622
  fleet.dispose();
606
623
  manager.dispose();
607
624
  });
@@ -614,13 +631,34 @@ export default function (pi: ExtensionAPI) {
614
631
  let widgetMode: WidgetMode = "background";
615
632
  function getWidgetMode(): WidgetMode { return widgetMode; }
616
633
  const widget = new AgentWidget(manager, agentActivity, getWidgetMode);
617
- function setWidgetMode(m: WidgetMode): void { widgetMode = m; widget.update(); }
634
+ function setWidgetMode(m: WidgetMode): void {
635
+ const changed = widgetMode !== m;
636
+ widgetMode = m;
637
+ widget.update();
638
+ if (changed) scheduleUiRefresh();
639
+ }
618
640
 
619
641
  // Claude Code-style FleetView: navigable list of main + subagents below the editor.
620
642
  const fleet = new FleetList(manager, agentActivity, () => currentCtx?.cwd, pi, () => currentCtx);
643
+
644
+ // One render-only scheduler is shared by both widgets. The callback resolves
645
+ // the live TUI target at fire time because session switches invalidate the
646
+ // previous widget context; both widgets use the same TUI, so one successful
647
+ // request is sufficient.
648
+ const deferredUiRefresh = createDeferredUiRefresh(() => {
649
+ if (widget.requestUiRefresh(true)) return;
650
+ fleet.requestUiRefresh(true);
651
+ });
652
+ const scheduleUiRefresh = (): void => deferredUiRefresh.schedule();
653
+
621
654
  let fleetViewEnabled = true;
622
655
  function isFleetViewEnabled(): boolean { return fleetViewEnabled; }
623
- function setFleetViewEnabled(b: boolean): void { fleetViewEnabled = b; fleet.setEnabled(b); }
656
+ function setFleetViewEnabled(b: boolean): void {
657
+ const changed = fleetViewEnabled !== b;
658
+ fleetViewEnabled = b;
659
+ fleet.setEnabled(b);
660
+ if (changed) scheduleUiRefresh();
661
+ }
624
662
 
625
663
  // Project/global default for writing the subagent .output transcript. A custom
626
664
  // agent's `output_transcript` frontmatter overrides this per spawn; when the
@@ -720,8 +758,9 @@ export default function (pi: ExtensionAPI) {
720
758
 
721
759
  // Grab UI context from first tool execution + clear lingering widget on new turn
722
760
  pi.on("tool_execution_start", async (_event, ctx) => {
723
- widget.setUICtx(ctx.ui as UICtx);
724
- fleet.setUICtx(ctx.ui as unknown as FleetUICtx);
761
+ const widgetContextChanged = widget.setUICtx(ctx.ui as UICtx);
762
+ const fleetContextChanged = fleet.setUICtx(ctx.ui as unknown as FleetUICtx);
763
+ if (widgetContextChanged || fleetContextChanged) scheduleUiRefresh();
725
764
  widget.onTurnStart();
726
765
  });
727
766
 
@@ -1319,6 +1358,7 @@ Terse command-style prompts produce shallow, generic work.
1319
1358
  widget.update();
1320
1359
  fleet.ensureTimer();
1321
1360
  fleet.update();
1361
+ scheduleUiRefresh();
1322
1362
 
1323
1363
  // Emit created event
1324
1364
  pi.events.emit("subagents:created", {
@@ -1379,8 +1419,10 @@ Terse command-style prompts produce shallow, generic work.
1379
1419
  fgId = a.id;
1380
1420
  agentActivity.set(a.id, fgState);
1381
1421
  widget.ensureTimer();
1422
+ widget.update();
1382
1423
  fleet.ensureTimer();
1383
1424
  fleet.update();
1425
+ scheduleUiRefresh();
1384
1426
  break;
1385
1427
  }
1386
1428
  }
@@ -66,7 +66,6 @@ export function streamToOutputFile(
66
66
  historyPath?: string,
67
67
  ): () => void {
68
68
  let writtenCount = 1; // initial user prompt already written
69
- const outputPaths = historyPath && historyPath !== path ? [path, historyPath] : [path];
70
69
 
71
70
  const flush = () => {
72
71
  const messages = session.messages;
@@ -80,9 +79,10 @@ export function streamToOutputFile(
80
79
  timestamp: new Date().toISOString(),
81
80
  cwd,
82
81
  };
83
- for (const outputPath of outputPaths) {
82
+ const entryJson = JSON.stringify(entry) + "\n";
83
+ for (const target of historyPath && historyPath !== path ? [path, historyPath] : [path]) {
84
84
  try {
85
- appendFileSync(outputPath, JSON.stringify(entry) + "\n", "utf-8");
85
+ appendFileSync(target, entryJson, "utf-8");
86
86
  } catch { /* ignore write errors */ }
87
87
  }
88
88
  writtenCount++;
@@ -275,16 +275,24 @@ export class AgentWidget {
275
275
  }
276
276
 
277
277
  /** Set the UI context (grabbed from first tool execution). */
278
- setUICtx(ctx: UICtx) {
279
- if (ctx !== this.uiCtx) {
280
- // UICtx changed — the widget registered on the old context is gone.
281
- // Force re-registration on next update().
282
- this.uiCtx = ctx;
283
- this.widgetRegistered = false;
284
- this.tui = undefined;
285
- this.lastStatusText = undefined;
286
- this.lastRenderKey = undefined;
287
- }
278
+ setUICtx(ctx: UICtx): boolean {
279
+ if (ctx === this.uiCtx) return false;
280
+
281
+ // UICtx changed — the widget registered on the old context is gone.
282
+ // Force re-registration on next update().
283
+ this.uiCtx = ctx;
284
+ this.widgetRegistered = false;
285
+ this.tui = undefined;
286
+ this.lastStatusText = undefined;
287
+ this.lastRenderKey = undefined;
288
+ return true;
289
+ }
290
+
291
+ /** Request a render on the currently registered TUI without touching input. */
292
+ requestUiRefresh(force = true): boolean {
293
+ if (!this.tui) return false;
294
+ this.tui.requestRender(force);
295
+ return true;
288
296
  }
289
297
 
290
298
  /**
@@ -90,7 +90,7 @@ export function stripBackgroundSgrParams(params: readonly number[]): number[] {
90
90
  }
91
91
 
92
92
  export function filterSgrSequences(text: string, filter: (params: number[]) => number[]): string {
93
- if (!text || !text.includes("\x1b[")) {
93
+ if (!text?.includes("\x1b[")) {
94
94
  return text;
95
95
  }
96
96
 
@@ -159,7 +159,7 @@ function sanitizeColonSgr(rawParams: string): string {
159
159
  }
160
160
 
161
161
  export function sanitizeAnsiForThemedOutput(text: string): string {
162
- if (!text || !text.includes("\x1b[")) return text;
162
+ if (!text?.includes("\x1b[")) return text;
163
163
  return text.replace(ANSI_SGR_WITH_COLON_PATTERN, (sequence, rawParams: string) =>
164
164
  rawParams.includes(":")
165
165
  ? sanitizeColonSgr(rawParams)
@@ -39,7 +39,6 @@ export function resolveDiffPresentationMode(
39
39
  return canRenderSplitLayout ? "split" : "unified";
40
40
  case "unified":
41
41
  return "unified";
42
- case "auto":
43
42
  default:
44
43
  return safeWidth >= (config.diffSplitMinWidth ?? 120) && canRenderSplitLayout ? "split" : "unified";
45
44
  }
@@ -1,39 +1,37 @@
1
+ import { getLanguageFromPath, highlightCode } from "@earendil-works/pi-coding-agent";
1
2
  import {
3
+ type Component,
2
4
  Text,
3
5
  truncateToWidth,
4
6
  visibleWidth,
5
7
  wrapTextWithAnsi,
6
- type Component,
7
8
  } from "@earendil-works/pi-tui";
8
- import { getLanguageFromPath, highlightCode } from "@earendil-works/pi-coding-agent";
9
9
  import {
10
10
  ANSI_SGR_PATTERN,
11
- STYLE_RESET_PARAMS,
11
+ expandSgrReset,
12
12
  filterSgrSequences,
13
- toSgrParams,
14
13
  readSgrColorSequence,
15
- isFiniteSgrParam,
16
- expandSgrReset,
14
+ toSgrParams,
17
15
  } from "./ansi-utils.js";
18
- import {
19
- buildCollapsedDiffHintText,
20
- clampRenderedLineToWidth,
21
- clampRenderedLinesToWidth,
22
- } from "./line-width-safety.js";
23
16
  import {
24
17
  buildDiffSummaryText,
18
+ type DiffPresentationMode,
25
19
  normalizeDiffRenderWidth,
26
20
  resolveDiffPresentationMode,
27
- type DiffPresentationMode,
28
21
  } from "./diff-presentation.js";
29
- import { pluralize, sanitizeAnsiForThemedOutput, sanitizeToolResultText, showMoreHintText } from "./render-utils.js";
30
- import { splitWriteContentLines } from "./write-display-utils.js";
22
+ import {
23
+ buildCollapsedDiffHintText,
24
+ clampRenderedLinesToWidth,
25
+ clampRenderedLineToWidth,
26
+ } from "./line-width-safety.js";
27
+ import { sanitizeAnsiForThemedOutput, sanitizeToolResultText, showMoreHintText } from "./render-utils.js";
31
28
  import {
32
29
  DEFAULT_TOOL_DISPLAY_CONFIG,
33
30
  type DiffIndicatorMode,
34
- type ToolDisplayConfig,
35
31
  type PersistedDiff,
32
+ type ToolDisplayConfig,
36
33
  } from "./types.js";
34
+ import { splitWriteContentLines } from "./write-display-utils.js";
37
35
 
38
36
  const MAX_HL_CHARS = 200_000;
39
37
 
@@ -267,7 +265,7 @@ function wrapToWidth(text: string, width: number, wordWrap: boolean): string[] {
267
265
  }
268
266
 
269
267
  function resolveLanguageFromPath(rawPath: string | undefined): string | undefined {
270
- if (!rawPath || !rawPath.trim()) {
268
+ if (!rawPath?.trim()) {
271
269
  return undefined;
272
270
  }
273
271
  const normalizedPath = rawPath.replace(/^@/, "").trim();
@@ -281,7 +279,7 @@ function resolveLanguageFromPath(rawPath: string | undefined): string | undefine
281
279
  }
282
280
  }
283
281
 
284
- function resolveShikiTheme(theme: DiffTheme): string {
282
+ function _resolveShikiTheme(theme: DiffTheme): string {
285
283
  if (process.env.DIFF_THEME) return process.env.DIFF_THEME;
286
284
  const background =
287
285
  parseAnsiColorCode(readThemeAnsi(theme, "bg", "toolSuccessBg")) ??
@@ -828,7 +826,9 @@ function tokenizeInlineDiff(input: string): Array<{ value: string; start: number
828
826
  const pattern = /(\s+|[A-Za-z0-9_]+|[^A-Za-z0-9_\s])/g;
829
827
  let match: RegExpExecArray | null;
830
828
 
831
- while ((match = pattern.exec(input)) !== null) {
829
+ while (true) {
830
+ match = pattern.exec(input);
831
+ if (match === null) break;
832
832
  const value = match[0] ?? "";
833
833
  if (!value) {
834
834
  continue;
@@ -1139,13 +1139,15 @@ function mixRgb(base: RgbColor, tint: RgbColor, ratio: number): RgbColor {
1139
1139
  }
1140
1140
 
1141
1141
  function extractThemeBackgroundAnsi(text: string): string | undefined {
1142
- if (!text || !text.includes("\x1b[")) {
1142
+ if (!text?.includes("\x1b[")) {
1143
1143
  return undefined;
1144
1144
  }
1145
1145
 
1146
1146
  ANSI_SGR_PATTERN.lastIndex = 0;
1147
1147
  let match: RegExpExecArray | null;
1148
- while ((match = ANSI_SGR_PATTERN.exec(text)) !== null) {
1148
+ while (true) {
1149
+ match = ANSI_SGR_PATTERN.exec(text);
1150
+ if (match === null) break;
1149
1151
  const parsed = toSgrParams(match[1] ?? "");
1150
1152
  for (let index = 0; index < parsed.length; index += 1) {
1151
1153
  const param = parsed[index] ?? 0;
@@ -1372,7 +1374,6 @@ function resolveIndicatorGlyph(
1372
1374
  return " ";
1373
1375
  }
1374
1376
  return kind === "add" ? "+" : "-";
1375
- case "none":
1376
1377
  default:
1377
1378
  return " ";
1378
1379
  }
@@ -1,6 +1,5 @@
1
1
  import { normalizeDiffRenderWidth } from "./diff-presentation.js";
2
- import { pluralize } from "./render-utils.js";
3
- import { showMoreHintText } from "./render-utils.js";
2
+ import { pluralize, showMoreHintText } from "./render-utils.js";
4
3
 
5
4
  export interface WidthMeasurementOps {
6
5
  measure(text: string): number;
@@ -0,0 +1,97 @@
1
+ /** Maximum source size eligible for optional asynchronous Shiki enhancement. */
2
+ export const MAX_HL_CHARS = 32_000;
3
+ const MAX_CACHE_ENTRIES = 48;
4
+
5
+ type CodeToAnsi = (code: string, lang: string, theme: string) => Promise<string>;
6
+ export type ShikiLoader = () => Promise<CodeToAnsi>;
7
+ type Subscriber = { fallback: readonly string[]; invalidate: () => void };
8
+ type Pending = { work: Promise<void>; subscribers: Set<Subscriber> };
9
+
10
+ let defaultLoad: Promise<CodeToAnsi> | undefined;
11
+ const loadDefault: ShikiLoader = () => {
12
+ if (!defaultLoad) {
13
+ defaultLoad = import("@shikijs/cli")
14
+ .then((module) => module.codeToANSI as CodeToAnsi)
15
+ .catch((error) => {
16
+ defaultLoad = undefined;
17
+ throw error;
18
+ });
19
+ }
20
+ return defaultLoad;
21
+ };
22
+
23
+ function differs(lines: readonly string[], fallback: readonly string[]): boolean {
24
+ return lines.length !== fallback.length || lines.some((line, index) => line !== fallback[index]);
25
+ }
26
+
27
+ /**
28
+ * Small, viewer-local async cache. The synchronous Pi highlighter remains the
29
+ * first paint; Shiki is only requested by an expanded card that has metadata.
30
+ */
31
+ export class ShikiHighlightCache {
32
+ private readonly cache = new Map<string, string[]>();
33
+ private readonly pending = new Map<string, Pending>();
34
+
35
+ constructor(private readonly loader: ShikiLoader = loadDefault) {}
36
+
37
+ get(
38
+ code: string,
39
+ lang: string | undefined,
40
+ theme: string,
41
+ fallback: readonly string[],
42
+ invalidate?: () => void,
43
+ ): string[] | undefined {
44
+ if (!lang || code.length > MAX_HL_CHARS) return undefined;
45
+
46
+ const key = `${theme}\0${lang}\0${code}`;
47
+ const cached = this.cache.get(key);
48
+ if (cached) {
49
+ // LRU: a frequently viewed card should not evict itself immediately.
50
+ this.cache.delete(key);
51
+ this.cache.set(key, cached);
52
+ return cached;
53
+ }
54
+
55
+ const existing = this.pending.get(key);
56
+ if (existing) {
57
+ if (invalidate && ![...existing.subscribers].some((item) => item.invalidate === invalidate)) {
58
+ existing.subscribers.add({ fallback, invalidate });
59
+ }
60
+ return undefined;
61
+ }
62
+
63
+ const pending: Pending = { work: undefined as never, subscribers: new Set() };
64
+ if (invalidate) pending.subscribers.add({ fallback, invalidate });
65
+ pending.work = Promise.resolve()
66
+ .then(() => this.loader())
67
+ .then((highlight) => highlight(code, lang, theme))
68
+ .then((ansi) => {
69
+ const lines = ansi.replace(/\r/g, "").replace(/\n$/, "").split("\n");
70
+ this.cache.set(key, lines);
71
+ while (this.cache.size > MAX_CACHE_ENTRIES) {
72
+ const oldest = this.cache.keys().next().value;
73
+ if (oldest !== undefined) this.cache.delete(oldest);
74
+ }
75
+ for (const subscriber of pending.subscribers) {
76
+ if (!differs(lines, subscriber.fallback)) continue;
77
+ try {
78
+ subscriber.invalidate();
79
+ } catch {
80
+ // A stale card must not prevent another card from being repainted.
81
+ }
82
+ }
83
+ })
84
+ .catch(() => {
85
+ // The synchronous Pi highlighter remains the durable fallback.
86
+ })
87
+ .finally(() => this.pending.delete(key));
88
+ this.pending.set(key, pending);
89
+ return undefined;
90
+ }
91
+
92
+ clear(): void {
93
+ this.cache.clear();
94
+ }
95
+ }
96
+
97
+ export const shikiHighlightCache = new ShikiHighlightCache();
@@ -1,6 +1,6 @@
1
- import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
2
- import { createViewerCcstyleResult, oneLine } from "./tool-result.js";
1
+ import { type Component, truncateToWidth } from "@earendil-works/pi-tui";
3
2
  import { DEFAULT_TOOL_DISPLAY_CONFIG } from "./diff/types.js";
3
+ import { createViewerCcstyleResult, oneLine } from "./tool-result.js";
4
4
 
5
5
  export interface ViewerToolRenderers {
6
6
  name: string;
@@ -82,5 +82,5 @@ export function createViewerCcstyleTool(toolName: string, isHovered: () => boole
82
82
  };
83
83
  }
84
84
 
85
- export { renderRichToolResult } from "./tool-result.js";
86
85
  export type { PersistedDiff, ViewerDiffConfig } from "./diff/types.js";
86
+ export { renderRichToolResult } from "./tool-result.js";