pi-cc-extensions 0.2.0 → 0.3.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.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <p align="center">
6
6
  <a href="#安装"><img alt="Pi package" src="https://img.shields.io/badge/Pi-package-58B7FF?style=flat-square"></a>
7
- <a href="./package.json"><img alt="Version 0.1.3" src="https://img.shields.io/badge/version-0.1.3-66E3C4?style=flat-square"></a>
7
+ <a href="./package.json"><img alt="Version 0.2.0" src="https://img.shields.io/badge/version-0.2.0-66E3C4?style=flat-square"></a>
8
8
  <a href="#兼容性"><img alt="Node.js 22.19 or newer" src="https://img.shields.io/badge/Node.js-%E2%89%A522.19-66E3C4?style=flat-square"></a>
9
9
  <a href="./extensions"><img alt="TypeScript extensions" src="https://img.shields.io/badge/TypeScript-extensions-3178C6?style=flat-square"></a>
10
10
  </p>
@@ -388,6 +388,8 @@ export class TerminalSplitCompositor {
388
388
  private maxScrollOffset = 0;
389
389
  private lastRootLineCount = 0;
390
390
  private rootLines: string[] = [];
391
+ private rootWindowWidth = 0;
392
+ private rootWindowRawRows = 0;
391
393
  private suppressRootGrowthAdjustment = false;
392
394
  private readonly toolExpansionStarts = new Map<string, number>();
393
395
  private visibleRootStart = 0;
@@ -399,6 +401,8 @@ export class TerminalSplitCompositor {
399
401
  private selectionAnchor: SelectionPoint | null = null;
400
402
  private selectionFocus: SelectionPoint | null = null;
401
403
  private selectionDragging = false;
404
+ private batchingMouseInput = false;
405
+ private selectionRepaintPending = false;
402
406
  private preserveSelectionFocusOnRelease = false;
403
407
  private lastLeftPress: { area: SelectionArea; line: number; at: number } | null = null;
404
408
  private consumedClusterClick = false;
@@ -554,6 +558,8 @@ export class TerminalSplitCompositor {
554
558
  this.lastLeftPress = null;
555
559
  this.hoveredRootLine = null;
556
560
  this.rootLines = [];
561
+ this.rootWindowWidth = 0;
562
+ this.rootWindowRawRows = 0;
557
563
  this.visibleRootLines = [];
558
564
  this.visibleRootStart = 0;
559
565
  this.lastRootLineCount = 0;
@@ -597,6 +603,17 @@ export class TerminalSplitCompositor {
597
603
  const rawRows = this.getRawRows();
598
604
  const width = Math.max(1, this.terminal.columns || 80);
599
605
  const cluster = this.getCluster(width, rawRows);
606
+ const scrollableRows = Math.max(1, rawRows - cluster.lines.length);
607
+ // A cluster-only paint cannot change the split point safely. Fall back to
608
+ // the normal frame when width, terminal height, or cluster height changed.
609
+ if (
610
+ this.rootWindowWidth !== width
611
+ || this.rootWindowRawRows !== rawRows
612
+ || (this.visibleScrollableRows > 0 && this.visibleScrollableRows !== scrollableRows)
613
+ ) {
614
+ this.requestRender();
615
+ return;
616
+ }
600
617
  if (cluster.lines.length === 0) return;
601
618
 
602
619
  this.originalWrite(
@@ -694,6 +711,8 @@ export class TerminalSplitCompositor {
694
711
 
695
712
  const rawRows = this.getRawRows();
696
713
  const renderWidth = Math.max(1, width);
714
+ this.rootWindowWidth = renderWidth;
715
+ this.rootWindowRawRows = rawRows;
697
716
  const cluster = this.getCluster(renderWidth, rawRows);
698
717
  const scrollableRows = Math.max(1, rawRows - cluster.lines.length);
699
718
  const renderedLines = this.originalRender(renderWidth);
@@ -717,6 +736,28 @@ export class TerminalSplitCompositor {
717
736
  return this.updateVisibleRootWindow(scrollableRows);
718
737
  }
719
738
 
739
+ private ensureRootWindow(width: number): void {
740
+ if (!this.originalRender) return;
741
+
742
+ const renderWidth = Math.max(1, width);
743
+ const rawRows = this.getRawRows();
744
+ const cluster = this.getCluster(renderWidth, rawRows);
745
+ const scrollableRows = Math.max(1, rawRows - cluster.lines.length);
746
+ const sameRootDimensions = this.rootWindowWidth === renderWidth && this.rootWindowRawRows === rawRows;
747
+
748
+ if (sameRootDimensions && this.visibleScrollableRows > 0) {
749
+ if (this.visibleScrollableRows !== scrollableRows) {
750
+ this.maxScrollOffset = Math.max(0, this.rootLines.length - scrollableRows);
751
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, this.maxScrollOffset));
752
+ this.pendingImageCleanup = true;
753
+ this.updateVisibleRootWindow(scrollableRows);
754
+ }
755
+ return;
756
+ }
757
+
758
+ this.refreshRootWindow(renderWidth);
759
+ }
760
+
720
761
  private handleInput(data: string): { consume?: boolean; data?: string } | undefined {
721
762
  if (this.disposed || this.hasVisibleOverlay()) return undefined;
722
763
 
@@ -735,8 +776,17 @@ export class TerminalSplitCompositor {
735
776
 
736
777
  const mousePackets = this.mouseScroll ? parseSgrMousePackets(data) : null;
737
778
  if (mousePackets) {
738
- for (const packet of mousePackets) {
739
- this.handleMousePacket(packet);
779
+ this.batchingMouseInput = true;
780
+ try {
781
+ for (const packet of mousePackets) {
782
+ this.handleMousePacket(packet);
783
+ }
784
+ } finally {
785
+ this.batchingMouseInput = false;
786
+ }
787
+ if (this.selectionRepaintPending) {
788
+ this.selectionRepaintPending = false;
789
+ this.repaintSelection(Math.max(1, this.terminal.columns || 80));
740
790
  }
741
791
  return { consume: true };
742
792
  }
@@ -777,7 +827,11 @@ export class TerminalSplitCompositor {
777
827
  return;
778
828
  }
779
829
 
780
- this.refreshRootWindow(Math.max(1, this.terminal.columns || 80));
830
+ const width = Math.max(1, this.terminal.columns || 80);
831
+ // Mouse selection and clicks operate on the last painted root window. Avoid
832
+ // re-rendering the complete transcript for every mouse packet; refresh only
833
+ // on the first packet or after a terminal resize.
834
+ this.ensureRootWindow(width);
781
835
  const location = this.selectionLocationForPacket(packet);
782
836
 
783
837
  if (isRightPress(packet)) {
@@ -845,6 +899,10 @@ export class TerminalSplitCompositor {
845
899
  this.clearSelection();
846
900
  this.lastLeftPress = null;
847
901
  this.consumedClusterClick = true;
902
+ // refreshRootWindow() updated the logical lines above, but the TUI
903
+ // render is scheduled asynchronously. Paint the collapsed/expanded
904
+ // viewport immediately so stale rows cannot remain until the next drag.
905
+ this.repaintScrollableViewport(Math.max(1, this.terminal.columns || 80));
848
906
  this.requestRender();
849
907
  return;
850
908
  }
@@ -869,7 +927,7 @@ export class TerminalSplitCompositor {
869
927
  this.lastLeftPress = null;
870
928
  this.preserveSelectionFocusOnRelease = false;
871
929
  this.selectionFocus = location.point;
872
- this.requestRender();
930
+ this.repaintSelection(width);
873
931
  return;
874
932
  }
875
933
  }
@@ -904,7 +962,7 @@ export class TerminalSplitCompositor {
904
962
  } else {
905
963
  this.clearSelection();
906
964
  }
907
- this.requestRender();
965
+ this.repaintSelection(Math.max(1, this.terminal.columns || 80));
908
966
  }
909
967
 
910
968
  private startSelection(location: SelectionLocation): void {
@@ -922,7 +980,7 @@ export class TerminalSplitCompositor {
922
980
  this.selectionDragging = true;
923
981
  this.preserveSelectionFocusOnRelease = true;
924
982
  this.lastLeftPress = null;
925
- this.requestRender();
983
+ this.repaintSelection(Math.max(1, this.terminal.columns || 80));
926
984
  return;
927
985
  }
928
986
 
@@ -932,7 +990,7 @@ export class TerminalSplitCompositor {
932
990
  this.selectionDragging = true;
933
991
  this.preserveSelectionFocusOnRelease = false;
934
992
  this.lastLeftPress = { area: location.area, line, at: now };
935
- this.requestRender();
993
+ this.repaintSelection(Math.max(1, this.terminal.columns || 80));
936
994
  }
937
995
 
938
996
  private selectionLocationForPacket(packet: SgrMousePacket): SelectionLocation | null {
@@ -974,7 +1032,7 @@ export class TerminalSplitCompositor {
974
1032
  line: edgeLine,
975
1033
  col: Math.max(0, packet.col - 1),
976
1034
  };
977
- this.requestRender();
1035
+ this.repaintSelection(Math.max(1, this.terminal.columns || 80));
978
1036
  return true;
979
1037
  }
980
1038
 
@@ -1065,7 +1123,7 @@ export class TerminalSplitCompositor {
1065
1123
 
1066
1124
  private scrollBy(delta: number): void {
1067
1125
  const width = Math.max(1, this.terminal.columns || 80);
1068
- this.refreshRootWindow(width);
1126
+ this.ensureRootWindow(width);
1069
1127
 
1070
1128
  const wasScrolled = this.scrollOffset > 0;
1071
1129
  const nextOffset = Math.max(0, Math.min(this.scrollOffset + delta, this.maxScrollOffset));
@@ -1088,12 +1146,24 @@ export class TerminalSplitCompositor {
1088
1146
  }
1089
1147
  }
1090
1148
 
1091
- private repaintScrollableViewport(width: number): void {
1092
- if (this.disposed || this.writing || this.hasVisibleOverlay()) return;
1149
+ private repaintSelection(width: number): void {
1150
+ if (this.batchingMouseInput) {
1151
+ this.selectionRepaintPending = true;
1152
+ return;
1153
+ }
1154
+ if (!this.repaintScrollableViewport(width)) {
1155
+ this.requestRender();
1156
+ }
1157
+ }
1158
+
1159
+ private repaintScrollableViewport(width: number): boolean {
1160
+ if (this.disposed || this.writing || this.hasVisibleOverlay()) return false;
1093
1161
 
1094
1162
  const rawRows = this.getRawRows();
1095
1163
  const cluster = this.getCluster(width, rawRows);
1096
1164
  const scrollableRows = Math.max(1, rawRows - cluster.lines.length);
1165
+ this.maxScrollOffset = Math.max(0, this.rootLines.length - scrollableRows);
1166
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, this.maxScrollOffset));
1097
1167
  const start = this.updateVisibleRootWindow(scrollableRows);
1098
1168
  let buffer = beginSynchronizedOutput()
1099
1169
  + this.consumePendingImageCleanup()
@@ -1113,6 +1183,7 @@ export class TerminalSplitCompositor {
1113
1183
  buffer += this.mouseReportingStateGuard();
1114
1184
  buffer += endSynchronizedOutput();
1115
1185
  this.originalWrite(buffer);
1186
+ return true;
1116
1187
  }
1117
1188
 
1118
1189
  private pauseMouseReportingForContextMenu(textToRestoreToClipboard: string | null = null): void {
@@ -984,6 +984,7 @@ export default function powerlineFooter(pi: ExtensionAPI): PowerlineController {
984
984
  let fixedEditorContainer: any = null;
985
985
  let fixedWidgetContainerAbove: any = null;
986
986
  let fixedWidgetContainerBelow: any = null;
987
+ let statusRenderNeedsFullRender = false;
987
988
  let stashShortcutInputUnsubscribe: (() => void) | null = null;
988
989
  let dismissWelcomeOverlay: (() => void) | null = null;
989
990
  let welcomeHeaderActive = false;
@@ -1024,7 +1025,12 @@ export default function powerlineFooter(pi: ExtensionAPI): PowerlineController {
1024
1025
  return;
1025
1026
  }
1026
1027
 
1027
- tuiRef?.requestRender();
1028
+ if (fixedEditorCompositor && !statusRenderNeedsFullRender) {
1029
+ fixedEditorCompositor.requestRepaint();
1030
+ } else {
1031
+ tuiRef?.requestRender();
1032
+ }
1033
+ statusRenderNeedsFullRender = false;
1028
1034
  }, STATUS_RENDER_DEBOUNCE_MS);
1029
1035
 
1030
1036
  const resetLayoutCache = () => {
@@ -1039,6 +1045,7 @@ export default function powerlineFooter(pi: ExtensionAPI): PowerlineController {
1039
1045
 
1040
1046
  const requestImmediateStatusRender = (options: { deferDuringTyping?: boolean } = {}) => {
1041
1047
  layoutDirty = true;
1048
+ statusRenderNeedsFullRender = true;
1042
1049
  if (options.deferDuringTyping !== false && Date.now() - lastEditorInputAt < EDITOR_STATUS_DEFER_MS) {
1043
1050
  statusRenderScheduler.schedule();
1044
1051
  return;
@@ -1244,6 +1251,7 @@ export default function powerlineFooter(pi: ExtensionAPI): PowerlineController {
1244
1251
  // Read from the extension runtime rather than retaining a session context
1245
1252
  // method across /reload. The runtime already restored the effective level.
1246
1253
  currentThinkingLevel = pi.getThinkingLevel();
1254
+ recalculateUsageFromEvents(ctx);
1247
1255
 
1248
1256
  if (ctx.hasUI) {
1249
1257
  ctx.ui.setStatus("stash", undefined);
@@ -1295,6 +1303,7 @@ export default function powerlineFooter(pi: ExtensionAPI): PowerlineController {
1295
1303
  welcomeOverlayShouldDismiss = false;
1296
1304
  welcomeDismissScheduler.cancel();
1297
1305
  statusRenderScheduler.cancel();
1306
+ statusRenderNeedsFullRender = false;
1298
1307
  restoreFooterStatusRepaintHook?.();
1299
1308
  restoreFooterStatusRepaintHook = null;
1300
1309
  teardownFixedEditorCompositor(isTerminalExit ? { resetExtendedKeyboardModes: true } : undefined);
@@ -1379,6 +1388,7 @@ export default function powerlineFooter(pi: ExtensionAPI): PowerlineController {
1379
1388
  currentCtx = ctx;
1380
1389
  currentThinkingLevel = pi.getThinkingLevel();
1381
1390
  liveAssistantUsage = null;
1391
+ recalculateUsageFromEvents(ctx);
1382
1392
  requestImmediateStatusRender({ deferDuringTyping: false });
1383
1393
  });
1384
1394
 
@@ -1388,6 +1398,8 @@ export default function powerlineFooter(pi: ExtensionAPI): PowerlineController {
1388
1398
  // intentionally unknown until the next model response, so the segment will
1389
1399
  // display "?" rather than a stale percentage.
1390
1400
  liveAssistantUsage = null;
1401
+ // Recalculate usage after compaction (old events removed, summary added).
1402
+ recalculateUsageFromEvents(ctx);
1391
1403
  fixedEditorCompositor?.resetTranscriptLayout();
1392
1404
  resetLayoutCache();
1393
1405
  requestImmediateStatusRender({ deferDuringTyping: false });
@@ -1455,6 +1467,13 @@ export default function powerlineFooter(pi: ExtensionAPI): PowerlineController {
1455
1467
  liveAssistantUsage = null;
1456
1468
  } else if (getUsageTokenTotal(event.message.usage) > 0) {
1457
1469
  liveAssistantUsage = event.message.usage;
1470
+ // Incrementally update cached usage (O(1) instead of rescanning all events).
1471
+ cachedUsage.input += event.message.usage.input;
1472
+ cachedUsage.output += event.message.usage.output;
1473
+ cachedUsage.cacheRead += event.message.usage.cacheRead;
1474
+ cachedUsage.cacheWrite += event.message.usage.cacheWrite;
1475
+ cachedUsage.cost += event.message.usage.cost.total;
1476
+ cachedLastAssistantUsage = event.message.usage;
1458
1477
  }
1459
1478
  }
1460
1479
  requestImmediateStatusRender({ deferDuringTyping: false });
@@ -1841,6 +1860,7 @@ export default function powerlineFooter(pi: ExtensionAPI): PowerlineController {
1841
1860
  tuiRef = null;
1842
1861
  currentEditor = null;
1843
1862
  statusRenderScheduler.cancel();
1863
+ statusRenderNeedsFullRender = false;
1844
1864
  resetLayoutCache();
1845
1865
  ctx.ui.notify("Powerline disabled", "info");
1846
1866
  }
@@ -2130,46 +2150,47 @@ export default function powerlineFooter(pi: ExtensionAPI): PowerlineController {
2130
2150
  },
2131
2151
  });
2132
2152
 
2133
- function buildSegmentContext(ctx: any, theme: Theme): SegmentContext {
2134
- const presetDef = getPreset(config.preset);
2135
- const colors: ColorScheme = presetDef.colors ?? getDefaultColors();
2136
-
2137
- // Build usage stats and get thinking level from session
2138
- let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, cost = 0;
2139
- let lastAssistant: AssistantMessage | undefined;
2140
- let thinkingLevelFromSession: string | null = null;
2141
-
2142
- const sessionEvents = ctx.sessionManager?.getBranch?.() ?? [];
2143
- for (const e of sessionEvents) {
2144
- if (!isRecord(e)) {
2145
- continue;
2146
- }
2147
-
2148
- // Check for thinking level change entries
2153
+ // ═══ Incremental usage stats cache ═══
2154
+ // Avoids scanning all session events on every render (was O(n) per frame).
2155
+ // Updated incrementally on message_end / session_start / session_compact.
2156
+ let cachedUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
2157
+ let cachedLastAssistantUsage: SessionAssistantUsage | null = null;
2158
+ let cachedThinkingLevel: string | null = null;
2159
+
2160
+ function recalculateUsageFromEvents(ctx: any): void {
2161
+ cachedUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
2162
+ cachedLastAssistantUsage = null;
2163
+ cachedThinkingLevel = null;
2164
+ const events = ctx?.sessionManager?.getBranch?.() ?? [];
2165
+ for (const e of events) {
2166
+ if (!isRecord(e)) continue;
2149
2167
  if (e.type === "thinking_level_change" && typeof e.thinkingLevel === "string") {
2150
- thinkingLevelFromSession = e.thinkingLevel;
2151
- }
2152
-
2153
- if (e.type !== "message" || !isSessionAssistantMessage(e.message)) {
2154
- continue;
2168
+ cachedThinkingLevel = e.thinkingLevel;
2155
2169
  }
2156
-
2170
+ if (e.type !== "message" || !isSessionAssistantMessage(e.message)) continue;
2157
2171
  const m = e.message;
2158
- if (m.stopReason === "error" || m.stopReason === "aborted") {
2159
- continue;
2160
- }
2161
- input += m.usage.input;
2162
- output += m.usage.output;
2163
- cacheRead += m.usage.cacheRead;
2164
- cacheWrite += m.usage.cacheWrite;
2165
- cost += m.usage.cost.total;
2172
+ if (m.stopReason === "error" || m.stopReason === "aborted") continue;
2173
+ cachedUsage.input += m.usage.input;
2174
+ cachedUsage.output += m.usage.output;
2175
+ cachedUsage.cacheRead += m.usage.cacheRead;
2176
+ cachedUsage.cacheWrite += m.usage.cacheWrite;
2177
+ cachedUsage.cost += m.usage.cost.total;
2166
2178
  if (getUsageTokenTotal(m.usage) > 0) {
2167
- lastAssistant = m;
2179
+ cachedLastAssistantUsage = m.usage;
2168
2180
  }
2169
2181
  }
2182
+ }
2183
+
2184
+ function buildSegmentContext(ctx: any, theme: Theme): SegmentContext {
2185
+ const presetDef = getPreset(config.preset);
2186
+ const colors: ColorScheme = presetDef.colors ?? getDefaultColors();
2187
+
2188
+ // Use cached incremental stats (O(1)) instead of scanning all events.
2189
+ const { input, output, cacheRead, cacheWrite, cost } = cachedUsage;
2190
+ const thinkingLevelFromSession = currentThinkingLevel ?? cachedThinkingLevel ?? "off";
2170
2191
 
2171
2192
  // Calculate context percentage.
2172
- const latestUsage = isStreaming ? liveAssistantUsage ?? lastAssistant?.usage : lastAssistant?.usage;
2193
+ const latestUsage = isStreaming ? liveAssistantUsage ?? cachedLastAssistantUsage : cachedLastAssistantUsage;
2173
2194
  const coreContextUsage = isStreaming && liveAssistantUsage ? null : readCoreContextUsage(ctx);
2174
2195
  const fallbackContextTokens = latestUsage ? getUsageTokenTotal(latestUsage) : 0;
2175
2196
  const contextWindow = coreContextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
@@ -2191,7 +2212,7 @@ export default function powerlineFooter(pi: ExtensionAPI): PowerlineController {
2191
2212
  ? ctx.modelRegistry?.isUsingOAuth?.(ctx.model) ?? false
2192
2213
  : false;
2193
2214
 
2194
- const thinkingLevel = currentThinkingLevel ?? thinkingLevelFromSession ?? "off";
2215
+ const thinkingLevel = thinkingLevelFromSession;
2195
2216
 
2196
2217
  return {
2197
2218
  model: ctx.model,
@@ -2511,8 +2532,9 @@ export default function powerlineFooter(pi: ExtensionAPI): PowerlineController {
2511
2532
  component.setExpanded(!expanded);
2512
2533
  } else {
2513
2534
  const state = (component.rendererState ??= {});
2514
- state.ccstyleToolExpanded = !expanded;
2515
- component.setExpanded?.(Boolean(component.expanded));
2535
+ const nextExpanded = !expanded;
2536
+ state.ccstyleToolExpanded = nextExpanded;
2537
+ component.setExpanded?.(nextExpanded);
2516
2538
  component.invalidate?.();
2517
2539
  }
2518
2540
  // The compositor refreshes and renders once after applying its viewport
@@ -2523,8 +2545,13 @@ export default function powerlineFooter(pi: ExtensionAPI): PowerlineController {
2523
2545
  key: expandableRootComponentKey(component),
2524
2546
  };
2525
2547
  };
2526
- const isExpandableRootLine = (line: number, _col: number, text: string): boolean =>
2527
- Boolean(findExpandableRootComponentAtText(line, text));
2548
+ const isExpandableRootLine = (_line: number, _col: number, text: string): boolean => {
2549
+ // O(1) regex check instead of full tree traversal + component.render().
2550
+ // Click handler (onRootClick) still uses findExpandableRootComponentAtText for accurate matching.
2551
+ if (!text) return false;
2552
+ const stripped = stripAnsiForToolClick(text);
2553
+ return stripped ? /(?:expand|\/ click)/.test(stripped) : false;
2554
+ };
2528
2555
  const readRenderTheme = (): Theme => {
2529
2556
  if (!currentCtx) return fallbackTheme;
2530
2557
  try {
@@ -2547,32 +2574,73 @@ export default function powerlineFooter(pi: ExtensionAPI): PowerlineController {
2547
2574
  (line.match(/\x1b\[[0-9;]*m/g) ?? []).some((sequence) =>
2548
2575
  sequence.slice(2, -1).split(";").includes("3"),
2549
2576
  );
2577
+ // Cache expensive per-line ANSI work by rendered line content. The TUI
2578
+ // creates a new array on every render, so caching the array reference does
2579
+ // not help once the transcript grows.
2580
+ const MAX_DECORATION_CACHE_ENTRIES = 4096;
2581
+ let decorationTheme: Theme | null = null;
2582
+ let thinkingColor = "";
2583
+ const thinkingLineCache = new Map<string, boolean>();
2584
+ const nonEmptyLineCache = new Map<string, boolean>();
2585
+ const unboldLineCache = new Map<string, string>();
2586
+
2587
+ const trimDecorationCache = <T>(cache: Map<string, T>): void => {
2588
+ if (cache.size > MAX_DECORATION_CACHE_ENTRIES) cache.clear();
2589
+ };
2590
+
2550
2591
  const decorateThinkingLines = (lines: string[]): string[] => {
2551
2592
  const theme = readRenderTheme();
2552
- const marker = "__ccstyle_thinking__";
2553
- const themedMarker = theme.fg("thinkingText", marker);
2554
- const markerIndex = themedMarker.indexOf(marker);
2555
- const thinkingColor = markerIndex > 0 ? themedMarker.slice(0, markerIndex) : "";
2556
- const isThinking = lines.map((line) =>
2557
- Boolean(thinkingColor && line.includes(thinkingColor) && hasItalicStyle(line)),
2558
- );
2593
+ if (theme !== decorationTheme) {
2594
+ decorationTheme = theme;
2595
+ const marker = "__ccstyle_thinking__";
2596
+ const themedMarker = theme.fg("thinkingText", marker);
2597
+ const markerIndex = themedMarker.indexOf(marker);
2598
+ thinkingColor = markerIndex > 0 ? themedMarker.slice(0, markerIndex) : "";
2599
+ thinkingLineCache.clear();
2600
+ }
2601
+
2602
+ // Fast path: themes without a thinking color need no processing.
2603
+ if (!thinkingColor) return lines;
2604
+
2605
+ const isThinking = lines.map((line) => {
2606
+ const cached = thinkingLineCache.get(line);
2607
+ if (cached !== undefined) return cached;
2608
+ const value = line.includes(thinkingColor) && hasItalicStyle(line);
2609
+ thinkingLineCache.set(line, value);
2610
+ trimDecorationCache(thinkingLineCache);
2611
+ return value;
2612
+ });
2559
2613
  const decorated = [...isThinking];
2614
+ const hasText = (line: string): boolean => {
2615
+ const cached = nonEmptyLineCache.get(line);
2616
+ if (cached !== undefined) return cached;
2617
+ const value = Boolean(stripAnsiForToolClick(line));
2618
+ nonEmptyLineCache.set(line, value);
2619
+ trimDecorationCache(nonEmptyLineCache);
2620
+ return value;
2621
+ };
2560
2622
 
2561
2623
  // Continue the guide through paragraph spacing inside one thinking block.
2562
2624
  for (let index = 0; index < lines.length; index++) {
2563
- if (stripAnsiForToolClick(lines[index] ?? "")) continue;
2625
+ if (hasText(lines[index] ?? "")) continue;
2564
2626
  let before = index - 1;
2565
- while (before >= 0 && !stripAnsiForToolClick(lines[before] ?? "")) before--;
2627
+ while (before >= 0 && !hasText(lines[before] ?? "")) before--;
2566
2628
  let after = index + 1;
2567
- while (after < lines.length && !stripAnsiForToolClick(lines[after] ?? "")) after++;
2629
+ while (after < lines.length && !hasText(lines[after] ?? "")) after++;
2568
2630
  decorated[index] = before >= 0 && after < lines.length && isThinking[before] && isThinking[after];
2569
2631
  }
2570
2632
 
2571
2633
  // Keep the native thinking text styling, but do not add a guide line.
2572
2634
  // The guide conflicted visually with the tool status markers.
2573
- return lines.map((line, index) =>
2574
- decorated[index] ? removeThinkingBold(line) : line,
2575
- );
2635
+ return lines.map((line, index) => {
2636
+ if (!decorated[index]) return line;
2637
+ const cached = unboldLineCache.get(line);
2638
+ if (cached !== undefined) return cached;
2639
+ const result = removeThinkingBold(line);
2640
+ unboldLineCache.set(line, result);
2641
+ trimDecorationCache(unboldLineCache);
2642
+ return result;
2643
+ });
2576
2644
  };
2577
2645
 
2578
2646
  let compositor: TerminalSplitCompositor;
@@ -178,31 +178,46 @@ function animationFrame(frames: string[], intervalMs = 120): string {
178
178
  return frames[Math.floor(Date.now() / intervalMs) % frames.length] ?? frames[0] ?? "";
179
179
  }
180
180
 
181
- const activeAnimationTimers = new Set<ReturnType<typeof setTimeout>>();
181
+ const activeAnimationContexts = new Set<any>();
182
+ let sharedAnimationTimer: ReturnType<typeof setTimeout> | null = null;
182
183
 
183
184
  function clearAnimation(context: any) {
184
- const timer = context?.state?.ccstyleAnimationTimer as ReturnType<typeof setTimeout> | undefined;
185
- if (!timer) return;
186
- clearTimeout(timer);
187
- activeAnimationTimers.delete(timer);
188
- context.state.ccstyleAnimationTimer = undefined;
185
+ if (!context?.state?.ccstyleAnimationScheduled) return;
186
+ context.state.ccstyleAnimationScheduled = false;
187
+ activeAnimationContexts.delete(context);
188
+ if (activeAnimationContexts.size === 0 && sharedAnimationTimer) {
189
+ clearTimeout(sharedAnimationTimer);
190
+ sharedAnimationTimer = null;
191
+ }
189
192
  }
190
193
 
191
194
  function clearAllAnimations() {
192
- for (const timer of activeAnimationTimers) clearTimeout(timer);
193
- activeAnimationTimers.clear();
195
+ for (const ctx of activeAnimationContexts) {
196
+ ctx.state.ccstyleAnimationScheduled = false;
197
+ }
198
+ activeAnimationContexts.clear();
199
+ if (sharedAnimationTimer) {
200
+ clearTimeout(sharedAnimationTimer);
201
+ sharedAnimationTimer = null;
202
+ }
194
203
  }
195
204
 
196
205
  function scheduleAnimation(context: any, intervalMs = 80) {
197
206
  const state = (context.state ??= {});
198
- if (state.ccstyleAnimationTimer) return;
199
- const timer = setTimeout(() => {
200
- activeAnimationTimers.delete(timer);
201
- state.ccstyleAnimationTimer = undefined;
202
- context.invalidate?.();
203
- }, intervalMs);
204
- state.ccstyleAnimationTimer = timer;
205
- activeAnimationTimers.add(timer);
207
+ if (state.ccstyleAnimationScheduled) return;
208
+ state.ccstyleAnimationScheduled = true;
209
+ activeAnimationContexts.add(context);
210
+ if (!sharedAnimationTimer) {
211
+ sharedAnimationTimer = setTimeout(() => {
212
+ sharedAnimationTimer = null;
213
+ const contexts = Array.from(activeAnimationContexts);
214
+ activeAnimationContexts.clear();
215
+ for (const ctx of contexts) {
216
+ ctx.state.ccstyleAnimationScheduled = false;
217
+ ctx.invalidate?.();
218
+ }
219
+ }, intervalMs);
220
+ }
206
221
  }
207
222
 
208
223
  function pendingIcon(name: string): string {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-cc-extensions",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Personal extensions for the Pi coding agent",
5
5
  "type": "module",
6
6
  "keywords": [