pi-claude-code-ui 1.0.92 → 1.0.93

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,22 @@
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.93 — 2026-10-25
7
+
8
+ ### Added
9
+
10
+ - **Grouped tool calls read as one sentence** - a run of calls collapses to `Read 2 files, ran 1 shell command` instead of a header plus a row per call. While they run it says `Reading 2 files, running 1 shell command… · 3s` with the call in progress underneath; `Ctrl+O` still shows every call. Inspired by [pi-polish](https://github.com/Pringled/pi-extensions/tree/main/packages/pi-polish).
11
+ - **The working line says what is happening** - while a tool runs it reads `Running npm test…`, `Reading stats.py…`, `Searching for TODO…` or `Waiting for agent: …`; the playful verbs remain while the model thinks.
12
+ - **`❯` prompt markers** - past prompts are one line in the theme's prompt color starting with `❯`, instead of a box labelled `User`, and the input line gets the same `❯`.
13
+ - **Shell file edits are drawn as edits** - bash calls that only change files (`sed -i`, `perl -pi`, `cat > file <<EOF`, `tee`, `echo … > file`) show as `Edit (sed) stats.py` or `Create (heredoc) notes.md` with a diff, taken from the file before and after the command. The command and what the model sees are unchanged; the diff is kept with the row, so it shows again after a resume. These rows are never folded into a group.
14
+ - **[pi-subagents](https://github.com/tintinweb/pi-subagents) support** - `Agent` rows show the agent type, what a running agent is doing, and its tool uses, tokens and time when done. Completion notices and the live "Agents" widget use the same green dots, connectors and indentation as tool rows (they used pink markers, `└─` arms, and a column of their own).
15
+
16
+ ### Fixed
17
+
18
+ - **Edit rows were indented one column further than every other row** on pi 1.4, along with their diff.
19
+ - **Tools without a summary of their own repeated their name** (`Ctx Reduce Ctx Reduce`); they now show their main argument.
20
+ - **Kotlin script files (`.kts`) were not syntax-highlighted** in diffs.
21
+
6
22
  ## 1.0.92 — 2026-10-24
7
23
 
8
24
  ### Changed
package/README.md CHANGED
@@ -25,14 +25,17 @@ Claude Code inspired tool rendering for Pi — Shiki-powered diffs, status dots,
25
25
  - **MCP-aware rendering** with hidden, summary, and preview modes
26
26
  - **Configurable output modes** for read, search, bash, and MCP results
27
27
  - **Live running previews** that show a few output lines for active tool calls (latest lines for bash), persisting until the next tool/text activity
28
- - **Subagent completion notifications** restyled to match the same Claude-style tool rows
28
+ - **[pi-subagents](https://github.com/tintinweb/pi-subagents) support** — `Agent` rows show what a running agent is doing and its tool uses, tokens and time; completion notices and the live "Agents" widget use the same dots, connectors and indentation as tool rows
29
+ - **Shell file edits drawn as edits** — a bash call that only changes files (`sed -i`, `perl -pi`, a heredoc, `echo … > file`) shows as `Edit (sed) path` or `Create (heredoc) path` with a diff of what changed. The command runs unchanged and the model sees the same output; only the row is drawn differently
29
30
  - **RTK rewrite integration** that folds rewrite notices into the bash tool row with a muted `(RTK)` badge and expanded-only rewrite details
30
31
  - **Transparent tool backgrounds** in `transparent` or `border` mode
31
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
32
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)
33
34
  - **Faint branch connectors** — `├ └ │` keep the same faint contrast on every terminal background, dark or light
34
35
  - **Transparent edit/write diffs** tinted with the theme's own diff colors
35
- - **Grouped consecutive tool calls** with single-row summaries for repeated targets and per-tool glance rows for mixed work (set `groupToolCalls: false` to disable)
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
+ - **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
38
+ - **`❯` prompt markers** — past prompts are one highlighted line starting with `❯`, and the input line gets the same marker, in the column where tool rows put their `●`
36
39
  - **Extra detail toggle** with `Ctrl+Shift+O`, increasing expanded preview caps without making the default view heavy
37
40
  - **Global border patch** for all tool rows, including unknown/custom tools
38
41
 
@@ -117,7 +120,7 @@ The selection is persisted to the active Pi agent settings file and applied to t
117
120
 
118
121
  #### Repaint the spinner with `/cc-spinner`
119
122
 
120
- 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 · ↓ 10 tokens · 2s)`) follows `muted`. Use `/cc-spinner` to bind either text element to any other theme color key:
121
124
 
122
125
  ```text
123
126
  /cc-spinner preview # list every common theme key with a colored sample
@@ -190,3 +193,4 @@ This project builds upon and was inspired by the excellent work of:
190
193
  - **[@heyhuynhgiabuu/pi-pretty](https://github.com/buddingnewinsights/pi-pretty)** by [huynhgiabuu](https://github.com/buddingnewinsights) — Pretty terminal output with syntax-highlighted file reads, colored bash output, and tree-view directory listings
191
194
  - **[@heyhuynhgiabuu/pi-diff](https://github.com/buddingnewinsights/pi-diff)** by [huynhgiabuu](https://github.com/buddingnewinsights) — Shiki-powered terminal diff renderer with word-level diffs in split and unified views
192
195
  - **[pi-tool-display](https://github.com/MasuRii/pi-tool-display)** by [MasuRii](https://github.com/MasuRii) — Compact tool call rendering, diff visualization, and output truncation
196
+ - **[pi-polish](https://github.com/Pringled/pi-extensions/tree/main/packages/pi-polish)** by [Thomas van Dongen](https://github.com/Pringled) — Claude Code-style output for Pi; the one-sentence tool groups, the activity status line, and the `❯` prompt markers follow its design
@@ -0,0 +1,301 @@
1
+ // Recognize shell commands that only change files (sed -i, perl -pi, heredoc and echo
2
+ // redirects), so their bash rows can be drawn like edit/write rows with a diff.
3
+ // Purely a rendering aid: the command runs unchanged, and anything this parser is
4
+ // unsure about stays an ordinary bash row.
5
+
6
+ export type BashFileEditKind = "edit" | "write";
7
+
8
+ export interface BashFileEdit {
9
+ /** "edit" for in-place changes and appends, "write" for files written whole. */
10
+ kind: BashFileEditKind;
11
+ /** How the file was changed, shown next to the label: "sed", "perl", "heredoc", "echo", "printf", "append". */
12
+ via: string;
13
+ /** Target paths as written in the command. */
14
+ paths: string[];
15
+ /** Directory from a leading `cd DIR &&`, as written. */
16
+ cd?: string;
17
+ /** Content written by a heredoc or echo/printf redirect, when known. */
18
+ content?: string;
19
+ }
20
+
21
+ type Token = { type: "word"; value: string } | { type: "op"; value: string };
22
+
23
+ const MAX_COMMAND_LENGTH = 200_000;
24
+
25
+ /** Split a heredoc body off the command: `cat > f <<'EOF'\nbody\nEOF` → head + body. */
26
+ function splitHeredoc(command: string): { head: string; body?: string; rest: string; expands?: boolean } | undefined {
27
+ const match = /<<-?[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/.exec(command);
28
+ if (!match) return { head: command, rest: "" };
29
+ const newline = command.indexOf("\n", match.index);
30
+ if (newline === -1) return undefined;
31
+ const delimiter = match[2];
32
+ const stripTabs = command.slice(match.index, match.index + 3) === "<<-";
33
+ const lines = command.slice(newline + 1).split("\n");
34
+ const end = lines.findIndex((line) => (stripTabs ? line.replace(/^\t+/, "") : line) === delimiter);
35
+ if (end === -1) return undefined;
36
+ const head = command.slice(0, match.index) + command.slice(match.index + match[0].length, newline);
37
+ const body = lines.slice(0, end).map((line) => (stripTabs ? line.replace(/^\t+/, "") : line)).join("\n");
38
+ // An unquoted delimiter lets the shell expand $VAR and `cmd` in the body.
39
+ const expands = !match[1] && /[$`\\]/.test(body);
40
+ return { head: `${head} <<HEREDOC`, body: `${body}\n`, rest: lines.slice(end + 1).join("\n"), expands };
41
+ }
42
+
43
+ /** Minimal POSIX-ish tokenizer: quotes, backslash escapes, and the operators we care about. */
44
+ function tokenize(text: string): Token[] | undefined {
45
+ const tokens: Token[] = [];
46
+ let i = 0;
47
+ let word: string | null = null;
48
+ const flush = () => {
49
+ if (word !== null) tokens.push({ type: "word", value: word });
50
+ word = null;
51
+ };
52
+ while (i < text.length) {
53
+ const c = text[i];
54
+ if (c === " " || c === "\t" || c === "\n") {
55
+ flush();
56
+ if (c === "\n") tokens.push({ type: "op", value: ";" });
57
+ i++;
58
+ continue;
59
+ }
60
+ if (c === "'") {
61
+ const end = text.indexOf("'", i + 1);
62
+ if (end === -1) return undefined;
63
+ word = (word ?? "") + text.slice(i + 1, end);
64
+ i = end + 1;
65
+ continue;
66
+ }
67
+ if (c === '"') {
68
+ let j = i + 1;
69
+ let value = "";
70
+ while (j < text.length && text[j] !== '"') {
71
+ if (text[j] === "\\" && j + 1 < text.length && '"\\$`'.includes(text[j + 1])) {
72
+ value += text[j + 1];
73
+ j += 2;
74
+ continue;
75
+ }
76
+ // Expansions inside double quotes make the value unknowable here.
77
+ if (text[j] === "$" || text[j] === "`") return undefined;
78
+ value += text[j++];
79
+ }
80
+ if (j >= text.length) return undefined;
81
+ word = (word ?? "") + value;
82
+ i = j + 1;
83
+ continue;
84
+ }
85
+ if (c === "\\" && i + 1 < text.length) {
86
+ word = (word ?? "") + text[i + 1];
87
+ i += 2;
88
+ continue;
89
+ }
90
+ const two = text.slice(i, i + 2);
91
+ if (two === "&&" || two === "||" || two === ">>" || two === "<<") {
92
+ flush();
93
+ tokens.push({ type: "op", value: two });
94
+ i += 2;
95
+ continue;
96
+ }
97
+ if (c === ";" || c === "|" || c === ">" || c === "<" || c === "&" || c === "(" || c === ")") {
98
+ flush();
99
+ tokens.push({ type: "op", value: c });
100
+ i++;
101
+ continue;
102
+ }
103
+ if (c === "$" || c === "`") return undefined;
104
+ word = (word ?? "") + c;
105
+ i++;
106
+ }
107
+ flush();
108
+ return tokens;
109
+ }
110
+
111
+ /** Split tokens into simple commands joined by && or ;. Pipes, ||, &, subshells: unsupported. */
112
+ function splitCommands(tokens: Token[]): Token[][] | undefined {
113
+ const commands: Token[][] = [[]];
114
+ for (const token of tokens) {
115
+ if (token.type === "op" && (token.value === "&&" || token.value === ";")) {
116
+ if (commands[commands.length - 1].length > 0) commands.push([]);
117
+ continue;
118
+ }
119
+ if (token.type === "op" && ["||", "|", "&", "(", ")"].includes(token.value)) return undefined;
120
+ commands[commands.length - 1].push(token);
121
+ }
122
+ return commands.filter((command) => command.length > 0);
123
+ }
124
+
125
+ const words = (command: Token[]): string[] | undefined =>
126
+ command.every((token) => token.type === "word") ? command.map((token) => token.value) : undefined;
127
+
128
+ function isPlainPath(path: string): boolean {
129
+ return path.length > 0 && path !== "-" && !/[*?[\]{}]/.test(path) && !path.startsWith("/dev/");
130
+ }
131
+
132
+ /** `sed -i[SUFFIX] [-e SCRIPT]... [SCRIPT] FILE...` (GNU and BSD forms). */
133
+ function parseSed(args: string[]): string[] | undefined {
134
+ let inPlace = false;
135
+ let haveScript = false;
136
+ const files: string[] = [];
137
+ for (let i = 0; i < args.length; i++) {
138
+ const arg = args[i];
139
+ if (arg === "-i" || arg === "--in-place") {
140
+ inPlace = true;
141
+ // BSD sed: `-i ''` or `-i .bak` takes the suffix as its own argument.
142
+ if (i + 1 < args.length && (args[i + 1] === "" || /^\.[\w.-]*$/.test(args[i + 1]))) i++;
143
+ continue;
144
+ }
145
+ if (/^-i\S+$/.test(arg) || arg.startsWith("--in-place=")) {
146
+ inPlace = true;
147
+ continue;
148
+ }
149
+ if (arg === "-e" || arg === "--expression" || arg === "-f" || arg === "--file") {
150
+ haveScript = true;
151
+ i++;
152
+ continue;
153
+ }
154
+ if (/^-[EnrsuzE]+$/.test(arg) || arg === "--regexp-extended" || arg === "--posix") continue;
155
+ if (arg.startsWith("-")) return undefined;
156
+ if (!haveScript) {
157
+ haveScript = true;
158
+ continue;
159
+ }
160
+ files.push(arg);
161
+ }
162
+ return inPlace && haveScript && files.length > 0 && files.every(isPlainPath) ? files : undefined;
163
+ }
164
+
165
+ /** `perl -pi[SUFFIX] -e SCRIPT FILE...` and combined flags such as `-0pi -e`. */
166
+ function parsePerl(args: string[]): string[] | undefined {
167
+ let inPlace = false;
168
+ let haveScript = false;
169
+ const files: string[] = [];
170
+ for (let i = 0; i < args.length; i++) {
171
+ const arg = args[i];
172
+ if (arg === "-e" || arg === "-E") {
173
+ haveScript = true;
174
+ i++;
175
+ continue;
176
+ }
177
+ if (/^-[\w.]+$/.test(arg)) {
178
+ const flags = arg.slice(1);
179
+ // Everything after `i` is the backup suffix (`-pi.bak`).
180
+ if (flags.includes("i")) {
181
+ inPlace = true;
182
+ continue;
183
+ }
184
+ // `-pe SCRIPT`, `-0pe SCRIPT`.
185
+ if (/[eE]$/.test(flags)) {
186
+ haveScript = true;
187
+ i++;
188
+ }
189
+ continue;
190
+ }
191
+ if (arg.startsWith("-")) return undefined;
192
+ if (!haveScript) {
193
+ haveScript = true;
194
+ continue;
195
+ }
196
+ files.push(arg);
197
+ }
198
+ return inPlace && haveScript && files.length > 0 && files.every(isPlainPath) ? files : undefined;
199
+ }
200
+
201
+ /** `cat > FILE <<EOF`, `cat <<EOF >> FILE`, `tee [-a] FILE <<EOF`, `echo TEXT > FILE`, `printf FMT > FILE`. */
202
+ function parseWrite(command: Token[], heredocBody: string | undefined): Omit<BashFileEdit, "cd"> | undefined {
203
+ const name = command[0]?.type === "word" ? command[0].value : "";
204
+ const plain: string[] = [];
205
+ let target: string | undefined;
206
+ let append = false;
207
+ let heredoc = false;
208
+ for (let i = 1; i < command.length; i++) {
209
+ const token = command[i];
210
+ if (token.type === "op" && (token.value === ">" || token.value === ">>")) {
211
+ const next = command[i + 1];
212
+ if (target !== undefined || next?.type !== "word") return undefined;
213
+ target = next.value;
214
+ append = token.value === ">>";
215
+ i++;
216
+ continue;
217
+ }
218
+ if (token.type === "op" && token.value === "<<") {
219
+ const next = command[i + 1];
220
+ if (next?.type !== "word" || next.value !== "HEREDOC") return undefined;
221
+ heredoc = true;
222
+ i++;
223
+ continue;
224
+ }
225
+ if (token.type === "op") return undefined;
226
+ plain.push(token.value);
227
+ }
228
+ if (name === "cat" && heredoc && plain.length === 0 && target && isPlainPath(target)) {
229
+ return { kind: append ? "edit" : "write", via: append ? "append" : "heredoc", paths: [target], content: heredocBody };
230
+ }
231
+ if (name === "tee" && heredoc && !target) {
232
+ const teeAppend = plain[0] === "-a" || plain[0] === "--append";
233
+ const files = teeAppend ? plain.slice(1) : plain;
234
+ if (files.length !== 1 || !isPlainPath(files[0]) || files[0].startsWith("-")) return undefined;
235
+ return { kind: teeAppend ? "edit" : "write", via: teeAppend ? "append" : "heredoc", paths: files, content: heredocBody };
236
+ }
237
+ if ((name === "echo" || name === "printf") && !heredoc && target && isPlainPath(target)) {
238
+ let content: string | undefined;
239
+ if (name === "echo") {
240
+ const noNewline = plain[0] === "-n";
241
+ const parts = plain.filter((part, index) => !(index === 0 && (part === "-n" || part === "-e")));
242
+ content = `${parts.join(" ")}${noNewline ? "" : "\n"}`;
243
+ }
244
+ return { kind: append ? "edit" : "write", via: append ? "append" : name, paths: [target], content };
245
+ }
246
+ return undefined;
247
+ }
248
+
249
+ /**
250
+ * The files a command changes, when it does nothing but change files: an optional leading
251
+ * `cd DIR` and `mkdir -p DIR`, then one or more `sed -i` / `perl -pi` commands, or a
252
+ * single heredoc or echo/printf redirect. Undefined for anything else.
253
+ */
254
+ export function detectBashFileEdit(command: string): BashFileEdit | undefined {
255
+ if (typeof command !== "string" || !command.trim() || command.length > MAX_COMMAND_LENGTH) return undefined;
256
+ const split = splitHeredoc(command.trim());
257
+ if (!split || split.rest.trim()) return undefined;
258
+ const tokens = tokenize(split.head);
259
+ if (!tokens) return undefined;
260
+ const commands = splitCommands(tokens);
261
+ if (!commands || commands.length === 0) return undefined;
262
+
263
+ let cd: string | undefined;
264
+ let index = 0;
265
+ const first = words(commands[0]);
266
+ if (first?.[0] === "cd" && first.length === 2 && isPlainPath(first[1])) {
267
+ cd = first[1];
268
+ index++;
269
+ }
270
+ while (index < commands.length) {
271
+ const mkdir = words(commands[index]);
272
+ if (mkdir?.[0] !== "mkdir" || !mkdir.slice(1).every((arg) => arg === "-p" || isPlainPath(arg))) break;
273
+ index++;
274
+ }
275
+ const rest = commands.slice(index);
276
+ if (rest.length === 0) return undefined;
277
+
278
+ // In-place editors, possibly several in a row.
279
+ const inPlace: string[] = [];
280
+ let via: string | undefined;
281
+ for (const segment of rest) {
282
+ const args = words(segment);
283
+ if (!args) {
284
+ inPlace.length = 0;
285
+ break;
286
+ }
287
+ const files = args[0] === "sed" || args[0] === "gsed" ? parseSed(args.slice(1)) : args[0] === "perl" ? parsePerl(args.slice(1)) : undefined;
288
+ if (!files) {
289
+ inPlace.length = 0;
290
+ break;
291
+ }
292
+ const tool = args[0] === "perl" ? "perl" : "sed";
293
+ via = via && via !== tool ? "shell" : tool;
294
+ for (const file of files) if (!inPlace.includes(file)) inPlace.push(file);
295
+ }
296
+ if (inPlace.length > 0) return { kind: "edit", via: via ?? "sed", paths: inPlace, ...(cd ? { cd } : {}) };
297
+
298
+ if (rest.length !== 1) return undefined;
299
+ const write = parseWrite(rest[0], split.expands ? undefined : split.body);
300
+ return write ? { ...write, ...(cd ? { cd } : {}) } : undefined;
301
+ }