@pi-kaush/pi-tool-call-markers 0.2.7 → 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.7",
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");
@@ -61,6 +66,7 @@ type PresentationPatchState = {
61
66
  theme?: ThemeLike;
62
67
  collapseParallel: boolean;
63
68
  groupCache: WeakMap<ToolExecutionRow, GroupRenderCache>;
69
+ collapsedCache: WeakMap<ToolExecutionRow, CollapsedRenderCache>;
64
70
  rowVersions: WeakMap<ToolExecutionRow, number>;
65
71
  rowGroups: WeakMap<ToolExecutionRow, ToolExecutionRow[]>;
66
72
  rowSignatures: WeakMap<ToolExecutionRow, string>;
@@ -85,6 +91,14 @@ type GroupRenderCache = {
85
91
  width: number;
86
92
  };
87
93
 
94
+ type CollapsedRenderCache = {
95
+ lines: string[];
96
+ signature: string;
97
+ version: number;
98
+ themeSample: string;
99
+ width: number;
100
+ };
101
+
88
102
  function envEnabled(name: string, defaultValue: boolean): boolean {
89
103
  const value = process.env[name]?.trim().toLowerCase();
90
104
  if (!value) return defaultValue;
@@ -104,9 +118,13 @@ function stripAnsi(text: string): string {
104
118
  // and overwrite the row's own marker.
105
119
  const INLINE_CONTROL_RE = /[\x00-\x08\x0b-\x1f\x7f]/g;
106
120
  // OSC first: the two-byte alternative would otherwise consume `\x1b]` and
107
- // 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.
108
126
  const DISPLAY_ANSI_RE =
109
- /\x1b(?:\][^\x07]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])/g;
127
+ /\x1b(?:\][^\x07\x1b]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])/g;
110
128
 
