@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 +4 -0
- package/README.md +17 -4
- package/package.json +1 -1
- package/src/bash-block.ts +289 -0
- package/src/index.ts +91 -7
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
|
-
|
|
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
|
|
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
|
|
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
|
@@ -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")
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
}
|