pi-claude-code-ui 1.0.70 → 1.0.72

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/CHANGELOG.md CHANGED
@@ -3,6 +3,18 @@
3
3
  > [!IMPORTANT]
4
4
  > **1.0.69 — package rename (permanent).** Canonical npm name is now [`pi-claude-code-ui`](https://www.npmjs.com/package/pi-claude-code-ui). `pi-claude-style-tools` is legacy and will not receive further releases. Install with `pi install npm:pi-claude-code-ui` or `npm i pi-claude-code-ui`.
5
5
 
6
+ ## 1.0.72 — 2026-07-18
7
+
8
+ ### Changed
9
+
10
+ - **Agent tools breathe** — `Agent` / subagent tools use a size cycle while pending (`⬤ → ● → • → (invisible) → • → ●`) instead of the ordinary on/off `●` blink, so agent work reads as a different kind of tool.
11
+
12
+ ## 1.0.71 — 2026-07-18
13
+
14
+ ### Fixed
15
+
16
+ - **Long-running tools no longer freeze their status dots** — the 15s stale watchdog was treating quiet tools (no `tool_execution_update`) as leaked and killing the blink timer mid-run. While an agent is live the timer now heartbeats itself; stale cleanup only runs after the agent finishes. Also stop clearing blink state on `turn_end` (turns end before tools run).
17
+
6
18
  ## 1.0.70 — 2026-07-17
7
19
 
8
20
  ### Fixed
@@ -434,10 +434,16 @@ function isToolExecutionLike(value: unknown): value is { toolName: string; toolC
434
434
  return typeof candidate.toolName === "string" && typeof candidate.toolCallId === "string";
435
435
  }
436
436
 
437
+ const AGENT_FAMILY_TOOL_NAMES = new Set(["Agent", "Agents", "get_subagent_result", "steer_subagent"]);
438
+
439
+ function isAgentFamilyToolName(name: unknown): boolean {
440
+ return typeof name === "string" && AGENT_FAMILY_TOOL_NAMES.has(name);
441
+ }
442
+
437
443
  function shouldIndentToolExecution(value: unknown): boolean {
438
444
  if (!value || typeof value !== "object") return false;
439
445
  const toolName = (value as Record<string, unknown>).toolName;
440
- return typeof toolName === "string" && ["Agent", "Agents", "get_subagent_result", "steer_subagent"].includes(toolName);
446
+ return isAgentFamilyToolName(toolName);
441
447
  }
442
448
 
443
449
  function isTerminalImageLine(line: string): boolean {
@@ -541,9 +547,11 @@ function getToolGroupOverallStatus(tools: any[]): ToolStatus {
541
547
 
542
548
  // Claude Code: solid filled circle that is either fully present or fully gone
543
549
  // while pending — never a hollow outlined ○. Classic ● + bold is the sweet
544
- // spot (⬤ was too large in most mono fonts).
550
+ // spot for ordinary tools. Agent-family tools use a breathing size cycle.
545
551
  const STATUS_DOT_FILLED = "●";
546
552
  const STATUS_DOT_BOLD = "\x1b[1m";
553
+ // big → medium → small → invisible → small → medium (then back to big)
554
+ const AGENT_BREATHE_GLYPHS = ["⬤", "●", "•", " ", "•", "●"] as const;
547
555
 
548
556
  function paintStatusDot(colorAnsi: string): string {
549
557
  return `${colorAnsi}${STATUS_DOT_BOLD}${STATUS_DOT_FILLED}${TRANSPARENT_RESET}`;
@@ -554,6 +562,12 @@ function themeStatusDot(theme: Theme, colorKey: "success" | "error" | "dim" | "m
554
562
  return theme.fg(colorKey, `${STATUS_DOT_BOLD}${STATUS_DOT_FILLED}`);
555
563
  }
556
564
 
565
+ function agentBreatheDot(theme: Theme): string {
566
+ const glyph = AGENT_BREATHE_GLYPHS[_globalBlinkPhaseIndex % AGENT_BREATHE_GLYPHS.length];
567
+ if (glyph === " ") return " ";
568
+ return theme.fg("success", `${STATUS_DOT_BOLD}${glyph}`);
569
+ }
570
+
557
571
  function groupStatusLight(status: ToolStatus): string {
558
572
  const color = status === "success" ? TOOL_STATUS_SUCCESS : status === "error" ? TOOL_STATUS_ERROR : TOOL_STATUS_PENDING;
559
573
  if (status === "pending") {
@@ -2590,9 +2604,10 @@ function indentBranchBlock(block: string): string {
2590
2604
 
2591
2605
  const MAX_BLINKING_TOOLS = 5;
2592
2606
  const BLINK_INTERVAL_MS = 500;
2593
- // If no streaming event occurs for this long, assume blink entries are leaked
2594
- // (a tool completed without clearing, or the turn ended without turn_end) and
2595
- // stop the re-render storm. Prevents idle TUI crashes and unbounded memory growth.
2607
+ // Safety net ONLY for leaked entries after the agent has stopped. Quiet
2608
+ // long-running tools (sleep, sparse builds, waiting on network) legitimately
2609
+ // emit no tool_execution_update for minutes — treating that silence as "stale"
2610
+ // is what made their ● freeze mid-run.
2596
2611
  const BLINK_STALE_TIMEOUT_MS = 15000;
2597
2612
  let _lastBlinkActivity = 0;
2598
2613
 
@@ -2605,6 +2620,9 @@ type BlinkEntry = { key: any; order: number; invalidate: () => void };
2605
2620
  const _blinkContexts = new Map<any, BlinkEntry>();
2606
2621
  let _globalBlinkTimer: ReturnType<typeof setTimeout> | null = null;
2607
2622
  let _blinkOrder = 0;
2623
+ // Shared phase for all blinkers. Ordinary tools use even/odd (on/off ●).
2624
+ // Agent-family tools map the index onto a 6-step size breath cycle.
2625
+ let _globalBlinkPhaseIndex = 0;
2608
2626
  let _globalBlinkPhase = true;
2609
2627
 
2610
2628
  function getBlinkIntervalMs(): number {
@@ -2632,6 +2650,18 @@ function updateBlinkActiveStates(): void {
2632
2650
  }
2633
2651
  }
2634
2652
 
2653
+ function _clearAllBlinkContexts(): void {
2654
+ for (const entry of _blinkContexts.values()) {
2655
+ try { entry.key._blinkActive = false; } catch { /* noop */ }
2656
+ }
2657
+ _blinkContexts.clear();
2658
+ if (_globalBlinkTimer) {
2659
+ clearTimeout(_globalBlinkTimer);
2660
+ _globalBlinkTimer = null;
2661
+ }
2662
+ updateBlinkActiveStates();
2663
+ }
2664
+
2635
2665
  function _scheduleGlobalBlinkTimer(): void {
2636
2666
  if (_globalBlinkTimer) return;
2637
2667
  const intervalMs = getBlinkIntervalMs();
@@ -2642,18 +2672,17 @@ function _scheduleGlobalBlinkTimer(): void {
2642
2672
  updateBlinkActiveStates();
2643
2673
  return;
2644
2674
  }
2645
- // Watchdog: if nothing has streamed for a while, the remaining entries are
2646
- // almost certainly leaked. Clear them and stop re-arming so we don't keep
2647
- // forcing full TUI re-renders while idle (render-assertion crash / OOM).
2648
- if (_lastBlinkActivity && Date.now() - _lastBlinkActivity > BLINK_STALE_TIMEOUT_MS) {
2649
- for (const entry of _blinkContexts.values()) {
2650
- try { entry.key._blinkActive = false; } catch { /* noop */ }
2651
- }
2652
- _blinkContexts.clear();
2653
- updateBlinkActiveStates();
2675
+ // While an agent run is live, quiet tools are still in flight — keep blinking.
2676
+ // Heartbeat here so sparse/no-output commands never look "stale".
2677
+ if (currentAgentWorkStartMs !== undefined) {
2678
+ markBlinkActivity();
2679
+ } else if (_lastBlinkActivity && Date.now() - _lastBlinkActivity > BLINK_STALE_TIMEOUT_MS) {
2680
+ // Agent already finished; leftover entries are leaks. Stop the re-render storm.
2681
+ _clearAllBlinkContexts();
2654
2682
  return;
2655
2683
  }
2656
- _globalBlinkPhase = !_globalBlinkPhase;
2684
+ _globalBlinkPhaseIndex = (_globalBlinkPhaseIndex + 1) % AGENT_BREATHE_GLYPHS.length;
2685
+ _globalBlinkPhase = _globalBlinkPhaseIndex % 2 === 0;
2657
2686
  for (const entry of getBlinkingEntries()) {
2658
2687
  try { entry.invalidate(); } catch { /* noop */ }
2659
2688
  }
@@ -2675,12 +2704,18 @@ function setupBlinkTimer(ctx: any): void {
2675
2704
  const invalidate = typeof ctx?.invalidate === "function" ? () => safeInvalidate(ctx) : () => {};
2676
2705
  const existing = _blinkContexts.get(key);
2677
2706
  if (existing) {
2678
- // Already tracked — just refresh the invalidate fn, skip expensive recalc
2707
+ // Already tracked — refresh invalidate + ensure the global timer is alive.
2708
+ // If a prior watchdog/pass stopped the timer without removing this entry
2709
+ // (or the timer simply died), a quiet long-running tool would otherwise
2710
+ // stay registered forever with a frozen ●.
2679
2711
  existing.invalidate = invalidate;
2712
+ markBlinkActivity();
2713
+ _scheduleGlobalBlinkTimer();
2680
2714
  return;
2681
2715
  }
2682
2716
  _blinkContexts.set(key, { key, order: ++_blinkOrder, invalidate });
2683
2717
  key._blinkActive = false;
2718
+ markBlinkActivity();
2684
2719
  updateBlinkActiveStates();
2685
2720
  _stopGlobalBlinkTimerIfEmpty();
2686
2721
  _scheduleGlobalBlinkTimer();
@@ -2709,9 +2744,13 @@ function blinkDot(ctx: any, theme: Theme): string {
2709
2744
  }
2710
2745
  setupBlinkTimer(ctx);
2711
2746
  const key = getBlinkKey(ctx);
2747
+ if (key?._blinkActive !== true) return " ";
2748
+ // Agent-family tools breathe through sizes; ordinary tools still on/off ●.
2749
+ if (ctx?.state?._agentBreathe === true) {
2750
+ return agentBreatheDot(theme);
2751
+ }
2712
2752
  // Claude Code: solid filled circle that either shows or fully disappears —
2713
2753
  // never a hollow outlined ○ in the off phase.
2714
- if (key?._blinkActive !== true) return " ";
2715
2754
  return _globalBlinkPhase ? themeStatusDot(theme, "success") : " ";
2716
2755
  }
2717
2756
 
@@ -4905,6 +4944,8 @@ function genericToolLabel(name: string): string {
4905
4944
  function renderGenericToolCall(name: string, args: any, theme: Theme, ctx: any): Text {
4906
4945
  syncToolCallStatus(ctx);
4907
4946
  ctx.state._openAiPatchFiles = [];
4947
+ // Agent / subagent tools get a size-breathing pending marker, not on/off ●.
4948
+ if (isAgentFamilyToolName(name)) ctx.state._agentBreathe = true;
4908
4949
  const sp = (path: string) => shortPath(ctx.cwd ?? process.cwd(), path);
4909
4950
  const summary = stableCallSummary(ctx, "_callSummary", () => summarizeGenericToolCall(name, args, theme, sp));
4910
4951
  return makeText(
@@ -6768,66 +6809,40 @@ export default function (pi: ExtensionAPI) {
6768
6809
  registerMcpToolOverrides();
6769
6810
  });
6770
6811
 
6771
- // Streaming activity keeps the blink timer alive; agent_end clears leaked
6772
- // entries so the re-render loop can't keep running while idle.
6812
+ // Streaming activity keeps the blink timer alive. Do NOT clear blink contexts
6813
+ // on turn_end — a turn ends when the assistant message finishes, BEFORE its
6814
+ // tools run. agent_end / agent_settled are the real "work finished" signals.
6773
6815
  pi.on("turn_start", async () => { markBlinkActivity(); });
6774
6816
  pi.on("message_start", async () => { markBlinkActivity(); });
6775
6817
  pi.on("message_update", async () => { markBlinkActivity(); });
6776
6818
  pi.on("tool_execution_start", async () => { markBlinkActivity(); });
6777
6819
  // Partial tool output is the main long-running signal (bash streams for minutes).
6778
- // Without this, the 15s stale watchdog can kill blink while a quiet command runs.
6779
6820
  pi.on("tool_execution_update", async () => { markBlinkActivity(); });
6780
6821
  pi.on("tool_execution_end", async () => { markBlinkActivity(); });
6822
+ // agent_end fires when a low-level run finishes (tools for that assistant message
6823
+ // are done). registerThinkingLabels clears currentAgentWorkStartMs on the same
6824
+ // event; defer so we only wipe blink state once the work marker is gone.
6825
+ // Do not use agent_settled here — older peer types don't include it, and the
6826
+ // live-agent heartbeat already keeps quiet long tools blinking until agent_end.
6781
6827
  pi.on("agent_end", async () => {
6782
- markBlinkActivity();
6783
- for (const entry of _blinkContexts.values()) {
6784
- entry.key._blinkActive = false;
6785
- }
6786
- _blinkContexts.clear();
6787
- if (_globalBlinkTimer) {
6788
- clearTimeout(_globalBlinkTimer);
6789
- _globalBlinkTimer = null;
6790
- }
6791
- });
6792
-
6793
- // Safety net: clear blink timers after a turn's tools finish.
6794
- // Keep Shiki hlCache across turns — it's an LRU (CACHE_LIMIT=48) and
6795
- // re-highlighting the same diffs every turn was a free long-chat tax.
6796
- // Full clear still happens on session_shutdown / theme rebind.
6797
- pi.on("turn_end", async () => {
6798
- for (const entry of _blinkContexts.values()) {
6799
- entry.key._blinkActive = false;
6800
- }
6801
- _blinkContexts.clear();
6802
- if (_globalBlinkTimer) {
6803
- clearTimeout(_globalBlinkTimer);
6804
- _globalBlinkTimer = null;
6805
- }
6828
+ queueMicrotask(() => {
6829
+ if (currentAgentWorkStartMs !== undefined) {
6830
+ markBlinkActivity();
6831
+ return;
6832
+ }
6833
+ _clearAllBlinkContexts();
6834
+ });
6806
6835
  });
6807
6836
  // Session rebuild (resume/reload/fork) must not leave history partials blinking.
6808
6837
  pi.on("session_start", async () => {
6809
- for (const entry of _blinkContexts.values()) {
6810
- entry.key._blinkActive = false;
6811
- }
6812
- _blinkContexts.clear();
6813
- if (_globalBlinkTimer) {
6814
- clearTimeout(_globalBlinkTimer);
6815
- _globalBlinkTimer = null;
6816
- }
6838
+ _clearAllBlinkContexts();
6817
6839
  });
6818
6840
  pi.on("session_shutdown", async () => {
6819
- for (const entry of _blinkContexts.values()) {
6820
- entry.key._blinkActive = false;
6821
- }
6822
- _blinkContexts.clear();
6841
+ _clearAllBlinkContexts();
6823
6842
  clearRtkRewriteState();
6824
6843
  WRITE_EXISTED_BEFORE.clear();
6825
6844
  clearHighlightCache();
6826
6845
  invalidateThemePaletteCache();
6827
6846
  bumpToolBranchVisualEpoch();
6828
- if (_globalBlinkTimer) {
6829
- clearTimeout(_globalBlinkTimer);
6830
- _globalBlinkTimer = null;
6831
- }
6832
6847
  });
6833
6848
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-code-ui",
3
- "version": "1.0.70",
3
+ "version": "1.0.72",
4
4
  "description": "Claude Code-style grouped tool rows for pi with Ctrl+O/Ctrl+Shift+O previews, math rendering, and consistent built-in, MCP, and custom tool rendering",
5
5
  "keywords": [
6
6
  "pi-package",