111
129
  function sanitizeInline(text: string): string {
112
130
  return text.replace(DISPLAY_ANSI_RE, "").replace(INLINE_CONTROL_RE, " ");
@@ -116,6 +134,33 @@ function hasVisibleContent(line: string): boolean {
116
134
  return stripAnsi(line).trim().length > 0;
117
135
  }
118
136
 
137
+ // State-transition fingerprint used by both the updateDisplay version bump
138
+ // and the settled-row render cache. It deliberately covers only shape
139
+ // transitions (partial/expanded/result/error): while a row is partial its
140
+ // content streams and it is never cached, and once settled the rendered
141
+ // content is fully determined by this fingerprint + width + theme.
142
+ function rowSignatureOf(row: ToolExecutionRow): string {
143
+ return `${row.isPartial}|${row.expanded}|${row.result ? 1 : 0}|${row.result?.isError ? 1 : 0}`;
144
+ }
145
+
146
+ // Theme samples are five theme.fg calls; computing them per row per frame
147
+ // would reintroduce the per-keystroke cost the render caches remove, so
148
+ // cache by theme object identity (a theme switch swaps the object).
149
+ const themeSamples = new WeakMap<ThemeLike, string>();
150
+ function themeSampleFor(theme: ThemeLike): string {
151
+ let sample = themeSamples.get(theme);
152
+ if (!sample) {
153
+ sample =
154
+ theme.fg("toolTitle", "x") +
155
+ theme.fg("muted", "x") +
156
+ theme.fg("dim", "x") +
157
+ theme.fg("warning", "x") +
158
+ theme.fg("error", "x");
159
+ themeSamples.set(theme, sample);
160
+ }
161
+ return sample;
162
+ }
163
+
119
164
  type InsetLayout = {
120
165
  contentWidth: number;
121
166
  left: number;
@@ -556,6 +601,64 @@ function diffCounts(diff: string): { added: number; removed: number } {
556
601
  return { added, removed };
557
602
  }
558
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
+
559
662
  function outcomeSummary(row: ToolExecutionRow): string | undefined {
560
663
  // Self-rendered tools own their result framing; the built-in heuristics
561
664
  // (line counts, diff stats) describe default-shell rows only, so their
@@ -796,7 +899,11 @@ function collapsedOutcome(
796
899
  const elapsed = liveElapsedText(row);
797
900
  return theme.fg("warning", elapsed ? `… · ${elapsed}` : "…");
798
901
  }
799
- 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
+ }
800
907
  return renderedOutcome(row, theme);
801
908
  }
802
909
 
@@ -808,7 +915,10 @@ function collapsedHeadline(
808
915
  // Failed rows render entirely in error so the row reads as the one that
809
916
  // failed, not just its outcome tail. Only the tool name is bold.
810
917
  const color = rowHasFailed(row) ? "error" : "muted";
811
- const marker = `${theme.fg(color, GROUP_MARKER)} `;
918
+ const marker = `${theme.fg(
919
+ color,
920
+ row.toolName === "subagent" ? SUBAGENT_MARKER : GROUP_MARKER,
921
+ )} `;
812
922
  const budget = Math.max(1, width - visibleWidth(marker));
813
923
  const label = collapsedCallLabel(row, budget, theme, color);
814
924
  const outcome = collapsedOutcome(row, budget, theme);
@@ -971,7 +1081,7 @@ function renderSubagentPlan(
971
1081
  `${step.profile ? ` [${sanitizeInline(step.profile)}]` : ""} ${subagentStepPreview(step.task)}`,
972
1082
  );
973
1083
 
974
- const marker = `${theme.fg(color, theme.bold(GROUP_MARKER))} `;
1084
+ const marker = `${theme.fg(color, theme.bold(SUBAGENT_MARKER))} `;
975
1085
  const budget = Math.max(1, width - visibleWidth(marker));
976
1086
  const outcome = failed
977
1087
  ? collapsedOutcome(row, budget, theme)
@@ -1066,6 +1176,9 @@ function renderCollapsedToolRow(
1066
1176
  ? renderSubagentPlan(row, layout.contentWidth, theme)
1067
1177
  : undefined;
1068
1178
  const body = plan ?? [collapsedHeadline(row, layout.contentWidth, theme)];
1179
+ if (row.toolName === "edit") {
1180
+ body.push(...renderedEditDiffLines(row, theme));
1181
+ }
1069
1182
  body.push(...imageResultLines(row, layout.contentWidth, theme));
1070
1183
  return ["", ...insetLines(body, width)];
1071
1184
  }
@@ -1077,8 +1190,12 @@ function groupedCallComponent(
1077
1190
  return {
1078
1191
  render(width: number): string[] {
1079
1192
  // Every member renders like a singleton — `% tool: call → outcome` per
1080
- // line with the tool name bolded — no bullets or internal blanks.
1081
- 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
+ ]);
1082
1199
  },
1083
1200
  invalidate() {},
1084
1201
  };
@@ -1115,12 +1232,7 @@ function renderGroupedToolRows(
1115
1232
  ): string[] {
1116
1233
  const theme = state.theme;
1117
1234
  if (!theme) return state.originalRender.call(row, width);
1118
- const themeSample =
1119
- theme.fg("toolTitle", "x") +
1120
- theme.fg("muted", "x") +
1121
- theme.fg("dim", "x") +
1122
- theme.fg("warning", "x") +
1123
- theme.fg("error", "x");
1235
+ const themeSample = themeSampleFor(theme);
1124
1236
  const cached = state.groupCache.get(row);
1125
1237
  const hasLiveMembers = rows.some(isLiveRow);
1126
1238
  if (
@@ -1330,6 +1442,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1330
1442
  const state: PresentationPatchState = {
1331
1443
  collapseParallel: envEnabled(COLLAPSE_PARALLEL_ENV, true),
1332
1444
  groupCache: new WeakMap(),
1445
+ collapsedCache: new WeakMap(),
1333
1446
  rowVersions: new WeakMap(),
1334
1447
  rowGroups: new WeakMap(),
1335
1448
  rowSignatures: new WeakMap(),
@@ -1342,7 +1455,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1342
1455
  // Group members always bump so their leader's cache refreshes; other
1343
1456
  // rows only bump on state transitions, so bash's per-second invalidate
1344
1457
  // ticks and resize invalidations stop busting caches.
1345
- const signature = `${this.isPartial}|${this.expanded}|${this.result ? 1 : 0}|${this.result?.isError ? 1 : 0}`;
1458
+ const signature = rowSignatureOf(this);
1346
1459
  if (
1347
1460
  state.rowGroups.has(this) ||
1348
1461
  state.rowSignatures.get(this) !== signature
@@ -1357,18 +1470,72 @@ function installPresentationPatch(): PresentationPatchState | undefined {
1357
1470
  this: ToolExecutionRow,
1358
1471
  width: number,
1359
1472
  ): string[] {
1360
- const lines = state.originalRender.call(this, width);
1473
+ const theme = state.theme;
1361
1474
  if (
1362
1475
  typeof this.expanded !== "boolean" ||
1363
1476
  typeof this.isPartial !== "boolean" ||
1364
1477
  this.expanded ||
1365
- !state.theme ||
1366
- (lines.length === 0 && (this.imageComponents?.length ?? 0) === 0)
1478
+ !theme
1367
1479
  ) {
1480
+ return state.originalRender.call(this, width);
1481
+ }
1482
+
1483
+ // ================================================================
1484
+ // Settled-row render cache — PLEASE DO NOT REMOVE THIS FAST PATH.
1485
+ // ================================================================
1486
+ // Pi's TUI re-renders the whole transcript on every keystroke, and
1487
+ // the collapsed-row decoration below rebuilds strings for every row
1488
+ // it touches. On a long session (~1,000 messages, hundreds of tool
1489
+ // rows) that measured at ~80 ms per keystroke — visible input lag.
1490
+ //
1491
+ // Settled rows (not partial, not expanded) have fully static render
1492
+ // output, so their collapsed lines are cached keyed by width +
1493
+ // rowSignatureOf + rowVersions + theme sample. updateDisplay bumps
1494
+ // rowVersions on every real state transition, and partial rows
1495
+ // bypass the cache entirely because their content streams. This
1496
+ // mirrors the existing groupCache discipline; keep them in sync.
1497
+ // ================================================================
1498
+ if (!this.isPartial) {
1499
+ const signature = rowSignatureOf(this);
1500
+ const version = state.rowVersions.get(this) ?? 0;
1501
+ const themeSample = themeSampleFor(theme);
1502
+ const cached = state.collapsedCache.get(this);
1503
+ if (
1504
+ cached &&
1505
+ cached.width === width &&
1506
+ cached.signature === signature &&
1507
+ cached.version === version &&
1508
+ cached.themeSample === themeSample
1509
+ ) {
1510
+ return cached.lines;
1511
+ }
1512
+ const lines = state.originalRender.call(this, width);
1513
+ if (lines.length === 0 && (this.imageComponents?.length ?? 0) === 0) {
1514
+ return lines;
1515
+ }
1516
+ try {
1517
+ const collapsed = renderCollapsedToolRow(this, width, theme);
1518
+ state.collapsedCache.set(this, {
1519
+ lines: collapsed,
1520
+ signature,
1521
+ version,
1522
+ themeSample,
1523
+ width,
1524
+ });
1525
+ return collapsed;
1526
+ } catch {
1527
+ return lines;
1528
+ }
1529
+ }
1530
+
1531
+ // Partial (streaming) rows keep the collapsed presentation but are
1532
+ // never cached: their content changes with every streamed chunk.
1533
+ const lines = state.originalRender.call(this, width);
1534
+ if (lines.length === 0 && (this.imageComponents?.length ?? 0) === 0) {
1368
1535
  return lines;
1369
1536
  }
1370
1537
  try {
1371
- return renderCollapsedToolRow(this, width, state.theme);
1538
+ return renderCollapsedToolRow(this, width, theme);
1372
1539
  } catch {
1373
1540
  return lines;
1374
1541
  }
@@ -1420,14 +1587,17 @@ function uninstallPresentationPatch(
1420
1587
  export default function (pi: ExtensionAPI) {
1421
1588
  const patch = installPresentationPatch();
1422
1589
  const grouping = patch ? installGroupingPatch(patch) : undefined;
1590
+ const bashPatch = installBashBlockPatch();
1423
1591
 
1424
1592
  pi.on("session_start", (_event, ctx) => {
1425
1593
  if (patch) patch.theme = ctx.ui.theme;
1594
+ if (bashPatch) bashPatch.theme = ctx.ui.theme;
1426
1595
  ctx.ui.setToolsExpanded(false);
1427
1596
  });
1428
1597
 
1429
1598
  pi.on("session_shutdown", () => {
1430
1599
  uninstallGroupingPatch(grouping);
1431
1600
  uninstallPresentationPatch(patch);
1601
+ uninstallBashBlockPatch(bashPatch);
1432
1602
  });
1433
1603
  }
@@ -5,6 +5,176 @@ const THINKING_GROUPING_PATCHED = Symbol.for("kg.pi.thinkingGrouping.v1");
5
5
  const PI_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
6
  const SPINNER_INTERVAL_MS = 80;
7
7
 
8
+ // Visual experiment for the settled "+ Thought" label. "inherit" keeps Pi's
9
+ // native styling (italic + thinkingText); "gray" sits halfway between the
10
+ // muted and text theme colors. The env var overrides the default so the
11
+ // variants can be compared without editing code or republishing.
12
+ const THOUGHT_LABEL_COLOR_ENV = "PI_TOOL_CALL_MARKERS_THOUGHT_COLOR";
13
+ type ThoughtLabelColor = "inherit" | "orange" | "gray";
14
+ const DEFAULT_THOUGHT_LABEL_COLOR: ThoughtLabelColor = "orange";
15
+ // cobalt2's orange token (#ffb86c).
16
+ const THOUGHT_LABEL_ORANGE = { r: 255, g: 184, b: 108 };
17
+
18
+ function thoughtLabelColorChoice(): ThoughtLabelColor {
19
+ const raw = process.env[THOUGHT_LABEL_COLOR_ENV];
20
+ return raw === "inherit" || raw === "orange" || raw === "gray"
21
+ ? raw
22
+ : DEFAULT_THOUGHT_LABEL_COLOR;
23
+ }
24
+
25
+ const CUBE_VALUES = [0, 95, 135, 175, 215, 255];
26
+ const BASIC_ANSI_RGB: Array<[number, string] | undefined> = [
27
+ [0, "#000000"],
28
+ [1, "#800000"],
29
+ [2, "#008000"],
30
+ [3, "#808000"],
31
+ [4, "#000080"],
32
+ [5, "#800080"],
33
+ [6, "#008080"],
34
+ [7, "#c0c0c0"],
35
+ [8, "#808080"],
36
+ [9, "#ff0000"],
37
+ [10, "#00ff00"],
38
+ [11, "#ffff00"],
39
+ [12, "#0000ff"],
40
+ [13, "#ff00ff"],
41
+ [14, "#00ffff"],
42
+ [15, "#ffffff"],
43
+ ];
44
+
45
+ type Rgb = { r: number; g: number; b: number };
46
+
47
+ type ThemeDetail = {
48
+ fg(color: string, text: string): string;
49
+ getFgAnsi?(color: string): string;
50
+ getColorMode?(): "truecolor" | "256color" | string;
51
+ };
52
+
53
+ let activeTheme: ThemeDetail | undefined;
54
+
55
+ function hexToRgb(hex: string): Rgb | undefined {
56
+ const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
57
+ if (!match) return undefined;
58
+ const value = parseInt(match[1]!, 16);
59
+ return { r: value >> 16, g: (value >> 8) & 255, b: value & 255 };
60
+ }
61
+
62
+ // Mirrors Pi's own mapping so a named color can round-trip through a
63
+ // 256-color terminal (e.g. when COLORFGBG forces 256color mode).
64
+ function ansi256ToRgb(index: number): Rgb | undefined {
65
+ if (index < 16) {
66
+ const hex = BASIC_ANSI_RGB[index]?.[1];
67
+ return hex ? hexToRgb(hex) : undefined;
68
+ }
69
+ if (index < 232) {
70
+ const cube = index - 16;
71
+ const channel = (n: number) => (n === 0 ? 0 : 55 + n * 40);
72
+ return {
73
+ r: channel(Math.floor(cube / 36)),
74
+ g: channel(Math.floor((cube % 36) / 6)),
75
+ b: channel(cube % 6),
76
+ };
77
+ }
78
+ const gray = 8 + (index - 232) * 10;
79
+ return { r: gray, g: gray, b: gray };
80
+ }
81
+
82
+ function rgbToAnsi256({ r, g, b }: Rgb): number {
83
+ const nearest = (value: number) =>
84
+ CUBE_VALUES.reduce(
85
+ (best, candidate) =>
86
+ Math.abs(candidate - value) < Math.abs(best - value) ? candidate : best,
87
+ 0,
88
+ );
89
+ const cubeR = nearest(r);
90
+ const cubeG = nearest(g);
91
+ const cubeB = nearest(b);
92
+ const cubeDistance =
93
+ (r - cubeR) ** 2 * 0.299 +
94
+ (g - cubeG) ** 2 * 0.587 +
95
+ (b - cubeB) ** 2 * 0.114;
96
+ const luminance = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
97
+ const grayStep = Math.max(0, Math.min(23, Math.round((luminance - 8) / 10)));
98
+ const grayValue = 8 + grayStep * 10;
99
+ const grayDistance = (luminance - grayValue) ** 2;
100
+ const spread = Math.max(r, g, b) - Math.min(r, g, b);
101
+ if (spread < 10 && grayDistance < cubeDistance) return 232 + grayStep;
102
+ return (
103
+ 16 +
104
+ 36 * CUBE_VALUES.indexOf(cubeR) +
105
+ 6 * CUBE_VALUES.indexOf(cubeG) +
106
+ CUBE_VALUES.indexOf(cubeB)
107
+ );
108
+ }
109
+
110
+ function parseAnsiFgRgb(ansi: string): Rgb | undefined {
111
+ if (/^\x1b\[39m$/.test(ansi)) return undefined;
112
+ const truecolor = /^\x1b\[38;2;(\d+);(\d+);(\d+)m$/.exec(ansi);
113
+ if (truecolor) {
114
+ return {
115
+ r: Number(truecolor[1]),
116
+ g: Number(truecolor[2]),
117
+ b: Number(truecolor[3]),
118
+ };
119
+ }
120
+ const indexed = /^\x1b\[38;5;(\d+)m$/.exec(ansi);
121
+ if (indexed) return ansi256ToRgb(Number(indexed[1]));
122
+ const basic = /^\x1b\[(9[0-7]|3[0-7])m$/.exec(ansi);
123
+ if (basic) {
124
+ const code = Number(basic[1]);
125
+ return ansi256ToRgb(code >= 90 ? code - 90 + 8 : code - 30);
126
+ }
127
+ return undefined;
128
+ }
129
+
130
+ function fgRgbAnsi({ r, g, b }: Rgb): string {
131
+ if (activeTheme?.getColorMode?.() === "256color") {
132
+ return `\x1b[38;5;${rgbToAnsi256({ r, g, b })}m`;
133
+ }
134
+ return `\x1b[38;2;${r};${g};${b}m`;
135
+ }
136
+
137
+ function midpointRgb(a: Rgb, b: Rgb): Rgb {
138
+ return {
139
+ r: Math.round((a.r + b.r) / 2),
140
+ g: Math.round((a.g + b.g) / 2),
141
+ b: Math.round((a.b + b.b) / 2),
142
+ };
143
+ }
144
+
145
+ let thoughtLabelPrefix = "";
146
+ let thoughtLabelSuffix = "";
147
+
148
+ function updateThoughtLabelStyle(): void {
149
+ thoughtLabelPrefix = "";
150
+ thoughtLabelSuffix = "";
151
+ const choice = thoughtLabelColorChoice();
152
+ if (choice === "inherit" || !activeTheme) return;
153
+
154
+ let rgb: Rgb | undefined;
155
+ if (choice === "orange") {
156
+ rgb = THOUGHT_LABEL_ORANGE;
157
+ } else if (activeTheme.getFgAnsi) {
158
+ const muted = parseAnsiFgRgb(activeTheme.getFgAnsi("muted"));
159
+ const text = parseAnsiFgRgb(activeTheme.getFgAnsi("text"));
160
+ if (muted && text) rgb = midpointRgb(muted, text);
161
+ }
162
+ if (!rgb) return;
163
+
164
+ // The styled label replaces Pi's italicized Text node wholesale (see
165
+ // restyleHiddenThinkingLabel). The leading italic-off still matters: the
166
+ // TUI's diff renderer can skip bytes shared with the previously drawn
167
+ // italic line, leaving the terminal in italic state otherwise.
168
+ thoughtLabelPrefix = `\x1b[23m${fgRgbAnsi(rgb)}`;
169
+ thoughtLabelSuffix = "\x1b[39m";
170
+ }
171
+
172
+ export function visibleThoughtLabel(label: string): string {
173
+ if (!thoughtLabelPrefix) return label;
174
+ const raw = label.replace(/\x1b\[[0-9;]*m/g, "");
175
+ return `${thoughtLabelPrefix}${raw}${thoughtLabelSuffix}`;
176
+ }
177
+
8
178
  type AssistantMessageLike = {
9
179
  content?: unknown[];
10
180
  };
@@ -12,9 +182,46 @@ type AssistantMessageLike = {
12
182
  type AssistantMessageRow = {
13
183
  hiddenThinkingLabel?: unknown;
14
184
  hideThinkingBlock?: unknown;
185
+ contentContainer?: { children?: unknown[] };
15
186
  updateContent(message: AssistantMessageLike, ...args: unknown[]): void;
16
187
  };
17
188
 
189
+ type TextLikeChild = {
190
+ text?: unknown;
191
+ setText?(text: string): void;
192
+ };
193
+
194
+ // Pi renders the hidden-thinking label as an italic Text node built from the
195
+ // plain label field. Embedding style codes in the field itself is not
196
+ // enough: the TUI diff renderer reuses the byte prefix shared with the
197
+ // previous (italic) frame, so an in-line italic reset may never reach the
198
+ // terminal. Swap the node's text for a self-contained styled version after
199
+ // each native render pass instead.
200
+ function restyleHiddenThinkingLabel(row: AssistantMessageRow): void {
201
+ if (!thoughtLabelPrefix) return;
202
+ if (
203
+ row.hideThinkingBlock !== true ||
204
+ typeof row.hiddenThinkingLabel !== "string"
205
+ ) {
206
+ return;
207
+ }
208
+ const label = row.hiddenThinkingLabel;
209
+ const children = row.contentContainer?.children;
210
+ if (!Array.isArray(children)) return;
211
+ const styled = visibleThoughtLabel(label);
212
+ for (const child of children) {
213
+ const textChild = child as TextLikeChild | undefined;
214
+ if (
215
+ typeof textChild?.text !== "string" ||
216
+ typeof textChild.setText !== "function" ||
217
+ !textChild.text.includes(label)
218
+ ) {
219
+ continue;
220
+ }
221
+ textChild.setText(styled);
222
+ }
223
+ }
224
+
18
225
  type ThinkingTiming = {
19
226
  finishedAt?: number;
20
227
  startedAt: number;
@@ -186,6 +393,11 @@ function installThinkingGroupingPatch():
186
393
  // Preserve Pi's native label if its private row shape changes.
187
394
  }
188
395
  Reflect.apply(state.originalUpdateContent, this, [combined, ...args]);
396
+ try {
397
+ restyleHiddenThinkingLabel(this);
398
+ } catch {
399
+ // Keep Pi's native label styling if the row shape changes.
400
+ }
189
401
  };
190
402
 
191
403
  state.patchedUpdateContent = patchedUpdateContent;
@@ -224,5 +436,15 @@ function uninstallThinkingGroupingPatch(
224
436
 
225
437
  export default function (pi: ExtensionAPI) {
226
438
  const patch = installThinkingGroupingPatch();
227
- pi.on("session_shutdown", () => uninstallThinkingGroupingPatch(patch));
439
+ pi.on("session_start", (_event, ctx) => {
440
+ if (ctx.mode !== "tui") return;
441
+ activeTheme = ctx.ui.theme as unknown as ThemeDetail;
442
+ updateThoughtLabelStyle();
443
+ });
444
+ pi.on("session_shutdown", () => {
445
+ activeTheme = undefined;
446
+ thoughtLabelPrefix = "";
447
+ thoughtLabelSuffix = "";
448
+ uninstallThinkingGroupingPatch(patch);
449
+ });
228
450
  }