pi-claude-code-ui 1.0.69 → 1.0.70

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,19 @@
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.70 — 2026-07-17
7
+
8
+ ### Fixed
9
+
10
+ - **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.
11
+ - **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`.
12
+
13
+ ### Changed
14
+
15
+ - **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.
16
+
3
17
  ## 1.0.69 — 2026-07-17
4
18
 
5
19
  ### 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
 
@@ -2651,6 +2702,11 @@ function pendingToolChromeColor(theme: Theme): "dim" | "muted" | "thinkingText"
2651
2702
  }
2652
2703
 
2653
2704
  function blinkDot(ctx: any, theme: Theme): string {
2705
+ // Only true in-flight tools arm the blink timer. Idle partials (args still
2706
+ // streaming, or history rows left isPartial without a result) stay static.
2707
+ if (ctx?.state?._toolStatus !== "pending") {
2708
+ return themeStatusDot(theme, "dim");
2709
+ }
2654
2710
  setupBlinkTimer(ctx);
2655
2711
  const key = getBlinkKey(ctx);
2656
2712
  // Claude Code: solid filled circle that either shows or fully disappears —
@@ -4724,6 +4780,10 @@ function registerThinkingLabels(pi: ExtensionAPI): void {
4724
4780
  // message history, so /new starts fresh while /resume picks up past prompts
4725
4781
  // and the original session start time. Resetting here is what lets /new
4726
4782
  // clear the totals (Math.min / Math.max seeding alone could never lower them).
4783
+ // Also drop the live-agent marker so history partials rebuilt during resume
4784
+ // never look "in flight" and re-arm blink timers.
4785
+ currentAgentWorkStartMs = undefined;
4786
+ currentAssistantMessageStartMs = undefined;
4727
4787
  sessionStartMs = undefined;
4728
4788
  userTurnCount = 0;
4729
4789
  });
@@ -4847,7 +4907,10 @@ function renderGenericToolCall(name: string, args: any, theme: Theme, ctx: any):
4847
4907
  ctx.state._openAiPatchFiles = [];
4848
4908
  const sp = (path: string) => shortPath(ctx.cwd ?? process.cwd(), path);
4849
4909
  const summary = stableCallSummary(ctx, "_callSummary", () => summarizeGenericToolCall(name, args, theme, sp));
4850
- return makeText(ctx.lastComponent, toolHeader(genericToolLabel(name), summary, theme, toolStatusDot(ctx, theme)));
4910
+ return makeText(
4911
+ ctx.lastComponent,
4912
+ toolHeader(genericToolLabel(name), summary, theme, toolStatusDot(ctx, theme), liveLineCountTrailing(ctx, theme)),
4913
+ );
4851
4914
  }
4852
4915
 
4853
4916
  function renderGenericToolResult(name: string, result: any, options: any, theme: Theme, ctx: any): Text {
@@ -4903,17 +4966,20 @@ function lineCountLabel(count: number): string {
4903
4966
 
4904
4967
  function runningPreviewBlock(
4905
4968
  result: any,
4906
- statusText: string,
4969
+ _statusText: string,
4907
4970
  expanded: boolean,
4908
4971
  theme: Theme,
4909
4972
  ctx: any,
4910
4973
  options: { lines?: string[]; totalLineCount?: number; styleLine?: (line: string) => string; tail?: boolean } = {},
4911
4974
  ): string {
4912
- setupBlinkTimer(ctx);
4975
+ // Keep the header status dot blinking while partial output streams. Call/result
4976
+ // renderers share rendererState, so setupBlinkTimer here re-arms the same key
4977
+ // the call header uses — but only when this tool actually started executing.
4978
+ syncToolCallStatus(ctx);
4979
+ if (ctx?.state?._toolStatus === "pending") setupBlinkTimer(ctx);
4980
+ else clearBlinkTimer(ctx);
4981
+
4913
4982
  const limit = liveToolPreviewLimit();
4914
- if (!liveToolPreviewEnabled() || limit <= 0) {
4915
- return withBranch(statusText, theme);
4916
- }
4917
4983
  let lines: string[];
4918
4984
  let totalLineCount: number;
4919
4985
  if (options.lines) {
@@ -4926,8 +4992,13 @@ function runningPreviewBlock(
4926
4992
  lines = collected.lines;
4927
4993
  totalLineCount = collected.total;
4928
4994
  }
4929
- if (totalLineCount === 0) {
4930
- return withBranch(statusText, theme);
4995
+ // Line count lives on the tool heading (via liveLineCountTrailing); keep it in
4996
+ // renderer state so the next renderCall pass can pick it up.
4997
+ if (ctx?.state) ctx.state._liveLineCount = totalLineCount;
4998
+
4999
+ if (!liveToolPreviewEnabled() || limit <= 0 || totalLineCount === 0) {
5000
+ // No status row — the blinking ● on the header is the only running indicator.
5001
+ return "";
4931
5002
  }
4932
5003
 
4933
5004
  const styleLine = options.styleLine ?? ((line: string) => theme.fg("dim", line || " "));
@@ -4942,7 +5013,7 @@ function runningPreviewBlock(
4942
5013
  if (options.tail && !expanded && totalLineCount > previewSource.length) {
4943
5014
  preview = `${theme.fg("muted", `... (${totalLineCount - previewSource.length} earlier lines${toolOutputDetailHint(theme, expanded, true)})`)}\n${preview}`;
4944
5015
  }
4945
- return withBranch(`${statusText} ${theme.fg("muted", `(${lineCountLabel(totalLineCount)})`)}\n${preview}`, theme);
5016
+ return withBranch(preview, theme);
4946
5017
  }
4947
5018
 
4948
5019
  function buildPersistentBashPreview(lines: string[], theme: Theme): string {
@@ -5317,7 +5388,7 @@ function renderApplyPatchCall(args: any, theme: Theme, ctx: any, sp: (path: stri
5317
5388
  syncToolCallStatus(ctx);
5318
5389
  const patchText = getStringArg(args, "patchText", "patch_text");
5319
5390
  const summary = stableCallSummary(ctx, "_callSummary", () => summarizeOpenAiToolCall("apply_patch", args, theme, sp));
5320
- const hdr = toolHeader("Apply Patch", summary, theme, toolStatusDot(ctx, theme));
5391
+ const hdr = toolHeader("Apply Patch", summary, theme, toolStatusDot(ctx, theme), liveLineCountTrailing(ctx, theme));
5321
5392
 
5322
5393
  if (!ctx.argsComplete) return makeText(ctx.lastComponent, hdr);
5323
5394
  const preview = getCachedApplyPatchPreview(patchText, sp, ctx);
@@ -6150,7 +6221,7 @@ export default function (pi: ExtensionAPI) {
6150
6221
  const line =
6151
6222
  theme.fg("customMessageLabel", `\x1b[1m[skill]\x1b[22m `) +
6152
6223
  theme.fg("customMessageText", skillName);
6153
- return makeText(ctx.lastComponent, `${toolStatusDot(ctx, theme)}${line}`);
6224
+ return makeText(ctx.lastComponent, `${toolStatusDot(ctx, theme)}${line}${liveLineCountTrailing(ctx, theme)}`);
6154
6225
  }
6155
6226
  const summary = stableCallSummary(ctx, "_callSummary", () => {
6156
6227
  let value = sp(args.path ?? "");
@@ -6162,7 +6233,10 @@ export default function (pi: ExtensionAPI) {
6162
6233
  }
6163
6234
  return value;
6164
6235
  });
6165
- return makeText(ctx.lastComponent, toolHeader("Read", summary, theme, toolStatusDot(ctx, theme)));
6236
+ return makeText(
6237
+ ctx.lastComponent,
6238
+ toolHeader("Read", summary, theme, toolStatusDot(ctx, theme), liveLineCountTrailing(ctx, theme)),
6239
+ );
6166
6240
  },
6167
6241
  renderResult(result, { expanded, isPartial }, theme, ctx) {
6168
6242
  if (isPartial) {
@@ -6197,7 +6271,10 @@ export default function (pi: ExtensionAPI) {
6197
6271
  const rewrite = ensureRtkRewriteForContext(ctx, args);
6198
6272
  const summary = stableCallSummary(ctx, "_callSummary", () => summarizeText(args.command, 72));
6199
6273
  const rtkBadge = rewrite ? theme.fg("muted", " (RTK)") : "";
6200
- return makeText(ctx.lastComponent, toolHeader("Bash", `${summary}${rtkBadge}`, theme, toolStatusDot(ctx, theme)));
6274
+ return makeText(
6275
+ ctx.lastComponent,
6276
+ toolHeader("Bash", `${summary}${rtkBadge}`, theme, toolStatusDot(ctx, theme), liveLineCountTrailing(ctx, theme)),
6277
+ );
6201
6278
  },
6202
6279
  renderResult(result, { expanded, isPartial }, theme, ctx) {
6203
6280
  const details = result.details as BashToolDetails | undefined;
@@ -6205,13 +6282,15 @@ export default function (pi: ExtensionAPI) {
6205
6282
  const output = result.content[0]?.type === "text" ? result.content[0].text : "";
6206
6283
  if (isPartial) {
6207
6284
  const preview = collectNonEmptyLines(output, expanded ? undefined : liveToolPreviewLimit());
6208
- const running = runningPreviewBlock(result, theme.fg("warning", "Running..."), expanded, theme, ctx, {
6285
+ const running = runningPreviewBlock(result, "", expanded, theme, ctx, {
6209
6286
  lines: preview.lines,
6210
6287
  totalLineCount: preview.total,
6211
6288
  styleLine: (line) => theme.fg("dim", line),
6212
6289
  tail: true,
6213
6290
  });
6214
- const withRewrite = expanded && rewrite ? `${running}\n${withBranch(formatRtkRewriteDetails(rewrite, theme), theme)}` : running;
6291
+ const withRewrite = expanded && rewrite
6292
+ ? [running, withBranch(formatRtkRewriteDetails(rewrite, theme), theme)].filter(Boolean).join("\n")
6293
+ : running;
6215
6294
  return makeText(ctx.lastComponent, withRewrite);
6216
6295
  }
6217
6296
  // Collapsed: only keep the live-preview tail (or nothing). Expanded: full list.
@@ -6255,7 +6334,10 @@ export default function (pi: ExtensionAPI) {
6255
6334
  if (args.path) value += ` in ${args.path}`;
6256
6335
  return value;
6257
6336
  });
6258
- return makeText(ctx.lastComponent, toolHeader("Grep", summary, theme, toolStatusDot(ctx, theme)));
6337
+ return makeText(
6338
+ ctx.lastComponent,
6339
+ toolHeader("Grep", summary, theme, toolStatusDot(ctx, theme), liveLineCountTrailing(ctx, theme)),
6340
+ );
6259
6341
  },
6260
6342
  renderResult(result, { expanded, isPartial }, theme, ctx) {
6261
6343
  if (isPartial) {
@@ -6292,7 +6374,10 @@ export default function (pi: ExtensionAPI) {
6292
6374
  if (args.path) value += ` in ${args.path}`;
6293
6375
  return value;
6294
6376
  });
6295
- return makeText(ctx.lastComponent, toolHeader("Find", summary, theme, toolStatusDot(ctx, theme)));
6377
+ return makeText(
6378
+ ctx.lastComponent,
6379
+ toolHeader("Find", summary, theme, toolStatusDot(ctx, theme), liveLineCountTrailing(ctx, theme)),
6380
+ );
6296
6381
  },
6297
6382
  renderResult(result, { expanded, isPartial }, theme, ctx) {
6298
6383
  if (isPartial) {
@@ -6336,7 +6421,10 @@ export default function (pi: ExtensionAPI) {
6336
6421
  renderCall(args, theme, ctx) {
6337
6422
  syncToolCallStatus(ctx);
6338
6423
  const summary = stableCallSummary(ctx, "_callSummary", () => sp(args.path ?? "."));
6339
- return makeText(ctx.lastComponent, toolHeader("List", summary, theme, toolStatusDot(ctx, theme)));
6424
+ return makeText(
6425
+ ctx.lastComponent,
6426
+ toolHeader("List", summary, theme, toolStatusDot(ctx, theme), liveLineCountTrailing(ctx, theme)),
6427
+ );
6340
6428
  },
6341
6429
  renderResult(result, { expanded, isPartial }, theme, ctx) {
6342
6430
  if (isPartial) {
@@ -6411,12 +6499,12 @@ export default function (pi: ExtensionAPI) {
6411
6499
  const base = sp(fp);
6412
6500
  return shouldRevealCallArgs(ctx) ? `${base} ${theme.fg("muted", `(${lineCount(args.content ?? "")} lines)`)}` : base;
6413
6501
  }, revealSummary);
6414
- const hdr = toolHeader(label, summary, theme, toolStatusDot(ctx, theme));
6502
+ const hdr = toolHeader(label, summary, theme, toolStatusDot(ctx, theme), liveLineCountTrailing(ctx, theme));
6415
6503
  return makeText(ctx.lastComponent, hdr);
6416
6504
  },
6417
6505
  renderResult(result, { expanded, isPartial }, theme, ctx) {
6418
6506
  if (isPartial) {
6419
- return makeText(ctx.lastComponent, runningPreviewBlock(result, theme.fg("dim", "Writing..."), expanded, theme, ctx));
6507
+ return makeText(ctx.lastComponent, runningPreviewBlock(result, "", expanded, theme, ctx));
6420
6508
  }
6421
6509
  clearBlinkTimer(ctx);
6422
6510
  setToolStatus(ctx, ctx.isError ? "error" : "success");
@@ -6534,7 +6622,7 @@ export default function (pi: ExtensionAPI) {
6534
6622
  const revealSummary = shouldRevealCallArgs(ctx) || (!!fp && hasOwnArg(args, "edits"));
6535
6623
  const summary = stableCallSummary(ctx, "_callSummary", () => shouldRevealCallArgs(ctx) && operations.length > 1 ? `${sp(fp)} ${theme.fg("muted", `(${operations.length} edits)`)}` : sp(fp), revealSummary);
6536
6624
  syncToolCallStatus(ctx);
6537
- const hdr = toolHeader("Edit", summary, theme, ` ${toolStatusDot(ctx, theme)}`);
6625
+ const hdr = toolHeader("Edit", summary, theme, ` ${toolStatusDot(ctx, theme)}`, liveLineCountTrailing(ctx, theme));
6538
6626
  if (!(ctx.argsComplete && operations.length > 0)) return makeText(ctx.lastComponent, hdr);
6539
6627
  const diffWidth = branchDiffWidth();
6540
6628
  const key = `edit:${fp}:${hashText(operations.map((edit) => `${edit.oldText}\u0000${edit.newText}`).join("\u0001"))}:${diffWidth}:${ctx.expanded ? 1 : 0}`;
@@ -6620,7 +6708,10 @@ export default function (pi: ExtensionAPI) {
6620
6708
  syncToolCallStatus(ctx);
6621
6709
  ctx.state._openAiPatchFiles = [];
6622
6710
  const summary = stableCallSummary(ctx, "_callSummary", () => summarizeOpenAiToolCall(name, args, theme, sp));
6623
- return makeText(ctx.lastComponent, toolHeader(label, summary, theme, toolStatusDot(ctx, theme)));
6711
+ return makeText(
6712
+ ctx.lastComponent,
6713
+ toolHeader(label, summary, theme, toolStatusDot(ctx, theme), liveLineCountTrailing(ctx, theme)),
6714
+ );
6624
6715
  },
6625
6716
  renderResult(result: any, { expanded, isPartial }: any, theme: Theme, ctx: any) {
6626
6717
  if (name === "apply_patch") return renderApplyPatchResult(result, isPartial, theme, ctx);
@@ -6683,6 +6774,10 @@ export default function (pi: ExtensionAPI) {
6683
6774
  pi.on("message_start", async () => { markBlinkActivity(); });
6684
6775
  pi.on("message_update", async () => { markBlinkActivity(); });
6685
6776
  pi.on("tool_execution_start", async () => { markBlinkActivity(); });
6777
+ // 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
+ pi.on("tool_execution_update", async () => { markBlinkActivity(); });
6780
+ pi.on("tool_execution_end", async () => { markBlinkActivity(); });
6686
6781
  pi.on("agent_end", async () => {
6687
6782
  markBlinkActivity();
6688
6783
  for (const entry of _blinkContexts.values()) {
@@ -6695,7 +6790,7 @@ export default function (pi: ExtensionAPI) {
6695
6790
  }
6696
6791
  });
6697
6792
 
6698
- // Safety net: clear all blink timers on turn boundaries.
6793
+ // Safety net: clear blink timers after a turn's tools finish.
6699
6794
  // Keep Shiki hlCache across turns — it's an LRU (CACHE_LIMIT=48) and
6700
6795
  // re-highlighting the same diffs every turn was a free long-chat tax.
6701
6796
  // Full clear still happens on session_shutdown / theme rebind.
@@ -6709,6 +6804,17 @@ export default function (pi: ExtensionAPI) {
6709
6804
  _globalBlinkTimer = null;
6710
6805
  }
6711
6806
  });
6807
+ // Session rebuild (resume/reload/fork) must not leave history partials blinking.
6808
+ 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
+ }
6817
+ });
6712
6818
  pi.on("session_shutdown", async () => {
6713
6819
  for (const entry of _blinkContexts.values()) {
6714
6820
  entry.key._blinkActive = false;
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.70",
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",