pi-claude-code-ui 1.0.91 → 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,29 @@
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
+
22
+ ## 1.0.92 — 2026-10-24
23
+
24
+ ### Changed
25
+
26
+ - **Terminal colors are on by default** - on pi 1.4 or newer, pi now follows the terminal's palette (Ghostty, Rex, ...) without `/cc-theme terminal on`. The first start switches to pi's `system` theme, saves the theme you had as `terminalThemePrevious`, and says so; `/cc-theme terminal off` (or `"terminalTheme": false`) restores that theme and keeps it. `pi --use-theme <name>` still wins for that run, and older pi versions are left alone.
27
+ - **Branch connectors have one rule and no settings** - `├ └ │` are always a faint mark at a fixed 2:1 contrast against the terminal background, which keeps them visible on dark, light, and tinted themes. The `/cc-tools branch` command and the `toolBranchColorMode` / `toolBranchRgbGray` settings are removed; existing values are ignored. On pi before 1.4 the contrast is taken against black or white (rgb 72 on dark themes, as before).
28
+
6
29
  ## 1.0.91 — 2026-10-24
7
30
 
8
31
  ### Added
package/README.md CHANGED
@@ -25,13 +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
- - **Terminal colors** — `/cc-theme terminal on` makes pi's theme follow the terminal's own palette (Ghostty, Rex, kitty, WezTerm, …), including switches between two dark or two light themes
32
- - **Theme-adaptive palette** — borders, branch connectors, 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)
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
+ - **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
33
35
  - **Transparent edit/write diffs** tinted with the theme's own diff colors
34
- - **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 `●`
35
39
  - **Extra detail toggle** with `Ctrl+Shift+O`, increasing expanded preview caps without making the default view heavy
36
40
  - **Global border patch** for all tool rows, including unknown/custom tools
37
41
 
@@ -59,7 +63,7 @@ Set in `.pi/settings.json` or the active Pi agent settings file (`$PI_CODING_AGE
59
63
  "diffCollapsedLines": 24,
60
64
  "diffLayout": "auto",
61
65
  "themeAdaptive": true,
62
- "terminalTheme": false
66
+ "terminalTheme": true
63
67
  }
64
68
  ```
65
69
 
@@ -71,8 +75,6 @@ When `themeAdaptive` is `true` (default), the following colors are derived from
71
75
 
72
76
  | Element | Derived from |
73
77
  |---------|--------------|
74
- | Branch connectors (`├`, `└`, `│`) | terminal background blended toward its text color to a 2:1 contrast (rgb 72 on a near-black terminal); `/cc-tools branch theme` uses `dim` → `muted` → `borderMuted` instead, `/cc-tools branch <0-255>` an exact gray |
75
- | User box, tool rules, code fences, thinking rule | the same blend at a 5.1:1 contrast (rgb ~136 on a near-black terminal) |
76
78
  | "✻ Turn took Ns" line (final message only, with session total + turn count) | `muted` |
77
79
  | Expanded thinking-block text and `∴` marker | `muted` |
78
80
  | Diff add/remove accents | `toolDiffAdded` / `toolDiffRemoved` |
@@ -82,7 +84,9 @@ When `themeAdaptive` is `true` (default), the following colors are derived from
82
84
  | Spinner verb text (`Working…`) | `borderAccent` (fallback: `accent`) |
83
85
  | Spinner status text | `muted` |
84
86
 
85
- These need pi 1.4 or newer, which exposes the theme's concrete colors. On older pi versions branch connectors stay rgb 72, diffs use `github-dark` / `github-light`, and diff tints are mixed against `toolSuccessBg`.
87
+ These need pi 1.4 or newer, which exposes the theme's concrete colors. On older pi versions diffs use `github-dark` / `github-light`, and diff tints are mixed against `toolSuccessBg`.
88
+
89
+ Branch connectors (`├`, `└`, `│`) and outlines (user box, tool rules, code fences, thinking rule) are neutral marks with nothing to configure. They blend the terminal's background toward its text color until they reach a fixed contrast: 2:1 for connectors (rgb 72 on a near-black terminal) and 5.1:1 for outlines (rgb ~136), so connectors stay faint but visible on dark, light, and tinted themes alike. On pi versions before 1.4, which don't report the terminal's colors, the same contrasts are taken against black or white.
86
90
 
87
91
  User-supplied `diffTheme` presets and `diffColors` overrides always win over theme-derived defaults. File-type icons (e.g. `ts`, `py`, `rs`) keep their language-identity colors and are not theme-derived.
88
92
 
@@ -92,13 +96,13 @@ On `/resume`, `/new`, or `/fork`, tool chrome is rebound from the **current** pi
92
96
 
93
97
  ### Terminal colors (Ghostty, Rex, …)
94
98
 
95
- `/cc-theme terminal on` switches pi to its built-in `system` theme, which builds every pi color from the palette the terminal reports, and keeps it there on every start. The theme you had before is saved as `terminalThemePrevious`; `/cc-theme terminal off` restores it.
99
+ On by default: at startup the extension switches pi to its built-in `system` theme, which builds every pi color from the palette the terminal reports, and keeps it there on every start. The theme you had before is saved as `terminalThemePrevious`; `/cc-theme terminal off` (or `"terminalTheme": false`) restores it and keeps it. `pi --use-theme <name>` still picks the theme for that run, and on pi versions without the `system` theme (before 1.4) nothing changes.
96
100
 
97
101
  Pi already rebuilds the `system` theme when the terminal switches between light and dark. Terminals send no notification when you switch between two themes of the same kind, for example from one dark Rex theme to another, so this extension also asks the terminal for its color scheme whenever the terminal window regains focus (you went to the terminal's settings and came back). Pi then re-reads the palette and repaints only if the colors changed. The query is answered by Ghostty, Rex, and other terminals that support color scheme reports; in regular (non-fullscreen) mode the extension turns on focus reporting itself and keeps the focus events out of the editor.
98
102
 
99
103
  ```text
100
104
  /cc-theme terminal on # follow the terminal's colors
101
- /cc-theme terminal off # go back to the previous theme
105
+ /cc-theme terminal off # go back to the previous theme and keep it
102
106
  /cc-theme terminal status # show the current state
103
107
  ```
104
108
 
@@ -116,7 +120,7 @@ The selection is persisted to the active Pi agent settings file and applied to t
116
120
 
117
121
  #### Repaint the spinner with `/cc-spinner`
118
122
 
119
- 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:
120
124
 
121
125
  ```text
122
126
  /cc-spinner preview # list every common theme key with a colored sample
@@ -189,3 +193,4 @@ This project builds upon and was inspired by the excellent work of:
189
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
190
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
191
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
@@ -17,6 +17,6 @@
17
17
  "diffCollapsedLines": 24,
18
18
  "showTruncationHints": false,
19
19
  "themeAdaptive": true,
20
- "terminalTheme": false,
20
+ "terminalTheme": true,
21
21
  "diffColors": {}
22
22
  }
@@ -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
+ }