pi-claude-code-ui 1.0.93 → 1.0.95

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,24 @@
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.95 — 2026-10-25
7
+
8
+ ### Changed
9
+
10
+ - **The working line's token count climbs as the model writes**, like pi-polish: `↓ N tokens` now includes thinking and tool-call arguments while they stream (it only counted visible text, so it sat near zero while the model thought), each finished message adds the provider's real output-token count, and the count keeps going across the turns of one request instead of starting over after every tool call.
11
+ - **Running bash commands no longer repeat their script under the row** by default. Set `bashCommandPreviewLines` (for example to `8`) to bring the preview back, for running and failed commands; `Ctrl+O` still shows the whole command.
12
+
13
+ ## 1.0.94 — 2026-10-25
14
+
15
+ ### Changed
16
+
17
+ - **Connectors hang under the tool name** - `├ └ │` start under the first letter of the label (`● Edit stats.py` / ` └ +1 -1 at line 4`) instead of under the `●`, in tool rows, expanded groups, subagent notices and the Agents widget. Expanded groups keep each call's own indentation, so output sits after its `└`.
18
+ - **Status lines are dim** - the line under a command (`Done (3 lines)`, `2 matches`, `+1 -1 • 1 hunk at line 4`) is drawn in the theme's `dim` color, as pi-polish does; errors stay red. The colored `[━━━━]` bar is gone from diff summaries, since it carries nothing in one color.
19
+
20
+ ### Fixed
21
+
22
+ - **A failed read said "1 lines loaded"**; it now shows the error.
23
+
6
24
  ## 1.0.93 — 2026-10-25
7
25
 
8
26
  ### Added
package/README.md CHANGED
@@ -19,7 +19,7 @@ Claude Code inspired tool rendering for Pi — Shiki-powered diffs, status dots,
19
19
  - **Claude-style OpenAI tool rendering** for `apply_patch` plus common Pi/OpenAI-style tools like `webfetch`, `web_search`, `fetch_content`, task tools, and context tools
20
20
  - **`apply_patch` diff previews** that render parsed file patches in the call phase, similar to `edit`/`write`
21
21
  - **Adaptive edit/write diffs** with split or unified layouts, syntax highlighting, and inline word-level emphasis
22
- - **Diff stat bar** with colored add/remove summary and hunk metadata
22
+ - **Diff summary** under each edit: added/removed line counts and hunk metadata, in the same quiet color as every other status line
23
23
  - **Progressive collapsed diff hints** that shorten on narrow terminals
24
24
  - **Live-only thinking** (default) — only the actively-streaming thinking renders expanded; finished thinking collapses to a one-line `Thought for Xs` row (`/cc-tools thinking full` restores always-expanded, `Ctrl+O` still expands anything)
25
25
  - **MCP-aware rendering** with hidden, summary, and preview modes
@@ -31,7 +31,7 @@ Claude Code inspired tool rendering for Pi — Shiki-powered diffs, status dots,
31
31
  - **Transparent tool backgrounds** in `transparent` or `border` mode
32
32
  - **Terminal colors** (default, pi 1.4+) — pi's theme follows the terminal's own palette (Ghostty, Rex, kitty, WezTerm, …), including switches between two dark or two light themes; `/cc-theme terminal off` keeps a theme of your choice
33
33
  - **Theme-adaptive palette** — dim text, spinner accent, diff highlighting, and diff backgrounds automatically follow the active pi theme (set `themeAdaptive: false` to keep the fixed Claude-style palette)
34
- - **Faint branch connectors** — `├ └ │` keep the same faint contrast on every terminal background, dark or light
34
+ - **Faint branch connectors** — `├ └ │` hang under the first letter of the tool name, keep the same faint contrast on every terminal background, and the status line after them (`Done`, `2 matches`, `+1 -1`) is drawn in the theme's dim color, as in pi-polish
35
35
  - **Transparent edit/write diffs** tinted with the theme's own diff colors
36
36
  - **Grouped consecutive tool calls** read as one sentence: `Read 2 files, ran 1 shell command`, or while they run `Reading 2 files, running 1 shell command… · 3s` with the call in progress underneath. `Ctrl+O` shows every call (set `groupToolCalls: false` to disable)
37
37
  - **Activity status line** — while a tool runs, the working line says what it is doing (`Running npm test…`, `Reading stats.py…`, `Searching for TODO…`); the playful verbs stay for the time the model is thinking
