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 +18 -0
- package/README.md +5 -5
- package/config/config.example.json +1 -1
- package/extensions/index.ts +94 -42
- package/extensions/spinner.ts +73 -47
- package/package.json +1 -1
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
|
|
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,
|
|
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":
|
|
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 · ↓
|
|
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` | `
|
|
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 |
|
package/extensions/index.ts
CHANGED
|
@@ -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
|
|
902
|
-
|
|
903
|
-
|
|
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 ?
|
|
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) :
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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) :
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)");
|
package/extensions/spinner.ts
CHANGED
|
@@ -545,22 +545,26 @@ function formatCount(value: number): string {
|
|
|
545
545
|
return new Intl.NumberFormat("en-US").format(value);
|
|
546
546
|
}
|
|
547
547
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
|
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
|
-
|
|
780
|
-
|
|
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 =
|
|
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
|
|
836
|
-
const previous =
|
|
837
|
-
|
|
838
|
-
|
|
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
|
|
842
|
-
|
|
843
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1047
|
+
beginRun(turnStartTime);
|
|
1027
1048
|
currentVerb = pickVerb();
|
|
1028
|
-
|
|
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 =
|
|
1068
|
+
const previousTokenCount = outputTokenCount();
|
|
1048
1069
|
|
|
1049
1070
|
if (evt.type === "start") {
|
|
1050
|
-
|
|
1051
|
-
} else if (evt.type === "text_start") {
|
|
1052
|
-
|
|
1053
|
-
} else if (evt.type === "text_delta") {
|
|
1054
|
-
const previous =
|
|
1055
|
-
|
|
1056
|
-
} else if (evt.type === "text_end") {
|
|
1057
|
-
|
|
1058
|
-
} else if (evt.type === "
|
|
1059
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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",
|