@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 +4 -0
- package/README.md +17 -4
- package/package.json +1 -1
- package/src/bash-block.ts +289 -0
- package/src/index.ts +188 -18
- package/src/thinking-block-merger.ts +223 -1
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");
|
|
@@ -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")
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
!
|
|
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,
|
|
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("
|
|
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
|
}
|