@@ -57,7 +57,7 @@ Set in `.pi/settings.json` or the active Pi agent settings file (`$PI_CODING_AGE
57
57
  "thinkingMode": "live",
58
58
  "bashOutputMode": "opencode",
59
59
  "bashCollapsedLines": 10,
60
- "bashCommandPreviewLines": 8,
60
+ "bashCommandPreviewLines": 0,
61
61
  "liveToolPreview": true,
62
62
  "liveToolPreviewLines": 5,
63
63
  "diffCollapsedLines": 24,
@@ -120,7 +120,7 @@ The selection is persisted to the active Pi agent settings file and applied to t
120
120
 
121
121
  #### Repaint the spinner with `/cc-spinner`
122
122
 
123
- While a tool runs, the verb names what it is doing (`Running npm test…`); otherwise it is a playful verb. The spinner glyph itself is still colored by pi's loader using `accent`, while the verb text (e.g. `Cooking…`) follows `borderAccent` by default so it stays lively without being the exact same color as the glyph. The status suffix (e.g. `(thinking · ↓ 10 tokens · 2s)`) follows `muted`. Use `/cc-spinner` to bind either text element to any other theme color key:
123
+ While a tool runs, the verb names what it is doing (`Running npm test…`); otherwise it is a playful verb. The spinner glyph itself is still colored by pi's loader using `accent`, while the verb text (e.g. `Cooking…`) follows `borderAccent` by default so it stays lively without being the exact same color as the glyph. The status suffix (e.g. `(thinking · ↓ 331 tokens · 6s)`) follows `muted`; its token count covers everything the model has written for the current request, thinking and tool calls included, and climbs as it streams. Use `/cc-spinner` to bind either text element to any other theme color key:
124
124
 
125
125
  ```text
126
126
  /cc-spinner preview # list every common theme key with a colored sample
@@ -171,7 +171,7 @@ Use `/cc-tools` to control tool UI at runtime:
171
171
  | `groupToolCalls` | `true` | Group adjacent/concurrent calls, collapsing repeated targets into one row |
172
172
  | `thinkingMode` | `live` | `live` = only streaming thinking expands (finished collapse to `Thought for Xs`); `full` = always expanded |
173
173
  | `bashCollapsedLines` | `10` | Lines for collapsed bash output |
174
- | `bashCommandPreviewLines` | `8` | Verbatim script lines shown while bash runs or after failure; `0` disables them |
174
+ | `bashCommandPreviewLines` | `0` | Script lines of a multi-line command shown under the row while bash runs or after it fails (for example `8`); `0` shows none. `Ctrl+O` always shows the whole command |
175
175
  | `liveToolPreview` | `true` | Show a small live output preview while tools are still running |
176
176
  | `liveToolPreviewLines` | `5` | Lines shown in the collapsed live preview |
177
177
  | `diffCollapsedLines` | `24` | Diff lines before collapsing |
@@ -11,7 +11,7 @@
11
11
  "thinkingMode": "live",
12
12
  "bashOutputMode": "opencode",
13
13
  "bashCollapsedLines": 10,
14
- "bashCommandPreviewLines": 8,
14
+ "bashCommandPreviewLines": 0,
15
15
  "liveToolPreview": true,
16
16
  "liveToolPreviewLines": 5,
17
17
  "diffCollapsedLines": 24,
@@ -795,6 +795,17 @@ function stripLeadingToolStatus(line: string): string {
795
795
  );
796
796
  }
797
797
 
