@pi-kaush/pi-tool-call-markers 0.2.8 → 0.2.9

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
@@ -2,6 +2,10 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Fix: preserve the visible text of hyperlink-wrapped paths in collapsed rows. Pi renders `read` call paths as OSC 8 hyperlinks (`ESC]8;;url ESC\ <path> ESC]8;; ESC\`); the previous greedy OSC strip consumed the path text along with the sequences, collapsing `% read: README.md:1-400` to `% read: :1-400`. OSC payloads now end at their first BEL/ST terminator, so `read`/`write`/`ls` rows show their filenames again.
6
+ - Show settled `edit` changes inline: the call line gains a `+added/-removed` outcome stat, and the display diff from `result.details.diff` renders as a bounded block underneath (`+` lines in the added tone, `-` in the removed tone, context muted, `...` for folded regions; capped at 12 lines with a `+N more` tail). `Ctrl+O` still returns Pi's full native diff.
7
+ - Render subagent plans with a `&` marker instead of `%`, so delegated calls read differently from ordinary tool rows (both the parsed plan shape and the generic fallback).
8
+ - Render user-run `!` bash blocks (BashExecutionComponent) with the railed prompt shell previously owned by `pi-content-layout` — execution display now lives entirely in this package, while `pi-content-layout` keeps only the transcript surface (message insets, status rows, editor, user prompts). The bash-block inset mirrors that package's message inset.
5
9
  - Render every collapsed tool call as a `% tool: call → outcome` line with the tool name bolded — one marker per call, no bullets, no blank lines between sections; grouped blocks share only their leading blank row (supersedes the nested sub-heading layout).
6
10
  - Render subagent calls as an unboxed plan — `% subagent` heading with chain/parallel counts and numbered steps as they execute, agent names in accent with emojis scraped from the native plan component (args fallback) — replacing the accent-rail card; failed subagents follow the full-red failure tone.
7
11
  - Strip display sequences and control bytes (notably `\r` from progress writers like git) from collapsed-row text so command output cannot return the cursor to column 0 and overwrite the row.
package/README.md CHANGED
@@ -21,21 +21,34 @@ Collapsed tool rows use semantic theme colors with no gear, background fill, box
21
21
  - **Quiet-turn grouping.** Sequential calls can join across an assistant row with no visible prose or thinking. Visible assistant content remains a boundary.
22
22
  - **MCP and self-rendered tools.** Their stable call labels, compact arguments, pending state, success, and first error line use the same collapsed shell. Native self-rendered details return when expanded.
23
23
  - **Images remain visible.** Image fallback text and terminal image components render below the corresponding marker with the same inset.
24
+ - **User-run `!` bash blocks.** Execution display is owned here: user-typed commands trade Pi's green rules for the railed prompt shell (dark surface, status-colored rail) that submitted prompts use.
24
25
  - **Native expansion remains authoritative.** `Ctrl+O` restores Pi's full individual tool rendering, including complete results, custom renderers, and error details.
25
26
 
26
27
  ## Subagent plans
27
28
 
28
- A recognized `subagent` call renders as an unboxed plan in the shared tool aesthetic:
29
+ A recognized `subagent` call renders as an unboxed plan in the shared tool aesthetic, marked with `&` instead of the ordinary `%` tool marker:
29
30
 
30
31
  ```text
31
- % subagent chain (3 steps) [repo-review]
32
+ & subagent chain (3 steps) [repo-review]
32
33
  1. 🐝 bee [workhorse] Challenge the compatibility conclusion…
33
34
  2. 🐝 bee …
34
35
  ```
35
36
 
36
- Single calls stay on one `% subagent <agent> [profile] <task preview>` row; chain calls get a heading with the kind, count, and scope followed by numbered steps (parallel tasks list without numbers). Agent display names (emoji + name) are scraped from the native plan component with an args fallback, and render in `accent`; everything else stays muted, and failed subagents go full red. Subagents never join ordinary tool groups.
37
+ Single calls stay on one `& subagent <agent> [profile] <task preview>` row; chain calls get a heading with the kind, count, and scope followed by numbered steps (parallel tasks list without numbers). Agent display names (emoji + name) are scraped from the native plan component with an args fallback, and render in `accent`; everything else stays muted, and failed subagents go full red. Subagents never join ordinary tool groups.
37
38
 
38
- Malformed, ambiguous, future, or too-narrow shapes fall back to the generic `% subagent …` collapsed row rather than dropping information, and `Ctrl+O` still exposes the native subagent renderer.
39
+ Malformed, ambiguous, future, or too-narrow shapes fall back to the generic `& subagent …` collapsed row rather than dropping information, and `Ctrl+O` still exposes the native subagent renderer.
40
+
41
+ ## Edit diffs
42
+
43
+ Settled `edit` calls keep their change visible without expanding: the call line gains a `+added/-removed` outcome stat, and the hunk renders as a bounded diff block underneath (`+` lines in the added tone, `-` in the removed tone, context muted, folded regions as `...`, capped at 12 lines with a count tail). The full native diff — line numbers, intra-line word highlights — still returns with `Ctrl+O`.
44
+
45
+ Files and paths in collapsed rows are preserved as displayed by Pi: hyperlink-wrapped paths (Pi wraps `read` call paths in OSC 8 hyperlinks) keep their visible text when sanitized for one-line rows.
46
+
47
+ ## Scope boundary
48
+
49
+ This package owns every **execution row** in the transcript: collapsed tool calls, tool grouping, subagent plans, thinking labels, and user-run `!` bash blocks (reshaped into the railed prompt shell). Transcript _surface_ layout — message insets, system-text and status-rule alignment, the editor surface, and the submitted-prompt shell for user messages — belongs to `@pi-kaush/pi-content-layout`, which never renders tool rows.
50
+
51
+ One shared visual contract crosses the line: `!` blocks align with message text, so their inset mirrors `pi-content-layout`'s message `contentInset` (see `src/bash-block.ts`). Change indentation in both packages together.
39
52
 
40
53
  ## Bundled thinking-block extension
41
54
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-kaush/pi-tool-call-markers",
3
- "version": "0.2.8",
3
+ "version": "0.2.9",
4
4
  "description": "Compact and group Pi tool calls, with a bundled extension for adjacent thinking blocks.",
5
5
  "license": "MIT",
6
6
  "author": "Kaushik Gopal",
@@ -0,0 +1,289 @@
1
+ import { BashExecutionComponent } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+
4
+ type ThemeLike = {
5
+ bold(text: string): string;
6
+ fg(color: string, text: string): string;
7
+ };
8
+
9
+ const BASH_BLOCK_PATCHED = Symbol.for("kg.pi.bashBlock.v1");
10
+
11
+ // The prompt surface uses a fixed near-black green tint instead of the
12
+ // theme's selectedBg token so every user-input surface stays identical.
13
+ const PROMPT_SURFACE_BG = "\x1b[48;2;7;19;18m"; // #071312
14
+ const PROMPT_RAIL = "▎";
15
+
16
+ // User-run bash blocks align with the message inset pi-content-layout
17
+ // applies to transcript lines. Keep this in sync with that package's
18
+ // contentInset (2 columns outside the rail for any width above 4); the
19
+ // conversation surface has one shared visual contract even though the two
20
+ // packages otherwise own disjoint components.
21
+ function bashBlockInset(width: number): number {
22
+ return width > BASH_BLOCK_INSET * 2 ? BASH_BLOCK_INSET : 0;
23
+ }
24
+ const BASH_BLOCK_INSET = 2;
25
+
26
+ const SGR_PATTERN = /\x1b\[([0-9;]*)m/g;
27
+ const BACKGROUND_PATTERN =
28
+ /\x1b\[(?:4[0-8]|10[0-7]|48;5;\d{1,3}|48;2;\d{1,3};\d{1,3};\d{1,3})m/g;
29
+ const DISPLAY_ANSI_PATTERN = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
30
+
31
+ function semanticControlEnd(text: string, offset: number): number | undefined {
32
+ if (text[offset] !== "\x1b") return undefined;
33
+ const kind = text[offset + 1];
34
+ if (kind !== "]" && kind !== "_" && kind !== "P" && kind !== "^") {
35
+ return undefined;
36
+ }
37
+ for (let index = offset + 2; index < text.length; index++) {
38
+ if (text[index] === "\x07") return index + 1;
39
+ if (text[index] === "\x1b" && text[index + 1] === "\\") {
40
+ return index + 2;
41
+ }
42
+ }
43
+ return undefined;
44
+ }
45
+
46
+ function splitLeadingSemanticControls(line: string): {
47
+ controls: string;
48
+ content: string;
49
+ } {
50
+ let offset = 0;
51
+ while (offset < line.length) {
52
+ const end = semanticControlEnd(line, offset);
53
+ if (end === undefined) break;
54
+ offset = end;
55
+ }
56
+ return { controls: line.slice(0, offset), content: line.slice(offset) };
57
+ }
58
+
59
+ function fitLine(line: string, width: number): string {
60
+ if (width <= 0) return "";
61
+ const clipped = truncateToWidth(line, width, "");
62
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
63
+ }
64
+
65
+ function reapplyBackgroundAfterReset(
66
+ text: string,
67
+ backgroundAnsi: string,
68
+ ): string {
69
+ return text.replace(SGR_PATTERN, (sequence, rawParameters: string) => {
70
+ const parameters =
71
+ rawParameters === "" ? [0] : rawParameters.split(";").map(Number);
72
+ return parameters.includes(0) || parameters.includes(49)
73
+ ? `${sequence}${backgroundAnsi}`
74
+ : sequence;
75
+ });
76
+ }
77
+
78
+ function paintBackground(
79
+ line: string,
80
+ width: number,
81
+ backgroundAnsi: string,
82
+ ): string {
83
+ const fitted = fitLine(line, width);
84
+ // Mirrors Theme.bg: background, content, then a background-only reset.
85
+ return `${backgroundAnsi}${reapplyBackgroundAfterReset(fitted, backgroundAnsi)}\x1b[49m`;
86
+ }
87
+
88
+ function stripDisplayAnsi(text: string): string {
89
+ return text.replace(DISPLAY_ANSI_PATTERN, "");
90
+ }
91
+
92
+ function replaceBackground(text: string, backgroundAnsi: string): string {
93
+ return text.replace(BACKGROUND_PATTERN, backgroundAnsi);
94
+ }
95
+
96
+ // Renders lines inside a railed, dark-surfaced block (the submitted-prompt
97
+ // shell): optional leading semantic controls stay outside the inset, the rail
98
+ // is recolored, and content backgrounds are normalized to the prompt surface.
99
+ function renderRailedBlockLines(
100
+ lines: string[],
101
+ width: number,
102
+ theme: ThemeLike,
103
+ inset: number,
104
+ railColor: string,
105
+ ): string[] {
106
+ const blockWidth = width - inset * 2;
107
+ if (inset === 0 || blockWidth <= visibleWidth(PROMPT_RAIL)) {
108
+ return lines.map((line) => fitLine(line, width));
109
+ }
110
+ const bodyWidth = blockWidth - visibleWidth(PROMPT_RAIL);
111
+ const margin = " ".repeat(inset);
112
+ return lines.map((line) => {
113
+ const { controls, content } = splitLeadingSemanticControls(line);
114
+ const recolored = replaceBackground(content, PROMPT_SURFACE_BG);
115
+ const rail = theme.fg(railColor, PROMPT_RAIL);
116
+ // One extra leading space inside the body, so submitted text sits two
117
+ // columns right of the rail instead of one.
118
+ const body = paintBackground(
119
+ ` ${fitLine(recolored, Math.max(1, bodyWidth - 1))}`,
120
+ bodyWidth,
121
+ PROMPT_SURFACE_BG,
122
+ );
123
+ return `${controls}${margin}${rail}${body}${margin}`;
124
+ });
125
+ }
126
+
127
+ const RULE_LINE_RE = /^─+$/;
128
+
129
+ function isRuleLine(line: string | undefined): line is string {
130
+ return line !== undefined && RULE_LINE_RE.test(stripDisplayAnsi(line).trim());
131
+ }
132
+
133
+ // `!!` commands draw their rules dim instead of bashMode green, but the
134
+ // component does not store that flag, so sniff the top rule's color to keep
135
+ // the rail consistent with the framing it replaces.
136
+ function sniffsDimRule(rule: string, theme: ThemeLike): boolean {
137
+ const prefixOf = (colored: string) => colored.slice(0, colored.indexOf("─"));
138
+ const dimPrefix = prefixOf(theme.fg("dim", "─"));
139
+ return (
140
+ dimPrefix.length > 0 &&
141
+ dimPrefix !== prefixOf(theme.fg("bashMode", "─")) &&
142
+ rule.startsWith(dimPrefix)
143
+ );
144
+ }
145
+
146
+ // Rail tone for a user bash block: failures read red and cancellations
147
+ // yellow, matching the block's status text; anything else keeps the green
148
+ // the rules used (dim for `!!` commands).
149
+ function bashBlockRailColor(
150
+ status: string | undefined,
151
+ topRule: string | undefined,
152
+ theme: ThemeLike,
153
+ ): string {
154
+ if (status === "error") return "error";
155
+ if (status === "cancelled") return "warning";
156
+ if (topRule !== undefined && sniffsDimRule(topRule, theme)) return "dim";
157
+ return "bashMode";
158
+ }
159
+
160
+ // Reshapes BashExecutionComponent output into the submitted-prompt shell: the
161
+ // leading spacer stays outside the block, the top/bottom rules are dropped,
162
+ // and the remaining lines get one blank padded row at each end plus the rail
163
+ // and dark surface.
164
+ function renderBashBlockLines(
165
+ lines: string[],
166
+ width: number,
167
+ theme: ThemeLike,
168
+ status: string | undefined,
169
+ inset = bashBlockInset(width),
170
+ ): string[] {
171
+ const [spacer, ...rest] = lines;
172
+ const topRule = isRuleLine(rest[0]) ? rest[0] : undefined;
173
+ const inner = topRule !== undefined ? rest.slice(1) : rest.slice();
174
+ if (isRuleLine(inner[inner.length - 1])) inner.pop();
175
+ const railColor = bashBlockRailColor(status, topRule, theme);
176
+ const block = renderRailedBlockLines(
177
+ ["", ...inner, ""],
178
+ width,
179
+ theme,
180
+ inset,
181
+ railColor,
182
+ );
183
+ return [fitLine(spacer ?? "", width), ...block];
184
+ }
185
+
186
+ type BashRow = { status?: unknown };
187
+
188
+ type BashBlockPatchState = {
189
+ theme?: ThemeLike;
190
+ cache: WeakMap<object, { width: number; src: string[]; lines: string[] }>;
191
+ originalRender: (width: number) => string[];
192
+ patchedRender?: (width: number) => string[];
193
+ };
194
+
195
+ // User-typed `!` commands are execution rows, so their presentation belongs
196
+ // here with every other collapsed execution display; pi-content-layout stops
197
+ // at the message surface. Pi's BashExecutionComponent already draws the
198
+ // command, rules, and status; this patch reshapes that output into the
199
+ // railed prompt shell and recolors the rail from the exit outcome.
200
+ // TODO: Replace prototype patching with a public Pi rendering API when available.
201
+ function installBashBlockPatch(): BashBlockPatchState | undefined {
202
+ try {
203
+ const proto = BashExecutionComponent?.prototype as unknown as BashRow & {
204
+ [BASH_BLOCK_PATCHED]?: BashBlockPatchState;
205
+ render?: (width: number) => string[];
206
+ };
207
+ if (!proto || typeof proto.render !== "function") return undefined;
208
+
209
+ const existing = proto[BASH_BLOCK_PATCHED];
210
+ if (existing) return existing;
211
+
212
+ const state: BashBlockPatchState = {
213
+ cache: new WeakMap(),
214
+ originalRender: proto.render,
215
+ };
216
+ const patchedRender = function renderWithBashBlockShell(
217
+ this: BashRow,
218
+ width: number,
219
+ ): string[] {
220
+ const theme = state.theme;
221
+ if (!theme) return state.originalRender.call(this, width);
222
+ const inset = bashBlockInset(width);
223
+ const bodyWidth = width - inset * 2 - visibleWidth(PROMPT_RAIL);
224
+ if (inset === 0 || bodyWidth < 8) {
225
+ return state.originalRender.call(this, width);
226
+ }
227
+ const lines = state.originalRender.call(this, Math.max(1, bodyWidth));
228
+ const entry = state.cache.get(this);
229
+ if (
230
+ entry &&
231
+ entry.width === width &&
232
+ entry.src.length === lines.length &&
233
+ entry.src.every((line, index) => line === lines[index])
234
+ ) {
235
+ return entry.lines;
236
+ }
237
+ const status = this.status;
238
+ const decorated = renderBashBlockLines(
239
+ lines,
240
+ width,
241
+ theme,
242
+ typeof status === "string" ? status : undefined,
243
+ inset,
244
+ );
245
+ state.cache.set(this, { width, src: lines, lines: decorated });
246
+ return decorated;
247
+ };
248
+
249
+ try {
250
+ state.patchedRender = patchedRender;
251
+ proto.render = patchedRender;
252
+ Object.defineProperty(proto, BASH_BLOCK_PATCHED, {
253
+ configurable: true,
254
+ value: state,
255
+ });
256
+ } catch {
257
+ proto.render = state.originalRender;
258
+ return undefined;
259
+ }
260
+ return state;
261
+ } catch {
262
+ // Pi internals can change across versions; fail silently.
263
+ return undefined;
264
+ }
265
+ }
266
+
267
+ function uninstallBashBlockPatch(state: BashBlockPatchState | undefined): void {
268
+ if (!state) return;
269
+ const proto = BashExecutionComponent?.prototype as unknown as BashRow & {
270
+ [BASH_BLOCK_PATCHED]?: BashBlockPatchState;
271
+ render?: (width: number) => string[];
272
+ };
273
+ if (
274
+ proto[BASH_BLOCK_PATCHED] !== state ||
275
+ proto.render !== state.patchedRender
276
+ ) {
277
+ return;
278
+ }
279
+ proto.render = state.originalRender;
280
+ delete proto[BASH_BLOCK_PATCHED];
281
+ }
282
+
283
+ export {
284
+ PROMPT_RAIL,
285
+ PROMPT_SURFACE_BG,
286
+ installBashBlockPatch,
287
+ uninstallBashBlockPatch,
288
+ };
289
+ export type { BashBlockPatchState };
package/src/index.ts CHANGED
@@ -10,10 +10,15 @@ import {
10
10
  visibleWidth,
11
11
  wrapTextWithAnsi,
12
12
  } from "@earendil-works/pi-tui";
13
+ import {
14
+ installBashBlockPatch,
15
+ uninstallBashBlockPatch,
16
+ } from "./bash-block.ts";
13
17
  import { runChatContainerHooks } from "./container-hooks.ts";
14
18
 
15
19
  const OUTER_INSET = 2;
16
20
  const GROUP_MARKER = "%";
21
+ const SUBAGENT_MARKER = "&";
17
22
  const PRESENTATION_PATCHED = Symbol.for("kg.pi.toolPresentation.v3");
18
23
  const LEGACY_PRESENTATION_PATCHED = Symbol.for("kg.pi.toolPresentation.v2");
19
24
  const GROUPING_PATCHED = Symbol.for("kg.pi.toolGrouping.v1");
@@ -113,9 +118,13 @@ function stripAnsi(text: string): string {
113
118
  // and overwrite the row's own marker.
114
119
  const INLINE_CONTROL_RE = /[\x00-\x08\x0b-\x1f\x7f]/g;
115
120
  // OSC first: the two-byte alternative would otherwise consume `\x1b]` and
116
- // leave the hyperlink payload behind as visible text.
121
+ // leave the hyperlink payload behind as visible text. OSC sequences (OSC 8
122
+ // hyperlinks, titles) end at their first BEL/ST terminator; a payload match
123
+ // must stop there too, because the visible text sits BETWEEN two OSC
124
+ // sequences — Pi wraps read paths as `ESC]8;;url ESC\ <path> ESC]8;; ESC\`,
125
+ // and a greedy `[^\x07]*` would swallow that path along with the sequences.
117
126
  const DISPLAY_ANSI_RE =
118
- /\x1b(?:\][^\x07]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])/g;
127
+ /\x1b(?:\][^\x07\x1b]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])/g;
119
128
 
120
129
  function sanitizeInline(text: string): string {
121
130
  return text.replace(DISPLAY_ANSI_RE, "").replace(INLINE_CONTROL_RE, " ");
@@ -592,6 +601,64 @@ function diffCounts(diff: string): { added: number; removed: number } {
592
601
  return { added, removed };
593
602
  }
594
603
 
604
+ // Pi's edit tool (renderShell "self") stores its display diff in
605
+ // result.details.diff — lines like `+27 <content>`, `-27 <content>`,
606
+ // ` 28 <context>`, plus `...` for folded regions. Self-rendered rows
607
+ // normally keep only their one-line call label; edits additionally show
608
+ // the change as a bounded diff block under the summary and a +a/-b stat in
609
+ // the outcome tail, so the hunk stays visible without Ctrl+O while
610
+ // expanded rows keep Pi's native rendering.
611
+ const EDIT_DIFF_LINE_RE = /^([+\-\s])(\s*\d*)(.*)$/;
612
+ const MAX_EDIT_DIFF_LINES = 12;
613
+
614
+ function editDiffText(row: ToolExecutionRow): string | undefined {
615
+ if (row.toolName !== "edit") return undefined;
616
+ if (!row.result || row.isPartial !== false || rowHasFailed(row))
617
+ return undefined;
618
+ const diff = row.result.details?.diff;
619
+ return typeof diff === "string" && diff.length > 0 ? diff : undefined;
620
+ }
621
+
622
+ function renderedEditDiffStat(row: ToolExecutionRow): string | undefined {
623
+ const diff = editDiffText(row);
624
+ if (diff === undefined) return undefined;
625
+ const { added, removed } = diffCounts(diff);
626
+ return added + removed === 0 ? "applied" : `+${added}/-${removed}`;
627
+ }
628
+
629
+ function renderedEditDiffLines(
630
+ row: ToolExecutionRow,
631
+ theme: ThemeLike,
632
+ ): string[] {
633
+ const diff = editDiffText(row);
634
+ if (diff === undefined) return [];
635
+ const raw = diff.split("\n");
636
+ if (raw[raw.length - 1] === "") raw.pop();
637
+ const lines: string[] = [];
638
+ for (const line of raw.slice(0, MAX_EDIT_DIFF_LINES)) {
639
+ const clean = sanitizeInline(line).trimEnd();
640
+ if (clean.trim() === "...") {
641
+ lines.push(theme.fg("muted", " ..."));
642
+ continue;
643
+ }
644
+ const match = EDIT_DIFF_LINE_RE.exec(clean);
645
+ if (!match) continue;
646
+ const color =
647
+ match[1] === "+"
648
+ ? "toolDiffAdded"
649
+ : match[1] === "-"
650
+ ? "toolDiffRemoved"
651
+ : "toolDiffContext";
652
+ lines.push(theme.fg(color, ` ${match[1]}${match[2]}${match[3]}`));
653
+ }
654
+ if (raw.length > MAX_EDIT_DIFF_LINES) {
655
+ lines.push(
656
+ theme.fg("muted", ` ... +${raw.length - MAX_EDIT_DIFF_LINES} more`),
657
+ );
658
+ }
659
+ return lines;
660
+ }
661
+
595
662
  function outcomeSummary(row: ToolExecutionRow): string | undefined {
596
663
  // Self-rendered tools own their result framing; the built-in heuristics
597
664
  // (line counts, diff stats) describe default-shell rows only, so their
@@ -832,7 +899,11 @@ function collapsedOutcome(
832
899
  const elapsed = liveElapsedText(row);
833
900
  return theme.fg("warning", elapsed ? `… · ${elapsed}` : "…");
834
901
  }
835
- if (row.getRenderShell?.() === "self") return renderedGenericOutcome(theme);
902
+ if (row.getRenderShell?.() === "self") {
903
+ const editStat = renderedEditDiffStat(row);
904
+ if (editStat) return theme.fg("muted", `→ ${editStat}`);
905
+ return renderedGenericOutcome(theme);
906
+ }
836
907
  return renderedOutcome(row, theme);
837
908
  }
838
909
 
@@ -844,7 +915,10 @@ function collapsedHeadline(
844
915
  // Failed rows render entirely in error so the row reads as the one that
845
916
  // failed, not just its outcome tail. Only the tool name is bold.
846
917
  const color = rowHasFailed(row) ? "error" : "muted";
847
- const marker = `${theme.fg(color, GROUP_MARKER)} `;
918
+ const marker = `${theme.fg(
919
+ color,
920
+ row.toolName === "subagent" ? SUBAGENT_MARKER : GROUP_MARKER,
921
+ )} `;
848
922
  const budget = Math.max(1, width - visibleWidth(marker));
849
923
  const label = collapsedCallLabel(row, budget, theme, color);
850
924
  const outcome = collapsedOutcome(row, budget, theme);
@@ -1007,7 +1081,7 @@ function renderSubagentPlan(
1007
1081
  `${step.profile ? ` [${sanitizeInline(step.profile)}]` : ""} ${subagentStepPreview(step.task)}`,
1008
1082
  );
1009
1083
 
1010
- const marker = `${theme.fg(color, theme.bold(GROUP_MARKER))} `;
1084
+ const marker = `${theme.fg(color, theme.bold(SUBAGENT_MARKER))} `;
1011
1085
  const budget = Math.max(1, width - visibleWidth(marker));
1012
1086
  const outcome = failed
1013
1087
  ? collapsedOutcome(row, budget, theme)
@@ -1102,6 +1176,9 @@ function renderCollapsedToolRow(
1102
1176
  ? renderSubagentPlan(row, layout.contentWidth, theme)
1103
1177
  : undefined;
1104
1178
  const body = plan ?? [collapsedHeadline(row, layout.contentWidth, theme)];
1179
+ if (row.toolName === "edit") {
1180
+ body.push(...renderedEditDiffLines(row, theme));
1181
+ }
1105
1182
  body.push(...imageResultLines(row, layout.contentWidth, theme));
1106
1183
  return ["", ...insetLines(body, width)];
1107
1184
  }
@@ -1113,8 +1190,12 @@ function groupedCallComponent(
1113
1190
  return {
1114
1191
  render(width: number): string[] {
1115
1192
  // Every member renders like a singleton — `% tool: call → outcome` per
1116
- // line with the tool name bolded — no bullets or internal blanks.
1117
- return rows.map((row) => collapsedHeadline(row, width, theme));
1193
+ // line with the tool name bolded — no bullets or internal blanks;
1194
+ // settled edits keep their change visible with a bounded diff block.
1195
+ return rows.flatMap((row) => [
1196
+ collapsedHeadline(row, width, theme),
1197
+ ...(row.toolName === "edit" ? renderedEditDiffLines(row, theme) : []),
1198
+ ]);
1118
1199
  },
1119
1200
  invalidate() {},
1120
1201
  };
@@ -1506,14 +1587,17 @@ function uninstallPresentationPatch(
1506
1587
  export default function (pi: ExtensionAPI) {
1507
1588
  const patch = installPresentationPatch();
1508
1589
  const grouping = patch ? installGroupingPatch(patch) : undefined;
1590
+ const bashPatch = installBashBlockPatch();
1509
1591
 
1510
1592
  pi.on("session_start", (_event, ctx) => {
1511
1593
  if (patch) patch.theme = ctx.ui.theme;
1594
+ if (bashPatch) bashPatch.theme = ctx.ui.theme;
1512
1595
  ctx.ui.setToolsExpanded(false);
1513
1596
  });
1514
1597
 
1515
1598
  pi.on("session_shutdown", () => {
1516
1599
  uninstallGroupingPatch(grouping);
1517
1600
  uninstallPresentationPatch(patch);
1601
+ uninstallBashBlockPatch(bashPatch);
1518
1602
  });
1519
1603
  }