pi-claude-code-ui 1.0.69 → 1.0.71

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
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ > [!IMPORTANT]
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
+
6
+ ## 1.0.71 — 2026-07-18
7
+
8
+ ### Fixed
9
+
10
+ - **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).
11
+
12
+ ## 1.0.70 — 2026-07-17
13
+
14
+ ### Fixed
15
+
16
+ - **Live tool status dots blink again while commands stream** — partial tool rows re-arm the blink timer from both the call header and the live preview path, and `tool_execution_update` keeps the 15s stale watchdog from killing blink mid-bash.
17
+ - **Interrupted / resumed tools no longer blink forever** — only tools with `executionStarted` during a live agent run count as pending. History partials (resume, compaction, `/tree`, aborted runs without a toolResult) settle to a static green/dim dot and clear blink timers on `session_start`.
18
+
19
+ ### Changed
20
+
21
+ - **No more `Running...` status row** — the blinking `●` on the tool heading is the only in-flight indicator. Live non-empty line count moves to the heading as muted `(N lines)`; the body shows only the output tail while streaming.
22
+
3
23
  ## 1.0.69 — 2026-07-17
4
24
 
5
25
  ### Changed
package/README.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # pi-claude-code-ui
2
2
 
3
+ > [!IMPORTANT]
4
+ > **Package renamed in 1.0.69.** This project is now published as [`pi-claude-code-ui`](https://www.npmjs.com/package/pi-claude-code-ui) (was `pi-claude-style-tools`).
5
+ >
6
+ > Install / migrate:
7
+ > ```bash
8
+ > pi install npm:pi-claude-code-ui
9
+ > # or
10
+ > npm i pi-claude-code-ui
11
+ > ```
12
+ > See [CHANGELOG 1.0.69](./CHANGELOG.md#1069--2026-07-17) for the full release notes (status dots, bare branch connectors, and more).
13
+
3
14
  Claude Code inspired tool rendering for Pi — Shiki-powered diffs, status dots, branch connectors, file icons, and configurable output modes.
4
15
 
5
16
  ## Features
@@ -483,7 +483,13 @@ type ToolStatus = "pending" | "success" | "error";
483
483
  function getToolStatusForGroup(tool: any): ToolStatus {
484
484
  if (tool?.result?.isError) return "error";
485
485
  if (tool?.result && tool?.isPartial !== true) return "success";
486
- return "pending";
486
+ // Only in-flight tools that actually started this agent run count as pending.
487
+ // History rows reconstructed without a matching toolResult stay isPartial=true
488
+ // forever; treating them as pending made interrupted tools blink again on resume.
489
+ if (tool?.isPartial === true && tool?.executionStarted === true && currentAgentWorkStartMs !== undefined) {
490
+ return "pending";
491
+ }
492
+ return "success";
487
493
  }
488
494
 
489
495
  let TOOL_STATUS_SUCCESS = "\x1b[32m";
@@ -2117,6 +2123,28 @@ function patchToolExecutionBackgroundSync(): void {
2117
2123
  proto[TOOL_BG_PATCH_FLAG] = true;
2118
2124
  }
2119
2125
 
2126
+ function syncLiveToolRenderState(component: any): void {
2127
+ // updateDisplay paints call header BEFORE result. Pre-seed status + live line
2128
+ // count so the header's blinking ● and `(N lines)` trail stay in sync with
2129
+ // the partial result that is about to render underneath.
2130
+ const state = component?.rendererState;
2131
+ if (!state || typeof state !== "object") return;
2132
+ const ctxLike = {
2133
+ state,
2134
+ isPartial: component?.isPartial === true,
2135
+ executionStarted: component?.executionStarted === true,
2136
+ isError: component?.result?.isError === true,
2137
+ };
2138
+ syncToolCallStatus(ctxLike);
2139
+ if (component?.isPartial === true && component?.result) {
2140
+ const raw = getTextContent(component.result).replace(/\r\n/g, "\n").trimEnd();
2141
+ // tailLimit=0 → count-only (no line materialization) so huge bash tails stay cheap.
2142
+ state._liveLineCount = collectNonEmptyLines(raw, 0).total;
2143
+ } else if (component?.isPartial !== true) {
2144
+ delete state._liveLineCount;
2145
+ }
2146
+ }
2147
+
2120
2148
  function patchToolRenderCacheInvalidation(): void {
2121
2149
  const proto = ToolExecutionComponent.prototype as any;
2122
2150
  if (proto[TOOL_CACHE_PATCH_FLAG]) return;
@@ -2138,6 +2166,9 @@ function patchToolRenderCacheInvalidation(): void {
2138
2166
  if (typeof original !== "function") continue;
2139
2167
  proto[method] = function patchedToolMutation(...args: any[]) {
2140
2168
  clearToolRenderCache(this);
2169
+ if (method === "updateDisplay" || method === "updateResult" || method === "invalidate") {
2170
+ syncLiveToolRenderState(this);
2171
+ }
2141
2172
  const result = original.apply(this, args);
2142
2173
  clearToolRenderCache(this);
2143
2174
  return result;
@@ -2240,31 +2271,50 @@ function isBlinkOn(): boolean {
2240
2271
  return Math.floor(Date.now() / 500) % 2 === 0;
2241
2272
  }
2242
2273
 
2243
- function toolHeader(tool: string, summary: string, theme: Theme, prefix = ""): string {
2274
+ function toolHeader(tool: string, summary: string, theme: Theme, prefix = "", trailing = ""): string {
2244
2275
  applyThemePaletteIfNeeded(theme);
2245
2276
  const label = theme.fg("toolTitle", theme.bold(tool));
2246
- if (!summary) return `${prefix}${label}`;
2247
- return `${prefix}${label} ${WRAP_MARK}${theme.fg("accent", summary)}`;
2277
+ const body = summary
2278
+ ? `${label} ${WRAP_MARK}${theme.fg("accent", summary)}`
2279
+ : label;
2280
+ return trailing ? `${prefix}${body}${trailing}` : `${prefix}${body}`;
2281
+ }
2282
+
2283
+ function liveLineCountTrailing(ctx: any, theme: Theme): string {
2284
+ if (ctx?.isPartial !== true) return "";
2285
+ const count = ctx?.state?._liveLineCount;
2286
+ if (typeof count !== "number" || !Number.isFinite(count) || count <= 0) return "";
2287
+ return ` ${theme.fg("muted", `(${lineCountLabel(count)})`)}`;
2248
2288
  }
2249
2289
 
2250
- function setToolStatus(ctx: any, status: "pending" | "success" | "error"): void {
2251
- ctx.state._toolStatus = status;
2290
+ function setToolStatus(ctx: any, status: "pending" | "success" | "error" | "idle"): void {
2291
+ if (ctx?.state) ctx.state._toolStatus = status;
2252
2292
  }
2253
2293
 
2254
2294
  function syncToolCallStatus(ctx: any): void {
2255
2295
  if (ctx?.isPartial) {
2256
- // A partial tool row blinks only while an agent is actually running.
2257
- // When reconstructed from session history (initial load, compaction
2258
- // rebuild, or /tree navigation to a node ending in a tool call) the
2259
- // matching tool result lives outside the selected branch, so core never
2260
- // calls updateResult() and isPartial stays true. With no active agent
2261
- // that row is settled, not live: aborted calls already carry an isError
2262
- // result (isPartial=false), so a leftover partial here has succeeded.
2263
- const live = currentAgentWorkStartMs !== undefined;
2264
- setToolStatus(ctx, live ? "pending" : "success");
2296
+ // Blink only for tools that actually started in the current agent run.
2297
+ // History rebuilds (resume, compaction, /tree) leave unmatched tool calls
2298
+ // with isPartial=true forever and never set executionStarted — those must
2299
+ // render settled, not keep a pending blink alive across sessions.
2300
+ const agentLive = currentAgentWorkStartMs !== undefined;
2301
+ const started = ctx?.executionStarted === true;
2302
+ if (agentLive && started) {
2303
+ setToolStatus(ctx, "pending");
2304
+ return;
2305
+ }
2306
+ if (agentLive && !started) {
2307
+ // Args still streaming before tool_execution_start — static, no blink.
2308
+ setToolStatus(ctx, "idle");
2309
+ clearBlinkTimer(ctx);
2310
+ return;
2311
+ }
2312
+ setToolStatus(ctx, "success");
2313
+ clearBlinkTimer(ctx);
2265
2314
  return;
2266
2315
  }
2267
2316
  setToolStatus(ctx, ctx.isError ? "error" : "success");
2317
+ clearBlinkTimer(ctx);
2268
2318
  }
2269
2319
 
2270
2320
  function shouldRevealCallArgs(ctx: any): boolean {
@@ -2480,9 +2530,10 @@ function getWriteWasNewFile(ctx: any, cwd: string, filePath: string, reveal = sh
2480
2530
  }
2481
2531
 
2482
2532
  function toolStatusDot(ctx: any, theme: Theme): string {
2483
- const status = ctx.state?._toolStatus as "pending" | "success" | "error" | undefined;
2533
+ const status = ctx.state?._toolStatus as "pending" | "success" | "error" | "idle" | undefined;
2484
2534
  if (status === "success") return `${themeStatusDot(theme, "success")} `;
2485
2535
  if (status === "error") return `${themeStatusDot(theme, "error")} `;
2536
+ if (status === "idle") return `${themeStatusDot(theme, "dim")} `;
2486
2537
  return `${blinkDot(ctx, theme)} `;
2487
2538
  }
2488
2539
 
@@ -2539,9 +2590,10 @@ function indentBranchBlock(block: string): string {
2539
2590
 
2540
2591
  const MAX_BLINKING_TOOLS = 5;
2541
2592
  const BLINK_INTERVAL_MS = 500;
2542
- // If no streaming event occurs for this long, assume blink entries are leaked
2543
- // (a tool completed without clearing, or the turn ended without turn_end) and
2544
- // stop the re-render storm. Prevents idle TUI crashes and unbounded memory growth.
2593
+ // Safety net ONLY for leaked entries after the agent has stopped. Quiet
2594
+ // long-running tools (sleep, sparse builds, waiting on network) legitimately
2595
+ // emit no tool_execution_update for minutes — treating that silence as "stale"
2596
+ // is what made their ● freeze mid-run.
2545
2597
  const BLINK_STALE_TIMEOUT_MS = 15000;
2546
2598
  let _lastBlinkActivity = 0;
2547
2599
 
@@ -2581,6 +2633,18 @@ function updateBlinkActiveStates(): void {
2581
2633
  }
2582
2634
  }
2583
2635
 
2636
+ function _clearAllBlinkContexts(): void {
2637
+ for (const entry of _blinkContexts.values()) {
2638
+ try { entry.key._blinkActive = false; } catch { /* noop */ }
2639
+ }
2640
+ _blinkContexts.clear();
2641
+ if (_globalBlinkTimer) {
2642
+ clearTimeout(_globalBlinkTimer);
2643
+ _globalBlinkTimer = null;
2644
+ }
2645
+ updateBlinkActiveStates();
2646
+ }
2647
+
2584
2648
  function _scheduleGlobalBlinkTimer(): void {
2585
2649
  if (_globalBlinkTimer) return;
2586
2650
  const intervalMs = getBlinkIntervalMs();
@@ -2591,15 +2655,13 @@ function _scheduleGlobalBlinkTimer(): void {
2591
2655
  updateBlinkActiveStates();
2592
2656
  return;
2593
2657
  }
2594
- // Watchdog: if nothing has streamed for a while, the remaining entries are
2595
- // almost certainly leaked. Clear them and stop re-arming so we don't keep
2596
- // forcing full TUI re-renders while idle (render-assertion crash / OOM).
2597
- if (_lastBlinkActivity && Date.now() - _lastBlinkActivity > BLINK_STALE_TIMEOUT_MS) {
2598
- for (const entry of _blinkContexts.values()) {
2599
- try { entry.key._blinkActive = false; } catch { /* noop */ }
2600
- }
2601
- _blinkContexts.clear();
2602
- updateBlinkActiveStates();
2658
+ // While an agent run is live, quiet tools are still in flight — keep blinking.
2659
+ // Heartbeat here so sparse/no-output commands never look "stale".
2660
+ if (currentAgentWorkStartMs !== undefined) {
2661
+ markBlinkActivity();
2662
+ } else if (_lastBlinkActivity && Date.now() - _lastBlinkActivity > BLINK_STALE_TIMEOUT_MS) {
2663
+ // Agent already finished; leftover entries are leaks. Stop the re-render storm.
2664
+ _clearAllBlinkContexts();
2603
2665
  return;
2604
2666
  }
2605
2667
  _globalBlinkPhase = !_globalBlinkPhase;
@@ -2624,12 +2686,18 @@ function setupBlinkTimer(ctx: any): void {
2624
2686
  const invalidate = typeof ctx?.invalidate === "function" ? () => safeInvalidate(ctx) : () => {};
2625
2687
  const existing = _blinkContexts.get(key);
2626
2688
  if (existing) {
2627
- // Already tracked — just refresh the invalidate fn, skip expensive recalc
2689
+ // Already tracked — refresh invalidate + ensure the global timer is alive.
2690
+ // If a prior watchdog/pass stopped the timer without removing this entry
2691
+ // (or the timer simply died), a quiet long-running tool would otherwise
2692
+ // stay registered forever with a frozen ●.
2628
2693
  existing.invalidate = invalidate;
2694
+ markBlinkActivity();
2695
+ _scheduleGlobalBlinkTimer();
2629
2696
  return;
2630
2697
  }
2631
2698
  _blinkContexts.set(key, { key, order: ++_blinkOrder, invalidate });
2632
2699
  key._blinkActive = false;
2700
+ markBlinkActivity();
2633
2701
  updateBlinkActiveStates();
2634
2702
  _stopGlobalBlinkTimerIfEmpty();
2635
2703
  _scheduleGlobalBlinkTimer();
@@ -2651,6 +2719,11 @@ function pendingToolChromeColor(theme: Theme): "dim" | "muted" | "thinkingText"
2651
2719
  }
2652
2720
 
2653
2721
  function blinkDot(ctx: any, theme: Theme): string {
2722
+ // Only true in-flight tools arm the blink timer. Idle partials (args still
2723
+ // streaming, or history rows left isPartial without a result) stay static.
2724
+ if (ctx?.state?._toolStatus !== "pending") {
2725
+ return themeStatusDot(theme, "dim");
2726
+ }
2654
2727
  setupBlinkTimer(ctx);
2655
2728
  const key = getBlinkKey(ctx);
2656
2729
  // Claude Code: solid filled circle that either shows or fully disappears —
@@ -4724,6 +4797,10 @@ function registerThinkingLabels(pi: ExtensionAPI): void {
4724
4797
  // message history, so /new starts fresh while /resume picks up past prompts
4725
4798
  // and the original session start time. Resetting here is what lets /new
4726
4799
  // clear the totals (Math.min / Math.max seeding alone could never lower them).
4800
+ // Also drop the live-agent marker so history partials rebuilt during resume
4801
+ // never look "in flight" and re-arm blink timers.
4802
+ currentAgentWorkStartMs = undefined;
4803
+ currentAssistantMessageStartMs = undefined;
4727
4804
  sessionStartMs = undefined;
4728
4805
  userTurnCount = 0;
4729
4806
  });
@@ -4847,7 +4924,10 @@ function renderGenericToolCall(name: string, args: any, theme: Theme, ctx: any):
4847
4924
  ctx.state._openAiPatchFiles = [];
4848
4925
  const sp = (path: string) => shortPath(ctx.cwd ?? process.cwd(), path);
4849
4926
  const summary = stableCallSummary(ctx, "_callSummary", () => summarizeGenericToolCall(name, args, theme, sp));
4850
- return makeText(ctx.lastComponent, toolHeader(genericToolLabel(name), summary, theme, toolStatusDot(ctx, theme)));
4927
+ return makeText(
4928
+ ctx.lastComponent,
4929
+ toolHeader(genericToolLabel(name), summary, theme, toolStatusDot(ctx, theme), liveLineCountTrailing(ctx, theme)),
4930
+ );
4851
4931
  }
4852
4932
 
4853
4933
  function renderGenericToolResult(name: string, result: any, options: any, theme: Theme, ctx: any): Text {
@@ -4903,17 +4983,20 @@ function lineCountLabel(count: number): string {
4903
4983
 
4904
4984
  function runningPreviewBlock(
4905
4985
  result: any,
4906
- statusText: string,
4986
+ _statusText: string,
4907
4987
  expanded: boolean,
4908
4988
  theme: Theme,
4909
4989
  ctx: any,
4910
4990
  options: { lines?: string[]; totalLineCount?: number; styleLine?: (line: string) => string; tail?: boolean } = {},
4911
4991
  ): string {
4912
- setupBlinkTimer(ctx);
4992
+ // Keep the header status dot blinking while partial output streams. Call/result
4993
+ // renderers share rendererState, so setupBlinkTimer here re-arms the same key
4994
+ // the call header uses — but only when this tool actually started executing.
4995
+ syncToolCallStatus(ctx);
4996
+ if (ctx?.state?._toolStatus === "pending") setupBlinkTimer(ctx);
4997
+ else clearBlinkTimer(ctx);
4998
+
4913
4999
  const limit = liveToolPreviewLimit();
4914
- if (!liveToolPreviewEnabled() || limit <= 0) {
4915
- return withBranch(statusText, theme);
4916
- }
4917
5000
  let lines: string[];
4918
5001
  let totalLineCount: number;
4919
5002
  if (options.lines) {
@@ -4926,8 +5009,13 @@ function runningPreviewBlock(
4926
5009
  lines = collected.lines;
4927
5010
  totalLineCount = collected.total;
4928
5011
  }
4929
- if (totalLineCount === 0) {
4930
- return withBranch(statusText, theme);
5012
+ // Line count lives on the tool heading (via liveLineCountTrailing); keep it in
5013
+ // renderer state so the next renderCall pass can pick it up.
5014
+ if (ctx?.state) ctx.state._liveLineCount = totalLineCount;
5015
+
5016
+ if (!liveToolPreviewEnabled() || limit <= 0 || totalLineCount === 0) {
5017
+ // No status row — the blinking ● on the header is the only running indicator.
5018
+ return "";
4931
5019
  }
4932
5020
 
4933
5021
  const styleLine = options.styleLine ?? ((line: string) => theme.fg("dim", line || " "));
@@ -4942,7 +5030,7 @@ function runningPreviewBlock(
4942
5030
  if (options.tail && !expanded && totalLineCount > previewSource.length) {
4943
5031
  preview = `${theme.fg("muted", `... (${totalLineCount - previewSource.length} earlier lines${toolOutputDetailHint(theme, expanded, true)})`)}\n${preview}`;
4944
5032
  }
4945
- return withBranch(`${statusText} ${theme.fg("muted", `(${lineCountLabel(totalLineCount)})`)}\n${preview}`, theme);
5033
+ return withBranch(preview, theme);
4946
5034
  }
4947
5035
 
4948
5036
  function buildPersistentBashPreview(lines: string[], theme: Theme): string {
@@ -5317,7 +5405,7 @@ function renderApplyPatchCall(args: any, theme: Theme, ctx: any, sp: (path: stri
5317
5405
  syncToolCallStatus(ctx);
5318
5406
  const patchText = getStringArg(args, "patchText", "patch_text");
5319
5407
  const summary = stableCallSummary(ctx, "_callSummary", () => summarizeOpenAiToolCall("apply_patch", args, theme, sp));
5320
- const hdr = toolHeader("Apply Patch", summary, theme, toolStatusDot(ctx, theme));
5408
+ const hdr = toolHeader("Apply Patch", summary, theme, toolStatusDot(ctx, theme), liveLineCountTrailing(ctx, theme));
5321
5409
 
5322
5410
  if (!ctx.argsComplete) return makeText(ctx.lastComponent, hdr);
5323
5411
  const preview = getCachedApplyPatchPreview(patchText, sp, ctx);
@@ -6150,7 +6238,7 @@ export default function (pi: ExtensionAPI) {
6150
6238
  const line =
6151
6239
  theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) +
6152
6240
  theme.fg("customMessageText", skillName);
6153
- return makeText(ctx.lastComponent, `${toolStatusDot(ctx, theme)}${line}`);
6241
+ return makeText(ctx.lastComponent, `${toolStatusDot(ctx, theme)}${line}${liveLineCountTrailing(ctx, theme)}`);
6154
6242
  }
6155
6243
  const summary = stableCallSummary(ctx, "_callSummary", () => {
6156
6244
  let value = sp(args.path ?? "");
@@ -6162,7 +6250,10 @@ export default function (pi: ExtensionAPI) {
6162
6250
  }
6163
6251
  return value;
6164
6252
  });
6165
- return makeText(ctx.lastComponent, toolHeader("Read", summary, theme, toolStatusDot(ctx, theme)));
6253
+ return makeText(
6254
+ ctx.lastComponent,
6255
+ toolHeader("Read", summary, theme, toolStatusDot(ctx, theme), liveLineCountTrailing(ctx, theme)),
6256
+ );
6166
6257
  },
6167
6258
  renderResult(result, { expanded, isPartial }, theme, ctx) {
6168
6259
  if (isPartial) {
@@ -6197,7 +6288,10 @@ export default function (pi: ExtensionAPI) {
6197
6288
  const rewrite = ensureRtkRewriteForContext(ctx, args);
6198
6289
  const summary = stableCallSummary(ctx, "_callSummary", () => summarizeText(args.command, 72));
6199
6290
  const rtkBadge = rewrite ? theme.fg("muted", " (RTK)") : "";
6200
- return makeText(ctx.lastComponent, toolHeader("Bash", `${summary}${rtkBadge}`, theme, toolStatusDot(ctx, theme)));
6291
+ return makeText(
6292
+ ctx.lastComponent,
6293
+ toolHeader("Bash", `${summary}${rtkBadge}`, theme, toolStatusDot(ctx, theme), liveLineCountTrailing(ctx, theme)),
6294
+ );
6201
6295
  },
6202
6296
  renderResult(result, { expanded, isPartial }, theme, ctx) {
6203
6297
  const details = result.details as BashToolDetails | undefined;
@@ -6205,13 +6299,15 @@ export default function (pi: ExtensionAPI) {
6205
6299
  const output = result.content[0]?.type === "text" ? result.content[0].text : "";
6206
6300
  if (isPartial) {
6207
6301
  const preview = collectNonEmptyLines(output, expanded ? undefined : liveToolPreviewLimit());
6208
- const running = runningPreviewBlock(result, theme.fg("warning", "Running..."), expanded, theme, ctx, {
6302
+ const running = runningPreviewBlock(result, "", expanded, theme, ctx, {
6209
6303
  lines: preview.lines,
6210
6304
  totalLineCount: preview.total,
6211
6305
  styleLine: (line) => theme.fg("dim", line),
6212
6306
  tail: true,
6213
6307
  });
6214
- const withRewrite = expanded && rewrite ? `${running}\n${withBranch(formatRtkRewriteDetails(rewrite, theme), theme)}` : running;
6308
+ const withRewrite = expanded && rewrite
6309
+ ? [running, withBranch(formatRtkRewriteDetails(rewrite, theme), theme)].filter(Boolean).join("\n")
6310
+ : running;
6215
6311
  return makeText(ctx.lastComponent, withRewrite);
6216
6312
  }
6217
6313
  // Collapsed: only keep the live-preview tail (or nothing). Expanded: full list.
@@ -6255,7 +6351,10 @@ export default function (pi: ExtensionAPI) {
6255
6351
  if (args.path) value += ` in ${args.path}`;
6256
6352
  return value;
6257
6353
  });
6258
- return makeText(ctx.lastComponent, toolHeader("Grep", summary, theme, toolStatusDot(ctx, theme)));
6354
+ return makeText(
6355
+ ctx.lastComponent,
6356
+ toolHeader("Grep", summary, theme, toolStatusDot(ctx, theme), liveLineCountTrailing(ctx, theme)),
6357
+ );
6259
6358
  },
6260
6359
  renderResult(result, { expanded, isPartial }, theme, ctx) {
6261
6360
  if (isPartial) {
@@ -6292,7 +6391,10 @@ export default function (pi: ExtensionAPI) {
6292
6391
  if (args.path) value += ` in ${args.path}`;
6293
6392
  return value;
6294
6393
  });
6295
- return makeText(ctx.lastComponent, toolHeader("Find", summary, theme, toolStatusDot(ctx, theme)));
6394
+ return makeText(
6395
+ ctx.lastComponent,
6396
+ toolHeader("Find", summary, theme, toolStatusDot(ctx, theme), liveLineCountTrailing(ctx, theme)),
6397
+ );
6296
6398
  },
6297
6399
  renderResult(result, { expanded, isPartial }, theme, ctx) {
6298
6400
  if (isPartial) {
@@ -6336,7 +6438,10 @@ export default function (pi: ExtensionAPI) {
6336
6438
  renderCall(args, theme, ctx) {
6337
6439
  syncToolCallStatus(ctx);
6338
6440
  const summary = stableCallSummary(ctx, "_callSummary", () => sp(args.path ?? "."));
6339
- return makeText(ctx.lastComponent, toolHeader("List", summary, theme, toolStatusDot(ctx, theme)));
6441
+ return makeText(
6442
+ ctx.lastComponent,
6443
+ toolHeader("List", summary, theme, toolStatusDot(ctx, theme), liveLineCountTrailing(ctx, theme)),
6444
+ );
6340
6445
  },
6341
6446
  renderResult(result, { expanded, isPartial }, theme, ctx) {
6342
6447
  if (isPartial) {
@@ -6411,12 +6516,12 @@ export default function (pi: ExtensionAPI) {
6411
6516
  const base = sp(fp);
6412
6517
  return shouldRevealCallArgs(ctx) ? `${base} ${theme.fg("muted", `(${lineCount(args.content ?? "")} lines)`)}` : base;
6413
6518
  }, revealSummary);
6414
- const hdr = toolHeader(label, summary, theme, toolStatusDot(ctx, theme));
6519
+ const hdr = toolHeader(label, summary, theme, toolStatusDot(ctx, theme), liveLineCountTrailing(ctx, theme));
6415
6520
  return makeText(ctx.lastComponent, hdr);
6416
6521
  },
6417
6522
  renderResult(result, { expanded, isPartial }, theme, ctx) {
6418
6523
  if (isPartial) {
6419
- return makeText(ctx.lastComponent, runningPreviewBlock(result, theme.fg("dim", "Writing..."), expanded, theme, ctx));
6524
+ return makeText(ctx.lastComponent, runningPreviewBlock(result, "", expanded, theme, ctx));
6420
6525
  }
6421
6526
  clearBlinkTimer(ctx);
6422
6527
  setToolStatus(ctx, ctx.isError ? "error" : "success");
@@ -6534,7 +6639,7 @@ export default function (pi: ExtensionAPI) {
6534
6639
  const revealSummary = shouldRevealCallArgs(ctx) || (!!fp && hasOwnArg(args, "edits"));
6535
6640
  const summary = stableCallSummary(ctx, "_callSummary", () => shouldRevealCallArgs(ctx) && operations.length > 1 ? `${sp(fp)} ${theme.fg("muted", `(${operations.length} edits)`)}` : sp(fp), revealSummary);
6536
6641
  syncToolCallStatus(ctx);
6537
- const hdr = toolHeader("Edit", summary, theme, ` ${toolStatusDot(ctx, theme)}`);
6642
+ const hdr = toolHeader("Edit", summary, theme, ` ${toolStatusDot(ctx, theme)}`, liveLineCountTrailing(ctx, theme));
6538
6643
  if (!(ctx.argsComplete && operations.length > 0)) return makeText(ctx.lastComponent, hdr);
6539
6644
  const diffWidth = branchDiffWidth();
6540
6645
  const key = `edit:${fp}:${hashText(operations.map((edit) => `${edit.oldText}\u0000${edit.newText}`).join("\u0001"))}:${diffWidth}:${ctx.expanded ? 1 : 0}`;
@@ -6620,7 +6725,10 @@ export default function (pi: ExtensionAPI) {
6620
6725
  syncToolCallStatus(ctx);
6621
6726
  ctx.state._openAiPatchFiles = [];
6622
6727
  const summary = stableCallSummary(ctx, "_callSummary", () => summarizeOpenAiToolCall(name, args, theme, sp));
6623
- return makeText(ctx.lastComponent, toolHeader(label, summary, theme, toolStatusDot(ctx, theme)));
6728
+ return makeText(
6729
+ ctx.lastComponent,
6730
+ toolHeader(label, summary, theme, toolStatusDot(ctx, theme), liveLineCountTrailing(ctx, theme)),
6731
+ );
6624
6732
  },
6625
6733
  renderResult(result: any, { expanded, isPartial }: any, theme: Theme, ctx: any) {
6626
6734
  if (name === "apply_patch") return renderApplyPatchResult(result, isPartial, theme, ctx);
@@ -6677,51 +6785,40 @@ export default function (pi: ExtensionAPI) {
6677
6785
  registerMcpToolOverrides();
6678
6786
  });
6679
6787
 
6680
- // Streaming activity keeps the blink timer alive; agent_end clears leaked
6681
- // entries so the re-render loop can't keep running while idle.
6788
+ // Streaming activity keeps the blink timer alive. Do NOT clear blink contexts
6789
+ // on turn_end — a turn ends when the assistant message finishes, BEFORE its
6790
+ // tools run. agent_end / agent_settled are the real "work finished" signals.
6682
6791
  pi.on("turn_start", async () => { markBlinkActivity(); });
6683
6792
  pi.on("message_start", async () => { markBlinkActivity(); });
6684
6793
  pi.on("message_update", async () => { markBlinkActivity(); });
6685
6794
  pi.on("tool_execution_start", async () => { markBlinkActivity(); });
6795
+ // Partial tool output is the main long-running signal (bash streams for minutes).
6796
+ pi.on("tool_execution_update", async () => { markBlinkActivity(); });
6797
+ pi.on("tool_execution_end", async () => { markBlinkActivity(); });
6798
+ // agent_end fires when a low-level run finishes (tools for that assistant message
6799
+ // are done). registerThinkingLabels clears currentAgentWorkStartMs on the same
6800
+ // event; defer so we only wipe blink state once the work marker is gone.
6801
+ // Do not use agent_settled here — older peer types don't include it, and the
6802
+ // live-agent heartbeat already keeps quiet long tools blinking until agent_end.
6686
6803
  pi.on("agent_end", async () => {
6687
- markBlinkActivity();
6688
- for (const entry of _blinkContexts.values()) {
6689
- entry.key._blinkActive = false;
6690
- }
6691
- _blinkContexts.clear();
6692
- if (_globalBlinkTimer) {
6693
- clearTimeout(_globalBlinkTimer);
6694
- _globalBlinkTimer = null;
6695
- }
6804
+ queueMicrotask(() => {
6805
+ if (currentAgentWorkStartMs !== undefined) {
6806
+ markBlinkActivity();
6807
+ return;
6808
+ }
6809
+ _clearAllBlinkContexts();
6810
+ });
6696
6811
  });
6697
-
6698
- // Safety net: clear all blink timers on turn boundaries.
6699
- // Keep Shiki hlCache across turns — it's an LRU (CACHE_LIMIT=48) and
6700
- // re-highlighting the same diffs every turn was a free long-chat tax.
6701
- // Full clear still happens on session_shutdown / theme rebind.
6702
- pi.on("turn_end", async () => {
6703
- for (const entry of _blinkContexts.values()) {
6704
- entry.key._blinkActive = false;
6705
- }
6706
- _blinkContexts.clear();
6707
- if (_globalBlinkTimer) {
6708
- clearTimeout(_globalBlinkTimer);
6709
- _globalBlinkTimer = null;
6710
- }
6812
+ // Session rebuild (resume/reload/fork) must not leave history partials blinking.
6813
+ pi.on("session_start", async () => {
6814
+ _clearAllBlinkContexts();
6711
6815
  });
6712
6816
  pi.on("session_shutdown", async () => {
6713
- for (const entry of _blinkContexts.values()) {
6714
- entry.key._blinkActive = false;
6715
- }
6716
- _blinkContexts.clear();
6817
+ _clearAllBlinkContexts();
6717
6818
  clearRtkRewriteState();
6718
6819
  WRITE_EXISTED_BEFORE.clear();
6719
6820
  clearHighlightCache();
6720
6821
  invalidateThemePaletteCache();
6721
6822
  bumpToolBranchVisualEpoch();
6722
- if (_globalBlinkTimer) {
6723
- clearTimeout(_globalBlinkTimer);
6724
- _globalBlinkTimer = null;
6725
- }
6726
6823
  });
6727
6824
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-code-ui",
3
- "version": "1.0.69",
3
+ "version": "1.0.71",
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",