798
+ /** Remove up to `count` leading spaces, keeping any escape codes in front of them. */
799
+ function trimAnsiLeftBy(text: string, count: number): string {
800
+ let current = text;
801
+ for (let i = 0; i < count; i++) {
802
+ const next = current.replace(/^((?:\x1b\[[0-9;]*m)*) /, "$1");
803
+ if (next === current) break;
804
+ current = next;
805
+ }
806
+ return current;
807
+ }
808
+
798
809
  function trimAnsiLeft(text: string): string {
799
810
  let current = text;
800
811
  while (true) {
@@ -898,9 +909,14 @@ function getCollapsedToolEntryLine(entry: CollapsedToolEntry, width: number, gro
898
909
  }
899
910
 
900
911
  function getExpandedToolGroupLines(tool: any, width: number, groupedLabel?: string): string[] {
901
- const lines = stripToolChrome(tool.render(Math.max(1, width)))
902
- .map((line) => removeGroupedToolPrefix(line, groupedLabel))
903
- .map((line) => tintGroupedToolLine(line, groupedLabel));
912
+ const rendered = stripToolChrome(tool.render(Math.max(1, width)));
913
+ // The child row's own padding is its first line's leading spaces. The header loses its
914
+ // marker and padding; the lines below lose only the padding, so the child's └ and the
915
+ // output under it keep their indent.
916
+ const childPad = /^ */.exec(stripAnsi(rendered[0] ?? ""))?.[0].length ?? 0;
917
+ const lines = rendered.map((line, index) =>
918
+ index === 0 ? tintGroupedToolLine(removeGroupedToolPrefix(line, groupedLabel), groupedLabel) : trimAnsiLeftBy(line, childPad),
919
+ );
904
920
  return lines.length > 0 ? lines : [`${FG_DIM}${String(tool?.toolName ?? "tool")}${TRANSPARENT_RESET}`];
905
921
  }
906
922
 
@@ -908,13 +924,13 @@ function branchPrefix(index: number, total: number, theme?: Theme): string {
908
924
  // Bare tee/corner only — no horizontal ─ arm.
909
925
  const branch = index === total - 1 ? "└" : "├";
910
926
  const rule = currentToolBranchAnsi(theme);
911
- return ` ${rule}${branch}${TRANSPARENT_RESET} `;
927
+ return ` ${BRANCH_INDENT}${rule}${branch}${TRANSPARENT_RESET} `;
912
928
  }
913
929
 
914
930
  function branchContinuation(index: number, total: number, theme?: Theme): string {
915
931
  const rule = currentToolBranchAnsi(theme);
916
932
  // Match lead width of ` X ` (3 cols of structure + spaces handled outside).
917
- return index === total - 1 ? " " : ` ${rule}│${TRANSPARENT_RESET} `;
933
+ return index === total - 1 ? ` ${BRANCH_INDENT}` : ` ${BRANCH_INDENT}${rule}│${TRANSPARENT_RESET} `;
918
934
  }
919
935
 
920
936
  function formatBranchedToolLines(
@@ -938,8 +954,8 @@ function formatBranchedToolLines(
938
954
  // Always strip any leftover status marker from the child call line before
939
955
  // re-prefixing. Agent breathe used · which the old stripper missed, so the
940
956
  // title walked sideways as size changed inside groups.
941
- const body = lineIndex === 0 ? removeGroupedToolPrefix(line) : trimAnsiLeft(line);
942
- const prefix = lineIndex === 0 ? `${branchPrefix(index, total)}${light} ` : `${branchContinuation(index, total)} `;
957
+ const body = lineIndex === 0 ? removeGroupedToolPrefix(line) : line;
958
+ const prefix = lineIndex === 0 ? `${branchPrefix(index, total)}${light} ` : `${branchContinuation(index, total)}`;
943
959
  output.push(clampLineWidth(`${prefix}${body}`, width));
944
960
  }
945
961
  return output;
@@ -1200,12 +1216,13 @@ class ToolGroupComponent extends Container {
1200
1216
  const activeLine = stripAnsi(getCompactToolLine(active, Math.max(1, safeWidth - 3), undefined, false))
1201
1217
  .trim()
1202
1218
  .replace(/\s+\(\d+ lines?\)$/, "");
1203
- header.push(clampLineWidth(` ${currentToolBranchAnsi()}└${TRANSPARENT_RESET} ${FG_DIM}${activeLine}${TRANSPARENT_RESET}`, safeWidth));
1219
+ const dim = safeFgAnsi(getGlobalPiTheme(), "dim") ?? FG_DIM;
1220
+ header.push(clampLineWidth(` ${BRANCH_INDENT}${currentToolBranchAnsi()}└${TRANSPARENT_RESET} ${dim}${activeLine}${TRANSPARENT_RESET}`, safeWidth));
1204
1221
  }
1205
1222
  }
1206
1223
  recordLines(header, undefined);
1207
1224
  lines.push(...header);
1208
- const childWidth = Math.max(1, safeWidth - 6);
1225
+ const childWidth = Math.max(1, safeWidth - 6 - BRANCH_INDENT.length);
1209
1226
  if (this.expanded) {
1210
1227
  for (let index = 0; index < total; index++) {
1211
1228
  const tool = this.tools[index];
@@ -1439,7 +1456,7 @@ function formatTodoOverlayLines(lines: string[], width: number): string[] {
1439
1456
  const withoutTodoHash = line.replace(/#(?=[A-Za-z0-9_-]+)/, "");
1440
1457
  const bare = withoutTodoHash.replace(/([├└])─/, "$1");
1441
1458
  const colored = bare.replace(/[├└]/, (branch) => `${currentToolBranchAnsi()}${branch}${TRANSPARENT_RESET}`);
1442
- return clampLineWidth(` ${colored}`, width);
1459
+ return clampLineWidth(` ${BRANCH_INDENT}${colored}`, width);
1443
1460
  });
1444
1461
  }
1445
1462
 
@@ -2671,7 +2688,7 @@ function renderSubagentNotificationLines(details: any, expanded: boolean, width:
2671
2688
  const header = ` ${dot} ${theme.fg("toolTitle", theme.bold("Agent"))} ${theme.fg("accent", String(agent.description ?? "agent"))} ${theme.fg(failed ? "error" : "muted", statusLabel)}`;
2672
2689
  const body: string[] = [];
2673
2690
  const stats = agentStatsText(agent);
2674
- if (stats) body.push(theme.fg("muted", stats));
2691
+ if (stats) body.push(theme.fg("dim", stats));
2675
2692
  const preview = String(agent.resultPreview ?? "").trim();
2676
2693
  if (preview) {
2677
2694
  const previewLines = preview.split("\n");
@@ -2682,7 +2699,7 @@ function renderSubagentNotificationLines(details: any, expanded: boolean, width:
2682
2699
  body.push(theme.fg("muted", `transcript: ${agent.outputFile}`));
2683
2700
  }
2684
2701
  const lines = [header];
2685
- body.forEach((line, index) => lines.push(` ${branchGlyph(index === body.length - 1 ? "└" : "├")} ${line}`));
2702
+ body.forEach((line, index) => lines.push(` ${BRANCH_INDENT}${branchGlyph(index === body.length - 1 ? "└" : "├")} ${line}`));
2686
2703
  blocks.push(lines.map((line) => clampLineWidth(line, width)));
2687
2704
  }
2688
2705
  return blocks.flatMap((block, index) => (index === 0 ? block : ["", ...block]));
@@ -2712,12 +2729,13 @@ function restyleAgentWidgetLines(lines: string[], width: number): string[] {
2712
2729
  // The running spinner becomes the breathing dot running agent rows use.
2713
2730
  rest = rest.replace(AGENT_WIDGET_SPINNER_RE, () => paintAgentBreatheDot(TOOL_STATUS_SUCCESS));
2714
2731
  rest = normalizeLeadingCheckGlyph(rest);
2715
- return clampLineWidth(` ${branchGlyph(item[2] as "├" | "└")} ${rest}`, width);
2732
+ return clampLineWidth(` ${BRANCH_INDENT}${branchGlyph(item[2] as "├" | "└")} ${rest}`, width);
2716
2733
  }
2717
2734
  const activity = AGENT_WIDGET_ACTIVITY_RE.exec(line);
2718
2735
  if (activity) {
2719
2736
  const bar = activity[2] === "│" ? branchGlyph("│") : " ";
2720
- return clampLineWidth(` ${bar} ${FG_DIM}${stripAnsi(activity[5])}${TRANSPARENT_RESET}`, width);
2737
+ const dim = safeFgAnsi(getGlobalPiTheme(), "dim") ?? FG_DIM;
2738
+ return clampLineWidth(` ${BRANCH_INDENT}${bar} ${dim}${stripAnsi(activity[5])}${TRANSPARENT_RESET}`, width);
2721
2739
  }
2722
2740
  return line;
2723
2741
  });
@@ -3810,36 +3828,75 @@ function toolStatusDot(ctx: any, theme: Theme): string {
3810
3828
  // Branch connector — visual tree from header to output
3811
3829
  // ---------------------------------------------------------------------------
3812
3830
 
3831
+ // Connectors hang under the first letter of the tool's label, two columns in from its ●
3832
+ // (`● Edit stats.py` / ` └ +1 -1 at line 4`), like Claude Code and pi-polish.
3833
+ const BRANCH_INDENT = " ";
3834
+
3813
3835
  function branchIndent(text: string, continued = false, theme?: Theme): string {
3814
3836
  const rule = currentToolBranchAnsi(theme);
3815
3837
  // Align under bare `├ `/`└ ` (│ + one space, or two spaces when closed).
3816
3838
  const prefix = continued ? `${rule}│${TRANSPARENT_RESET} ` : " ";
3817
- return `${prefix}${WRAP_MARK}${text}`;
3839
+ return `${BRANCH_INDENT}${prefix}${WRAP_MARK}${text}`;
3818
3840
  }
3819
3841
 
3820
3842
  function branchLead(text: string, continued = false, theme?: Theme): string {
3821
3843
  const rule = currentToolBranchAnsi(theme);
3822
3844
  // Bare tee/corner only — no horizontal ─ arm.
3823
- return `${rule}${continued ? "├" : "└"}${TRANSPARENT_RESET} ${WRAP_MARK}${text}`;
3845
+ return `${BRANCH_INDENT}${rule}${continued ? "├" : "└"}${TRANSPARENT_RESET} ${WRAP_MARK}${text}`;
3846
+ }
3847
+
3848
+ // SGR parameters that set a foreground color: 30–37, 39, 90–97, 38;5;n, 38;2;r;g;b.
3849
+ function stripForegroundAnsi(text: string): string {
3850
+ return text.replace(/\x1b\[([0-9;]*)m/g, (match, paramsText: string) => {
3851
+ if (paramsText === "") return match;
3852
+ const params = paramsText.split(";");
3853
+ const kept: string[] = [];
3854
+ for (let i = 0; i < params.length; i++) {
3855
+ const code = Number(params[i] || "0");
3856
+ if (code === 38) {
3857
+ const mode = Number(params[i + 1] || "0");
3858
+ i += mode === 2 ? 4 : mode === 5 ? 2 : 1;
3859
+ continue;
3860
+ }
3861
+ if ((code >= 30 && code <= 37) || code === 39 || (code >= 90 && code <= 97)) continue;
3862
+ kept.push(params[i]);
3863
+ }
3864
+ return kept.length === 0 ? "" : `\x1b[${kept.join(";")}m`;
3865
+ });
3824
3866
  }
3825
3867
 
3826
- function withBranch(content: string, theme: Theme, _isError = false, continued = false): string {
3868
+ /**
3869
+ * The status line under a tool's label ("Done (3 lines)", "2 matches", "+1 -1 at line 4")
3870
+ * in the theme's dim color, as pi-polish draws it. Errors keep their red.
3871
+ */
3872
+ function toneStatusLine(text: string, theme?: Theme): string {
3873
+ const t = (theme ?? getGlobalPiTheme()) as any;
3874
+ const dim = safeFgAnsi(t, "dim");
3875
+ if (!dim || !text.trim()) return text;
3876
+ const error = safeFgAnsi(t, "error");
3877
+ if ((error && text.includes(error)) || text.includes(TOOL_STATUS_ERROR)) return text;
3878
+ const body = stripForegroundAnsi(text).replace(/\x1b\[0?m/g, (reset) => `${reset}${dim}`);
3879
+ return `${dim}${body}\x1b[39m`;
3880
+ }
3881
+
3882
+ /** `tone`: the first line is a status line (dim); pass false when it is content (a command, a path). */
3883
+ function withBranch(content: string, theme: Theme, _isError = false, continued = false, tone = true): string {
3827
3884
  if (!content || !content.trim()) return "";
3828
3885
  const lines = content.split("\n");
3829
- const first = lines[0] ?? "";
3886
+ const first = tone ? toneStatusLine(lines[0] ?? "", theme) : lines[0] ?? "";
3830
3887
  if (lines.length === 1) return branchLead(first, continued, theme);
3831
3888
  const rest = lines.slice(1).map((line) => branchIndent(line, continued, theme));
3832
3889
  return `${branchLead(first, continued, theme)}\n${rest.join("\n")}`;
3833
3890
  }
3834
3891
 
3835
- function withClippedBranch(content: string, theme: Theme, continued = false): string {
3836
- return withBranch(content, theme, false, continued).replaceAll(WRAP_MARK, CLIP_MARK);
3892
+ function withClippedBranch(content: string, theme: Theme, continued = false, tone = true): string {
3893
+ return withBranch(content, theme, false, continued, tone).replaceAll(WRAP_MARK, CLIP_MARK);
3837
3894
  }
3838
3895
 
3839
- function withFinalBranchBlock(content: string, theme: Theme, isError = false): string {
3896
+ function withFinalBranchBlock(content: string, theme: Theme, isError = false, tone = true): string {
3840
3897
  if (!content || !content.trim()) return "";
3841
3898
  const lines = content.split("\n");
3842
- const first = lines[0] ?? "";
3899
+ const first = tone ? toneStatusLine(lines[0] ?? "", theme) : lines[0] ?? "";
3843
3900
  if (lines.length === 1) return branchLead(first, false, theme);
3844
3901
  const middle = lines.slice(1, -1).map((line) => branchIndent(line, true, theme));
3845
3902
  const last = lines[lines.length - 1] ?? "";
@@ -4239,9 +4296,11 @@ function bashOutputMode(): "opencode" | "summary" | "preview" {
4239
4296
  return getMode(readSettings().bashOutputMode, ["opencode", "summary", "preview"] as const, "opencode");
4240
4297
  }
4241
4298
 
4299
+ // Off by default: the header already names the command, and repeating a long script under
4300
+ // it while it runs made the transcript busy. Ctrl+O still shows the whole command.
4242
4301
  function bashCommandPreviewLimit(): number {
4243
4302
  const value = readSettings().bashCommandPreviewLines;
4244
- return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 8;
4303
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0;
4245
4304
  }
4246
4305
 
4247
4306
  function renderBashCommandBlock(
@@ -4259,7 +4318,8 @@ function renderBashCommandBlock(
4259
4318
  lines.push(`... ${presentation.sourceLines.length - sourceLimit} more command lines`);
4260
4319
  }
4261
4320
  const body = lines.map((line) => theme.fg("accent", line || " ")).join("\n");
4262
- return expanded ? withBranch(body, theme, false, true) : withClippedBranch(body, theme, true);
4321
+ // The command itself, not a status line: keep its color.
4322
+ return expanded ? withBranch(body, theme, false, true, false) : withClippedBranch(body, theme, true, false);
4263
4323
  }
4264
4324
 
4265
4325
  function liveToolPreviewEnabled(): boolean {
@@ -5220,7 +5280,7 @@ function termW(): number {
5220
5280
  return Math.max(40, Math.min(raw - 4, MAX_TERM_WIDTH));
5221
5281
  }
5222
5282
 
5223
- function branchDiffWidth(componentWidth?: number, chromeWidth = 2): number {
5283
+ function branchDiffWidth(componentWidth?: number, chromeWidth = 2 + BRANCH_INDENT.length): number {
5224
5284
  const width = typeof componentWidth === "number" && Number.isFinite(componentWidth)
5225
5285
  ? Math.floor(componentWidth)
5226
5286
  : termW();
@@ -5230,7 +5290,7 @@ function branchDiffWidth(componentWidth?: number, chromeWidth = 2): number {
5230
5290
  // Diff preview cache keys also carry _toolBranchVisualEpoch, which applyThemePaletteIfNeeded
5231
5291
  // bumps when the theme palette changes: otherwise rows rendered before a /theme or light/dark
5232
5292
  // switch kept the old diff backgrounds and stat colors while new rows used the new ones.
5233
- function contextDiffWidth(ctx: any, chromeWidth = 2): number {
5293
+ function contextDiffWidth(ctx: any, chromeWidth = 2 + BRANCH_INDENT.length): number {
5234
5294
  return branchDiffWidth(ctx.state?._diffComponentWidth, chromeWidth);
5235
5295
  }
5236
5296
 
@@ -5390,26 +5450,12 @@ function stripes(width: number): string {
5390
5450
  return BG_BASE + FG_STRIPE + "╱".repeat(width) + D_RST;
5391
5451
  }
5392
5452
 
5393
- function renderDiffStatBar(added: number, removed: number, width = termW()): string {
5394
- const total = added + removed;
5395
- if (total === 0 || width < 20) return "";
5396
- const slots = Math.max(8, Math.min(20, Math.floor(width / 14)));
5397
- let addSlots = Math.max(0, Math.min(slots, Math.round((added / total) * slots)));
5398
- if (added > 0 && addSlots === 0) addSlots = 1;
5399
- if (removed > 0 && addSlots >= slots) addSlots = slots - 1;
5400
- const removeSlots = Math.max(0, slots - addSlots);
5401
- const addBar = addSlots > 0 ? `${FG_ADD}${"━".repeat(addSlots)}${D_RST}` : "";
5402
- const removeBar = removeSlots > 0 ? `${FG_DEL}${"━".repeat(removeSlots)}${D_RST}` : "";
5403
- return `${FG_DIM}[${D_RST}${addBar}${removeBar}${FG_DIM}]${D_RST}`;
5404
- }
5405
-
5406
5453
  function summarizeDiff(added: number, removed: number): string {
5407
5454
  const parts: string[] = [];
5408
5455
  if (added > 0) parts.push(`${FG_ADD}+${added}${D_RST}`);
5409
5456
  if (removed > 0) parts.push(`${FG_DEL}-${removed}${D_RST}`);
5410
5457
  if (!parts.length) return `${FG_DIM}no changes${D_RST}`;
5411
- const bar = renderDiffStatBar(added, removed);
5412
- return bar ? `${parts.join(" ")} ${bar}` : parts.join(" ");
5458
+ return parts.join(" ");
5413
5459
  }
5414
5460
 
5415
5461
  function diffSummaryWithMeta(added: number, removed: number, hunks: number, mode: string): string {
@@ -7570,7 +7616,8 @@ function renderBashFileEditResult(edit: BashFileEdit, records: BashFileEditRecor
7570
7616
  const block = bashFileEditDiffBlock(record, index, theme, ctx);
7571
7617
  return records!.length > 1 ? `${theme.fg("accent", sp(resolveBashEditPath(ctx.cwd ?? process.cwd(), edit, record.path)))}\n${block}` : block;
7572
7618
  });
7573
- return makeResponsiveDiffText(ctx, ctx.lastComponent, withFinalBranchBlock(blocks.join("\n"), theme));
7619
+ // With several files the first line is a path, not a status line.
7620
+ return makeResponsiveDiffText(ctx, ctx.lastComponent, withFinalBranchBlock(blocks.join("\n"), theme, false, records.length === 1));
7574
7621
  }
7575
7622
 
7576
7623
  // ===========================================================================
@@ -8075,6 +8122,11 @@ export default function (pi: ExtensionAPI) {
8075
8122
  const details = result.details as ReadToolDetails | undefined;
8076
8123
  const content = result.content.find((block: any) => block?.type === "text");
8077
8124
  if (content?.type !== "text") return makeText(ctx.lastComponent, withBranch(theme.fg("error", "No text content"), theme));
8125
+ // A failed read (missing file, permission) said "1 lines loaded"; show the error instead.
8126
+ if (ctx.isError) {
8127
+ const message = content.text.trim().split("\n");
8128
+ return makeText(ctx.lastComponent, withBranch(theme.fg("error", expanded ? message.join("\n") : message[0] ?? "Error"), theme));
8129
+ }
8078
8130
  const lines = content.text.split("\n");
8079
8131
  let text = theme.fg("muted", `${lines.length} lines loaded`);
8080
8132
  if (details?.truncation?.truncated) text += theme.fg("warning", " (truncated)");
@@ -545,22 +545,26 @@ function formatCount(value: number): string {
545
545
  return new Intl.NumberFormat("en-US").format(value);
546
546
  }
547
547
 
548
- function estimateResponseLength(message: any): number {
549
- if (!Array.isArray(message?.content)) return 0;
550
- return message.content.reduce((sum: number, block: any) =>
551
- sum + (block?.type === "text" && typeof block.text === "string" ? block.text.length : 0), 0);
552
- }
553
-
554
- function textBlockLengths(message: any): number[] {
555
- if (!Array.isArray(message?.content)) return [];
556
- const lengths: number[] = [];
557
- for (let i = 0; i < message.content.length; i++) {
558
- const block = message.content[i];
559
- if (block?.type === "text" && typeof block.text === "string") {
560
- lengths[i] = block.text.length;
548
+ /** Characters the model produced in one content block: text, thinking, or tool-call arguments. */
549
+ function contentBlockLength(block: any): number {
550
+ if (block?.type === "text" && typeof block.text === "string") return block.text.length;
551
+ if (block?.type === "thinking" && typeof block.thinking === "string") return block.thinking.length;
552
+ if (block?.type === "toolCall") {
553
+ try {
554
+ return JSON.stringify(block.arguments ?? {}).length;
555
+ } catch {
556
+ return 0;
561
557
  }
562
558
  }
563
- return lengths;
559
+ return 0;
560
+ }
561
+
562
+ /** Output tokens of a finished assistant message: the provider's count, or an estimate (chars / 4). */
563
+ function messageOutputTokens(message: any): number {
564
+ const reported = message?.usage?.output;
565
+ if (typeof reported === "number" && Number.isFinite(reported) && reported > 0) return reported;
566
+ if (!Array.isArray(message?.content)) return 0;
567
+ return Math.round(message.content.reduce((sum: number, block: any) => sum + contentBlockLength(block), 0) / 4);
564
568
  }
565
569
 
566
570
  function statusText(text: string): string {
@@ -776,8 +780,12 @@ export default function (pi: ExtensionAPI) {
776
780
  let completionTimer: ReturnType<typeof setTimeout> | null = null;
777
781
  let thoughtStatusTimer: ReturnType<typeof setTimeout> | null = null;
778
782
  let currentVerb = "";
779
- let responseLength = 0;
780
- let responseTextBlockLengths: number[] = [];
783
+ // Output tokens for the whole run, like pi-polish: finished messages add their real
784
+ // usage.output; the message still streaming adds an estimate (chars / 4) of its text,
785
+ // thinking and tool-call arguments, so the count climbs as the model writes.
786
+ let runOutputTokens = 0;
787
+ let streamedChars = 0;
788
+ let streamedBlockLengths: number[] = [];
781
789
  let thinkingStatus: "thinking" | number /* duration ms */ | null = null;
782
790
  let thinkingStartTime = 0;
783
791
  let thoughtForSetAt = 0;
@@ -803,7 +811,7 @@ export default function (pi: ExtensionAPI) {
803
811
 
804
812
  function buildWorkingMessage(): string {
805
813
  const elapsed = Date.now() - (agentStartTime || turnStartTime);
806
- const tokenCount = Math.max(0, Math.round(responseLength / 4));
814
+ const tokenCount = outputTokenCount();
807
815
  const statusParts: string[] = [];
808
816
 
809
817
  if (thinkingStatus === "thinking") {
@@ -832,15 +840,27 @@ export default function (pi: ExtensionAPI) {
832
840
  return message;
833
841
  }
834
842
 
835
- function setResponseTextBlockLength(index: number, length: number): void {
836
- const previous = responseTextBlockLengths[index] ?? 0;
837
- responseTextBlockLengths[index] = Math.max(0, length);
838
- responseLength = Math.max(0, responseLength + responseTextBlockLengths[index] - previous);
843
+ function setStreamedBlockLength(index: number, length: number): void {
844
+ const previous = streamedBlockLengths[index] ?? 0;
845
+ streamedBlockLengths[index] = Math.max(0, length);
846
+ streamedChars = Math.max(0, streamedChars + streamedBlockLengths[index] - previous);
847
+ }
848
+
849
+ function resetStreamed(): void {
850
+ streamedBlockLengths = [];
851
+ streamedChars = 0;
839
852
  }
840
853
 
841
- function resetResponseTracking(message?: any): void {
842
- responseTextBlockLengths = message ? textBlockLengths(message) : [];
843
- responseLength = message ? estimateResponseLength(message) : 0;
854
+ function outputTokenCount(): number {
855
+ return runOutputTokens + Math.round(streamedChars / 4);
856
+ }
857
+
858
+ /** Start of a top-level request (not steering or follow-ups inside it). */
859
+ function beginRun(now = Date.now()): void {
860
+ if (agentStartTime) return;
861
+ agentStartTime = now;
862
+ runOutputTokens = 0;
863
+ resetStreamed();
844
864
  }
845
865
 
846
866
  function syncWorkingMessage(force = false): void {
@@ -877,7 +897,7 @@ export default function (pi: ExtensionAPI) {
877
897
 
878
898
  function getWorkingMessageIntervalMs(): number {
879
899
  const elapsed = Date.now() - (agentStartTime || turnStartTime);
880
- const tokenCount = Math.max(0, Math.round(responseLength / 4));
900
+ const tokenCount = outputTokenCount();
881
901
  // Keep ticking once per second even when idle so /cc-spinner changes
882
902
  // take effect within ~1s and elapsed-time crossover into the timer-on
883
903
  // state still fires close to 30s. syncWorkingMessage short-circuits
@@ -964,7 +984,8 @@ export default function (pi: ExtensionAPI) {
964
984
  turnStartTime = 0;
965
985
  thinkingStatus = null;
966
986
  thoughtForSetAt = 0;
967
- resetResponseTracking();
987
+ runOutputTokens = 0;
988
+ resetStreamed();
968
989
  restoreDefaultWorkingMessage();
969
990
  }
970
991
 
@@ -1008,12 +1029,12 @@ export default function (pi: ExtensionAPI) {
1008
1029
 
1009
1030
  pi.on("before_agent_start", async () => {
1010
1031
  // Start once per top-level request. Steering/follow-up messages while the
1011
- // agent is active must not reset the timer.
1012
- if (!agentStartTime) agentStartTime = Date.now();
1032
+ // agent is active must not reset the timer or the token count.
1033
+ beginRun();
1013
1034
  });
1014
1035
 
1015
1036
  pi.on("agent_start", async (_event, ctx) => {
1016
- if (!agentStartTime) agentStartTime = Date.now();
1037
+ beginRun();
1017
1038
  herdrMarkWorking(ctx);
1018
1039
  });
1019
1040
 
@@ -1023,9 +1044,9 @@ export default function (pi: ExtensionAPI) {
1023
1044
  activeCtx = ctx;
1024
1045
  applyThemeColors(ctx.ui?.theme);
1025
1046
  turnStartTime = Date.now();
1026
- if (!agentStartTime) agentStartTime = turnStartTime;
1047
+ beginRun(turnStartTime);
1027
1048
  currentVerb = pickVerb();
1028
- resetResponseTracking();
1049
+ resetStreamed();
1029
1050
  clearCompletionTimer();
1030
1051
  if (typeof thinkingStatus !== "number" || Date.now() - thoughtForSetAt >= THOUGHT_DISPLAY_MS) {
1031
1052
  thinkingStatus = null;
@@ -1044,21 +1065,19 @@ export default function (pi: ExtensionAPI) {
1044
1065
  applyThemeColors(ctx.ui?.theme);
1045
1066
  const evt = event.assistantMessageEvent;
1046
1067
  let statusChanged = false;
1047
- const previousTokenCount = Math.max(0, Math.round(responseLength / 4));
1068
+ const previousTokenCount = outputTokenCount();
1048
1069
 
1049
1070
  if (evt.type === "start") {
1050
- resetResponseTracking();
1051
- } else if (evt.type === "text_start") {
1052
- setResponseTextBlockLength(evt.contentIndex, 0);
1053
- } else if (evt.type === "text_delta") {
1054
- const previous = responseTextBlockLengths[evt.contentIndex] ?? 0;
1055
- setResponseTextBlockLength(evt.contentIndex, previous + (typeof evt.delta === "string" ? evt.delta.length : 0));
1056
- } else if (evt.type === "text_end") {
1057
- setResponseTextBlockLength(evt.contentIndex, typeof evt.content === "string" ? evt.content.length : 0);
1058
- } else if (evt.type === "done") {
1059
- resetResponseTracking(evt.message);
1060
- } else if (evt.type === "error") {
1061
- resetResponseTracking(evt.error);
1071
+ resetStreamed();
1072
+ } else if (evt.type === "text_start" || evt.type === "thinking_start" || evt.type === "toolcall_start") {
1073
+ setStreamedBlockLength(evt.contentIndex, 0);
1074
+ } else if (evt.type === "text_delta" || evt.type === "thinking_delta" || evt.type === "toolcall_delta") {
1075
+ const previous = streamedBlockLengths[evt.contentIndex] ?? 0;
1076
+ setStreamedBlockLength(evt.contentIndex, previous + (typeof evt.delta === "string" ? evt.delta.length : 0));
1077
+ } else if (evt.type === "text_end" || evt.type === "thinking_end") {
1078
+ setStreamedBlockLength(evt.contentIndex, typeof evt.content === "string" ? evt.content.length : 0);
1079
+ } else if (evt.type === "toolcall_end") {
1080
+ setStreamedBlockLength(evt.contentIndex, contentBlockLength({ type: "toolCall", arguments: evt.toolCall?.arguments }));
1062
1081
  }
1063
1082
 
1064
1083
  if (evt.type === "thinking_start") {
@@ -1081,12 +1100,20 @@ export default function (pi: ExtensionAPI) {
1081
1100
  return;
1082
1101
  }
1083
1102
 
1084
- const nextTokenCount = Math.max(0, Math.round(responseLength / 4));
1103
+ const nextTokenCount = outputTokenCount();
1085
1104
  if (previousTokenCount === 0 && nextTokenCount > 0) {
1086
1105
  rescheduleRefreshLoop();
1087
1106
  }
1088
1107
  });
1089
1108
 
1109
+ // A finished message swaps its estimate for the provider's real output-token count.
1110
+ pi.on("message_end", async (event: any) => {
1111
+ if (event?.message?.role !== "assistant") return;
1112
+ runOutputTokens += messageOutputTokens(event.message);
1113
+ resetStreamed();
1114
+ if (turnActive) syncWorkingMessage();
1115
+ });
1116
+
1090
1117
  pi.on("tool_execution_start", async (event: any, ctx) => {
1091
1118
  if (typeof event?.toolCallId !== "string") return;
1092
1119
  activeCtx = ctx;
@@ -1135,8 +1162,7 @@ export default function (pi: ExtensionAPI) {
1135
1162
  return;
1136
1163
  }
1137
1164
 
1138
- responseLength = 0;
1139
- responseTextBlockLengths = [];
1165
+ resetStreamed();
1140
1166
  });
1141
1167
 
1142
1168
  pi.on("agent_end", async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-claude-code-ui",
3
- "version": "1.0.93",
3
+ "version": "1.0.95",
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",