@pi-kaush/pi-tool-call-markers 0.2.9 → 0.2.11
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 +26 -0
- package/README.md +20 -8
- package/package.json +1 -1
- package/src/bash-block.ts +27 -13
- package/src/index.ts +153 -65
- package/src/info-visibility-state.ts +22 -0
- package/src/info-visibility.ts +74 -0
- package/src/thinking-block-merger.ts +140 -149
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- Add `/toggle-info`: hide tool calls and thinking entirely, or restore them
|
|
6
|
+
collapsed. Execution rows (including user-run `!` blocks) are lifted out of
|
|
7
|
+
the render pass via the shared chat-container hook; thinking blocks are
|
|
8
|
+
stripped before Pi builds the label/expanded block and replayed on restore.
|
|
9
|
+
Every session starts visible, and Ctrl+O/Ctrl+T behavior is unchanged when
|
|
10
|
+
visible. The toggle state lives on globalThis because Pi loads each package
|
|
11
|
+
entrypoint through jiti with module caching disabled — module-level state
|
|
12
|
+
would otherwise be duplicated between entrypoints.
|
|
13
|
+
- Adopt Pi's native content-color split in collapsed rows: tool names ride
|
|
14
|
+
`toolTitle` and call content/outcomes ride `toolOutput` (structural
|
|
15
|
+
chrome stays muted; failed rows stay uniformly error).
|
|
16
|
+
- Quiet the settled "+ Thought" label to the muted tone collapsed tool
|
|
17
|
+
calls use; only the live "⠋ Thinking…" spinner keeps the session's active
|
|
18
|
+
thinking-level tint (thinkingOff…thinkingMax), re-tinting on
|
|
19
|
+
thinking_level_select; the expanded thinking block is untouched. The
|
|
20
|
+
PI_TOOL_CALL_MARKERS_THOUGHT_COLOR values are `level` (default),
|
|
21
|
+
`mdheading`, and `inherit`; the midpoint `gray` variant and its RGB
|
|
22
|
+
color math are gone.
|
|
23
|
+
- Paint user-run `!` bash block surfaces with the theme's
|
|
24
|
+
`userMessageBg` token instead of a hardcoded hex, matching
|
|
25
|
+
pi-content-layout's prompt surfaces under any theme.
|
|
26
|
+
- Show subagent progress in the collapsed plan headline: while running, the tail reads `→ N turns · provider/model` in the warning tone from streamed result details; settled calls keep the same `→ N turns · provider/model` in muted instead of a bare `→ done` (turns aggregate across tasks, model only when all tasks agree).
|
|
27
|
+
- Source the settled "+ Thought" label color from the theme's mdHeading
|
|
28
|
+
token (orange in cobalt2, amber in Pi's stock themes) instead of a
|
|
29
|
+
hardcoded RGB. The PI_TOOL_CALL_MARKERS_THOUGHT_COLOR experiment values
|
|
30
|
+
are now `mdheading` (default), `gray`, and `inherit`.
|
|
5
31
|
- 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
32
|
- 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
33
|
- 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).
|
package/README.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
Give Pi's collapsed tool calls a quiet, width-safe transcript shell while preserving native details under `Ctrl+O`. The package also includes a display-only thinking-block adapter.
|
|
4
4
|
|
|
5
|
+
### Install
|
|
6
|
+
|
|
7
|
+
```fish
|
|
8
|
+
pi install npm:@pi-kaush/pi-tool-call-markers
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Restart Pi or run `/reload`.
|
|
12
|
+
|
|
5
13
|
## What it changes
|
|
6
14
|
|
|
7
15
|
Collapsed tool rows use semantic theme colors with no gear, background fill, box padding, or filled blank rows:
|
|
@@ -36,6 +44,8 @@ A recognized `subagent` call renders as an unboxed plan in the shared tool aesth
|
|
|
36
44
|
|
|
37
45
|
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.
|
|
38
46
|
|
|
47
|
+
While a subagent runs, the plan headline's tail shows live progress from the streamed result details — `→ 1 turn · provider/model` in the warning tone — and a settled call keeps the same `→ N turns · provider/model` summary in muted (turns aggregate across tasks; the model shows only when every task used the same one).
|
|
48
|
+
|
|
39
49
|
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
50
|
|
|
41
51
|
## Edit diffs
|
|
@@ -50,6 +60,14 @@ This package owns every **execution row** in the transcript: collapsed tool call
|
|
|
50
60
|
|
|
51
61
|
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.
|
|
52
62
|
|
|
63
|
+
## Hiding info entirely: /toggle-info
|
|
64
|
+
|
|
65
|
+
The default transcript keeps tool calls and thinking collapsed, and Ctrl+O /
|
|
66
|
+
Ctrl+T expand them as usual. When you want prose only, `/toggle-info` hides
|
|
67
|
+
tool calls (including user-run `!` blocks) and thinking completely; running it
|
|
68
|
+
again restores the collapsed view. The toggle is per-session: every session
|
|
69
|
+
starts visible.
|
|
70
|
+
|
|
53
71
|
## Bundled thinking-block extension
|
|
54
72
|
|
|
55
73
|
The second package entrypoint, `src/thinking-block-merger.ts`, combines only directly adjacent `thinking` blocks in a display copy. Tool calls, text, provider blocks, signatures, and stored session messages are unchanged.
|
|
@@ -63,15 +81,9 @@ When Pi exposes its per-row hidden-thinking and streaming fields, hidden reasoni
|
|
|
63
81
|
|
|
64
82
|
The live label samples Pi's native braille spinner sequence from the content updates Pi already renders; it does not add a timer. The adapter stores the first local streaming timestamp per assistant row in a `WeakMap`. A restored message or an older runtime with no streaming argument uses `+ Thought`. Visible-thinking mode remains native. There is no interval, timeout, render request, model call, or network work.
|
|
65
83
|
|
|
66
|
-
##
|
|
67
|
-
|
|
68
|
-
```bash
|
|
69
|
-
pi install npm:@pi-kaush/pi-tool-call-markers
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
For local development:
|
|
84
|
+
## Local development
|
|
73
85
|
|
|
74
|
-
```
|
|
86
|
+
```fish
|
|
75
87
|
pi \
|
|
76
88
|
-e ./extensions/pi-tool-call-markers/src/index.ts \
|
|
77
89
|
-e ./extensions/pi-tool-call-markers/src/thinking-block-merger.ts
|
package/package.json
CHANGED
package/src/bash-block.ts
CHANGED
|
@@ -4,13 +4,24 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
|
4
4
|
type ThemeLike = {
|
|
5
5
|
bold(text: string): string;
|
|
6
6
|
fg(color: string, text: string): string;
|
|
7
|
+
getBgAnsi?(color: string): string;
|
|
7
8
|
};
|
|
8
9
|
|
|
9
10
|
const BASH_BLOCK_PATCHED = Symbol.for("kg.pi.bashBlock.v1");
|
|
10
11
|
|
|
11
|
-
// The prompt surface
|
|
12
|
-
//
|
|
13
|
-
|
|
12
|
+
// The prompt surface paints with the theme's userMessageBg token, matching
|
|
13
|
+
// pi-content-layout's surfaces. userMessageBg is a required pi theme token;
|
|
14
|
+
// the legacy hex remains only as a fallback for theme lookalikes without
|
|
15
|
+
// getBgAnsi.
|
|
16
|
+
const PROMPT_SURFACE_BG_FALLBACK = "\x1b[48;2;7;19;18m"; // #071312
|
|
17
|
+
|
|
18
|
+
function promptSurfaceBg(theme: ThemeLike): string {
|
|
19
|
+
try {
|
|
20
|
+
return theme.getBgAnsi?.("userMessageBg") ?? PROMPT_SURFACE_BG_FALLBACK;
|
|
21
|
+
} catch {
|
|
22
|
+
return PROMPT_SURFACE_BG_FALLBACK;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
14
25
|
const PROMPT_RAIL = "▎";
|
|
15
26
|
|
|
16
27
|
// User-run bash blocks align with the message inset pi-content-layout
|
|
@@ -109,16 +120,17 @@ function renderRailedBlockLines(
|
|
|
109
120
|
}
|
|
110
121
|
const bodyWidth = blockWidth - visibleWidth(PROMPT_RAIL);
|
|
111
122
|
const margin = " ".repeat(inset);
|
|
123
|
+
const surfaceBg = promptSurfaceBg(theme);
|
|
112
124
|
return lines.map((line) => {
|
|
113
125
|
const { controls, content } = splitLeadingSemanticControls(line);
|
|
114
|
-
const recolored = replaceBackground(content,
|
|
126
|
+
const recolored = replaceBackground(content, surfaceBg);
|
|
115
127
|
const rail = theme.fg(railColor, PROMPT_RAIL);
|
|
116
128
|
// One extra leading space inside the body, so submitted text sits two
|
|
117
129
|
// columns right of the rail instead of one.
|
|
118
130
|
const body = paintBackground(
|
|
119
131
|
` ${fitLine(recolored, Math.max(1, bodyWidth - 1))}`,
|
|
120
132
|
bodyWidth,
|
|
121
|
-
|
|
133
|
+
surfaceBg,
|
|
122
134
|
);
|
|
123
135
|
return `${controls}${margin}${rail}${body}${margin}`;
|
|
124
136
|
});
|
|
@@ -186,6 +198,7 @@ function renderBashBlockLines(
|
|
|
186
198
|
type BashRow = { status?: unknown };
|
|
187
199
|
|
|
188
200
|
type BashBlockPatchState = {
|
|
201
|
+
owners: number;
|
|
189
202
|
theme?: ThemeLike;
|
|
190
203
|
cache: WeakMap<object, { width: number; src: string[]; lines: string[] }>;
|
|
191
204
|
originalRender: (width: number) => string[];
|
|
@@ -207,9 +220,13 @@ function installBashBlockPatch(): BashBlockPatchState | undefined {
|
|
|
207
220
|
if (!proto || typeof proto.render !== "function") return undefined;
|
|
208
221
|
|
|
209
222
|
const existing = proto[BASH_BLOCK_PATCHED];
|
|
210
|
-
if (existing)
|
|
223
|
+
if (existing) {
|
|
224
|
+
existing.owners++;
|
|
225
|
+
return existing;
|
|
226
|
+
}
|
|
211
227
|
|
|
212
228
|
const state: BashBlockPatchState = {
|
|
229
|
+
owners: 1,
|
|
213
230
|
cache: new WeakMap(),
|
|
214
231
|
originalRender: proto.render,
|
|
215
232
|
};
|
|
@@ -265,7 +282,9 @@ function installBashBlockPatch(): BashBlockPatchState | undefined {
|
|
|
265
282
|
}
|
|
266
283
|
|
|
267
284
|
function uninstallBashBlockPatch(state: BashBlockPatchState | undefined): void {
|
|
268
|
-
if (!state) return;
|
|
285
|
+
if (!state || state.owners <= 0) return;
|
|
286
|
+
state.owners--;
|
|
287
|
+
if (state.owners > 0) return;
|
|
269
288
|
const proto = BashExecutionComponent?.prototype as unknown as BashRow & {
|
|
270
289
|
[BASH_BLOCK_PATCHED]?: BashBlockPatchState;
|
|
271
290
|
render?: (width: number) => string[];
|
|
@@ -280,10 +299,5 @@ function uninstallBashBlockPatch(state: BashBlockPatchState | undefined): void {
|
|
|
280
299
|
delete proto[BASH_BLOCK_PATCHED];
|
|
281
300
|
}
|
|
282
301
|
|
|
283
|
-
export {
|
|
284
|
-
PROMPT_RAIL,
|
|
285
|
-
PROMPT_SURFACE_BG,
|
|
286
|
-
installBashBlockPatch,
|
|
287
|
-
uninstallBashBlockPatch,
|
|
288
|
-
};
|
|
302
|
+
export { PROMPT_RAIL, installBashBlockPatch, uninstallBashBlockPatch };
|
|
289
303
|
export type { BashBlockPatchState };
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
uninstallBashBlockPatch,
|
|
16
16
|
} from "./bash-block.ts";
|
|
17
17
|
import { runChatContainerHooks } from "./container-hooks.ts";
|
|
18
|
+
import { installInfoVisibility } from "./info-visibility.ts";
|
|
18
19
|
|
|
19
20
|
const OUTER_INSET = 2;
|
|
20
21
|
const GROUP_MARKER = "%";
|
|
@@ -63,13 +64,13 @@ type ToolExecutionRow = {
|
|
|
63
64
|
};
|
|
64
65
|
|
|
65
66
|
type PresentationPatchState = {
|
|
67
|
+
owners: number;
|
|
66
68
|
theme?: ThemeLike;
|
|
67
69
|
collapseParallel: boolean;
|
|
68
70
|
groupCache: WeakMap<ToolExecutionRow, GroupRenderCache>;
|
|
69
71
|
collapsedCache: WeakMap<ToolExecutionRow, CollapsedRenderCache>;
|
|
70
72
|
rowVersions: WeakMap<ToolExecutionRow, number>;
|
|
71
73
|
rowGroups: WeakMap<ToolExecutionRow, ToolExecutionRow[]>;
|
|
72
|
-
rowSignatures: WeakMap<ToolExecutionRow, string>;
|
|
73
74
|
originalRender: (width: number) => string[];
|
|
74
75
|
originalUpdateDisplay: () => void;
|
|
75
76
|
patchedRender?: (width: number) => string[];
|
|
@@ -77,6 +78,7 @@ type PresentationPatchState = {
|
|
|
77
78
|
};
|
|
78
79
|
|
|
79
80
|
type GroupingPatchState = {
|
|
81
|
+
owners: number;
|
|
80
82
|
presentation: PresentationPatchState;
|
|
81
83
|
originalRender: (width: number) => string[];
|
|
82
84
|
patchedRender?: (width: number) => string[];
|
|
@@ -111,12 +113,11 @@ function stripAnsi(text: string): string {
|
|
|
111
113
|
return text.replace(ANSI_RE, "");
|
|
112
114
|
}
|
|
113
115
|
|
|
114
|
-
// Command output can carry cursor-moving control bytes
|
|
115
|
-
//
|
|
116
|
-
// and scraped text is stripped of display sequences and
|
|
117
|
-
// it can reach a row;
|
|
118
|
-
|
|
119
|
-
const INLINE_CONTROL_RE = /[\x00-\x08\x0b-\x1f\x7f]/g;
|
|
116
|
+
// Command output can carry cursor-moving control bytes, raw terminal
|
|
117
|
+
// sequences, line feeds, and tabs. Collapsed rows are single-line, so result
|
|
118
|
+
// and scraped text is stripped of display sequences and all C0 controls before
|
|
119
|
+
// it can reach a row; otherwise control bytes could split or overwrite it.
|
|
120
|
+
const INLINE_CONTROL_RE = /[\x00-\x1f\x7f]/g;
|
|
120
121
|
// OSC first: the two-byte alternative would otherwise consume `\x1b]` and
|
|
121
122
|
// leave the hyperlink payload behind as visible text. OSC sequences (OSC 8
|
|
122
123
|
// hyperlinks, titles) end at their first BEL/ST terminator; a payload match
|
|
@@ -134,11 +135,9 @@ function hasVisibleContent(line: string): boolean {
|
|
|
134
135
|
return stripAnsi(line).trim().length > 0;
|
|
135
136
|
}
|
|
136
137
|
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
// content streams and it is never cached, and once settled the rendered
|
|
141
|
-
// content is fully determined by this fingerprint + width + theme.
|
|
138
|
+
// Shape fingerprint used in settled-row cache keys. It protects against
|
|
139
|
+
// direct state mutation followed by render without updateDisplay; real
|
|
140
|
+
// component display updates invalidate the cache through rowVersions.
|
|
142
141
|
function rowSignatureOf(row: ToolExecutionRow): string {
|
|
143
142
|
return `${row.isPartial}|${row.expanded}|${row.result ? 1 : 0}|${row.result?.isError ? 1 : 0}`;
|
|
144
143
|
}
|
|
@@ -253,7 +252,7 @@ function capFailureTail(tail: string, cap: number, theme: ThemeLike): string {
|
|
|
253
252
|
}
|
|
254
253
|
|
|
255
254
|
function renderedGenericOutcome(theme: ThemeLike): string {
|
|
256
|
-
return theme.fg("
|
|
255
|
+
return theme.fg("toolOutput", "→ done");
|
|
257
256
|
}
|
|
258
257
|
|
|
259
258
|
// The MCP adapter stashes its call's first line in renderer state before
|
|
@@ -707,7 +706,7 @@ function renderedOutcome(
|
|
|
707
706
|
): string | undefined {
|
|
708
707
|
const summary = outcomeSummary(row);
|
|
709
708
|
if (!summary) return undefined;
|
|
710
|
-
return theme.fg("
|
|
709
|
+
return theme.fg("toolOutput", `→ ${summary}`);
|
|
711
710
|
}
|
|
712
711
|
|
|
713
712
|
function renderedGroupedOutcome(
|
|
@@ -847,7 +846,7 @@ function renderedCallSummary(
|
|
|
847
846
|
|
|
848
847
|
const fallback = compactArgs(row.args);
|
|
849
848
|
return fallback
|
|
850
|
-
? theme.fg("
|
|
849
|
+
? theme.fg("toolOutput", fallback)
|
|
851
850
|
: theme.fg("muted", "(no arguments)");
|
|
852
851
|
}
|
|
853
852
|
|
|
@@ -860,9 +859,13 @@ function styledCallLabel(
|
|
|
860
859
|
const match = /^(\S+)(.*)$/s.exec(plain);
|
|
861
860
|
if (!match) return theme.fg(color, plain);
|
|
862
861
|
const rest = match[2] ?? "";
|
|
862
|
+
// Failed rows stay uniformly error-colored; settled rows use Pi's native
|
|
863
|
+
// split — toolTitle for the name, toolOutput for the call content.
|
|
864
|
+
const nameColor = color === "muted" ? "toolTitle" : color;
|
|
865
|
+
const restColor = color === "muted" ? "toolOutput" : color;
|
|
863
866
|
return (
|
|
864
|
-
theme.fg(
|
|
865
|
-
(rest ? theme.fg(
|
|
867
|
+
theme.fg(nameColor, theme.bold(match[1] ?? "")) +
|
|
868
|
+
(rest ? theme.fg(restColor, `:${rest}`) : "")
|
|
866
869
|
);
|
|
867
870
|
}
|
|
868
871
|
|
|
@@ -901,7 +904,7 @@ function collapsedOutcome(
|
|
|
901
904
|
}
|
|
902
905
|
if (row.getRenderShell?.() === "self") {
|
|
903
906
|
const editStat = renderedEditDiffStat(row);
|
|
904
|
-
if (editStat) return theme.fg("
|
|
907
|
+
if (editStat) return theme.fg("toolOutput", `→ ${editStat}`);
|
|
905
908
|
return renderedGenericOutcome(theme);
|
|
906
909
|
}
|
|
907
910
|
return renderedOutcome(row, theme);
|
|
@@ -1054,6 +1057,33 @@ function subagentStepPreview(task: string): string {
|
|
|
1054
1057
|
return clean.length > 40 ? `${clean.slice(0, 40)}...` : clean;
|
|
1055
1058
|
}
|
|
1056
1059
|
|
|
1060
|
+
// Partial and final subagent results both carry per-task usage in details
|
|
1061
|
+
// (the subagent extension streams makeDetails snapshots through onUpdate), so
|
|
1062
|
+
// this works live and settled. Surface the smallest useful progress — total
|
|
1063
|
+
// turns, and the model when every task resolved to the same one.
|
|
1064
|
+
function subagentProgressText(row: ToolExecutionRow): string | undefined {
|
|
1065
|
+
const results = row.result?.details?.results;
|
|
1066
|
+
if (!Array.isArray(results) || results.length === 0) return undefined;
|
|
1067
|
+
let turns = 0;
|
|
1068
|
+
const models = new Set<string>();
|
|
1069
|
+
for (const result of results) {
|
|
1070
|
+
if (!isRecord(result)) continue;
|
|
1071
|
+
const usage = result.usage;
|
|
1072
|
+
const turnCount = isRecord(usage) ? usage.turns : undefined;
|
|
1073
|
+
if (typeof turnCount === "number" && Number.isFinite(turnCount)) {
|
|
1074
|
+
turns += turnCount;
|
|
1075
|
+
}
|
|
1076
|
+
const model = result.model ?? result.requestedModel;
|
|
1077
|
+
if (typeof model === "string" && model.trim().length > 0) {
|
|
1078
|
+
models.add(model.trim());
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
const parts: string[] = [];
|
|
1082
|
+
if (turns > 0) parts.push(`${turns} turn${turns === 1 ? "" : "s"}`);
|
|
1083
|
+
if (models.size === 1) parts.push(sanitizeInline([...models][0]!));
|
|
1084
|
+
return parts.length > 0 ? parts.join(" · ") : undefined;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1057
1087
|
// Subagents render as an ordinary unboxed tool block: a `%` heading with the
|
|
1058
1088
|
// plan kind/count/scope, then the numbered chain steps or parallel tasks with
|
|
1059
1089
|
// agent names in accent. Everything else stays muted (or error on failure),
|
|
@@ -1075,20 +1105,22 @@ function renderSubagentPlan(
|
|
|
1075
1105
|
// other collapsed-row text so control bytes cannot reach the terminal.
|
|
1076
1106
|
const displayOf = (agent: string) =>
|
|
1077
1107
|
theme.fg(nameColor, displayNames.get(agent) ?? sanitizeInline(agent));
|
|
1108
|
+
const detailColor = failed ? "error" : "toolOutput";
|
|
1078
1109
|
const detailOf = (step: SubagentStep) =>
|
|
1079
1110
|
theme.fg(
|
|
1080
|
-
|
|
1111
|
+
detailColor,
|
|
1081
1112
|
`${step.profile ? ` [${sanitizeInline(step.profile)}]` : ""} ${subagentStepPreview(step.task)}`,
|
|
1082
1113
|
);
|
|
1083
1114
|
|
|
1084
1115
|
const marker = `${theme.fg(color, theme.bold(SUBAGENT_MARKER))} `;
|
|
1085
1116
|
const budget = Math.max(1, width - visibleWidth(marker));
|
|
1117
|
+
const progress = subagentProgressText(row);
|
|
1086
1118
|
const outcome = failed
|
|
1087
1119
|
? collapsedOutcome(row, budget, theme)
|
|
1088
1120
|
: isLiveRow(row)
|
|
1089
|
-
?
|
|
1121
|
+
? theme.fg("warning", progress ? `→ ${progress}` : "…")
|
|
1090
1122
|
: row.result
|
|
1091
|
-
?
|
|
1123
|
+
? theme.fg("toolOutput", progress ? `→ ${progress}` : "→ done")
|
|
1092
1124
|
: theme.fg("warning", "…");
|
|
1093
1125
|
const headline = (label: string) =>
|
|
1094
1126
|
marker +
|
|
@@ -1100,7 +1132,7 @@ function renderSubagentPlan(
|
|
|
1100
1132
|
const step = plan.steps[0]!;
|
|
1101
1133
|
return [
|
|
1102
1134
|
headline(
|
|
1103
|
-
theme.fg(
|
|
1135
|
+
theme.fg(failed ? "error" : "toolTitle", theme.bold("subagent")) +
|
|
1104
1136
|
" " +
|
|
1105
1137
|
displayOf(step.agent) +
|
|
1106
1138
|
detailOf(step),
|
|
@@ -1114,10 +1146,10 @@ function renderSubagentPlan(
|
|
|
1114
1146
|
: `parallel (${plan.steps.length} tasks)`;
|
|
1115
1147
|
const lines = [
|
|
1116
1148
|
headline(
|
|
1117
|
-
theme.fg(
|
|
1149
|
+
theme.fg(failed ? "error" : "toolTitle", theme.bold("subagent")) +
|
|
1118
1150
|
" " +
|
|
1119
|
-
theme.fg(
|
|
1120
|
-
theme.fg(
|
|
1151
|
+
theme.fg(failed ? "error" : "toolOutput", kindLabel) +
|
|
1152
|
+
theme.fg(failed ? "error" : "toolOutput", ` [${plan.scope}]`),
|
|
1121
1153
|
),
|
|
1122
1154
|
];
|
|
1123
1155
|
const shown = plan.steps.slice(0, 3);
|
|
@@ -1183,22 +1215,64 @@ function renderCollapsedToolRow(
|
|
|
1183
1215
|
return ["", ...insetLines(body, width)];
|
|
1184
1216
|
}
|
|
1185
1217
|
|
|
1186
|
-
function
|
|
1218
|
+
function groupedChildLabel(
|
|
1219
|
+
row: ToolExecutionRow,
|
|
1220
|
+
width: number,
|
|
1221
|
+
theme: ThemeLike,
|
|
1222
|
+
color: string = "muted",
|
|
1223
|
+
): string {
|
|
1224
|
+
const label = collapsedCallLabel(row, width, theme, color);
|
|
1225
|
+
const plain = stripAnsi(label);
|
|
1226
|
+
const toolName = row.toolName ?? "tool";
|
|
1227
|
+
let prefix = "";
|
|
1228
|
+
if (plain === toolName) {
|
|
1229
|
+
prefix = toolName;
|
|
1230
|
+
} else if (plain.startsWith(`${toolName}:`)) {
|
|
1231
|
+
const whitespace = /^\s*/.exec(plain.slice(toolName.length + 1))?.[0] ?? "";
|
|
1232
|
+
prefix = `${toolName}:${whitespace}`;
|
|
1233
|
+
}
|
|
1234
|
+
const prefixWidth = visibleWidth(prefix);
|
|
1235
|
+
const child = sliceByColumn(
|
|
1236
|
+
label,
|
|
1237
|
+
prefixWidth,
|
|
1238
|
+
Math.max(0, visibleWidth(label) - prefixWidth),
|
|
1239
|
+
true,
|
|
1240
|
+
);
|
|
1241
|
+
return child || theme.fg(color, "(no arguments)");
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function renderGroupedCallLines(
|
|
1187
1245
|
rows: ToolExecutionRow[],
|
|
1246
|
+
width: number,
|
|
1188
1247
|
theme: ThemeLike,
|
|
1189
|
-
):
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
|
|
1248
|
+
): string[] {
|
|
1249
|
+
const lines: string[] = [];
|
|
1250
|
+
let previousToolName: string | undefined;
|
|
1251
|
+
for (const row of rows) {
|
|
1252
|
+
const toolName = row.toolName ?? "tool";
|
|
1253
|
+
if (toolName !== previousToolName) {
|
|
1254
|
+
lines.push(
|
|
1255
|
+
`${theme.fg("muted", GROUP_MARKER)} ${theme.fg("toolTitle", theme.bold(toolName))}`,
|
|
1256
|
+
);
|
|
1257
|
+
previousToolName = toolName;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const color = rowHasFailed(row) ? "error" : "muted";
|
|
1261
|
+
const prefix = ` ${theme.fg(color, "•")} `;
|
|
1262
|
+
const budget = Math.max(1, width - visibleWidth(prefix));
|
|
1263
|
+
const label = groupedChildLabel(row, budget, theme, color);
|
|
1264
|
+
const outcome = collapsedOutcome(row, budget, theme);
|
|
1265
|
+
const suffix = theme.fg(color, "…");
|
|
1266
|
+
lines.push(
|
|
1267
|
+
prefix +
|
|
1268
|
+
(outcome
|
|
1269
|
+
? fitSummaryTail(label, outcome, budget, suffix)
|
|
1270
|
+
: fitSummary(label, budget, suffix)),
|
|
1271
|
+
);
|
|
1272
|
+
if (row.toolName === "edit")
|
|
1273
|
+
lines.push(...renderedEditDiffLines(row, theme));
|
|
1274
|
+
}
|
|
1275
|
+
return lines;
|
|
1202
1276
|
}
|
|
1203
1277
|
|
|
1204
1278
|
function sameMembers(
|
|
@@ -1247,8 +1321,8 @@ function renderGroupedToolRows(
|
|
|
1247
1321
|
}
|
|
1248
1322
|
|
|
1249
1323
|
const layout = insetLayout(width);
|
|
1250
|
-
const
|
|
1251
|
-
const lines = ["", ...insetLines(
|
|
1324
|
+
const body = renderGroupedCallLines(rows, layout.contentWidth, theme);
|
|
1325
|
+
const lines = ["", ...insetLines(body, width)];
|
|
1252
1326
|
if (hasLiveMembers) {
|
|
1253
1327
|
state.groupCache.delete(row);
|
|
1254
1328
|
} else {
|
|
@@ -1347,12 +1421,14 @@ function installGroupingPatch(
|
|
|
1347
1421
|
|
|
1348
1422
|
const existing = proto[GROUPING_PATCHED];
|
|
1349
1423
|
if (existing) {
|
|
1424
|
+
existing.owners++;
|
|
1350
1425
|
existing.presentation = presentation;
|
|
1351
1426
|
existing.disabled = false;
|
|
1352
1427
|
return existing;
|
|
1353
1428
|
}
|
|
1354
1429
|
|
|
1355
1430
|
const state: GroupingPatchState = {
|
|
1431
|
+
owners: 1,
|
|
1356
1432
|
presentation,
|
|
1357
1433
|
originalRender: proto.render,
|
|
1358
1434
|
};
|
|
@@ -1400,10 +1476,12 @@ function installGroupingPatch(
|
|
|
1400
1476
|
}
|
|
1401
1477
|
|
|
1402
1478
|
function uninstallGroupingPatch(state: GroupingPatchState | undefined): void {
|
|
1403
|
-
if (!state) return;
|
|
1479
|
+
if (!state || state.owners <= 0) return;
|
|
1480
|
+
state.owners--;
|
|
1481
|
+
if (state.owners > 0) return;
|
|
1404
1482
|
// Even when another extension's wrapper sits on top of ours, grouping must
|
|
1405
|
-
// not outlive its owner: the wrapper delegates once disabled, and a
|
|
1406
|
-
// reinstall re-enables it.
|
|
1483
|
+
// not outlive its final owner: the wrapper delegates once disabled, and a
|
|
1484
|
+
// later reinstall re-enables it.
|
|
1407
1485
|
state.disabled = true;
|
|
1408
1486
|
const proto = Container?.prototype as unknown as ComponentContainer & {
|
|
1409
1487
|
[GROUPING_PATCHED]?: GroupingPatchState;
|
|
@@ -1427,7 +1505,10 @@ function installPresentationPatch(): PresentationPatchState | undefined {
|
|
|
1427
1505
|
if (!proto) return undefined;
|
|
1428
1506
|
|
|
1429
1507
|
const existing = proto[PRESENTATION_PATCHED];
|
|
1430
|
-
if (existing)
|
|
1508
|
+
if (existing) {
|
|
1509
|
+
existing.owners++;
|
|
1510
|
+
return existing;
|
|
1511
|
+
}
|
|
1431
1512
|
const legacy = proto[LEGACY_PRESENTATION_PATCHED];
|
|
1432
1513
|
if (legacy) {
|
|
1433
1514
|
proto.render = legacy.originalRender;
|
|
@@ -1440,30 +1521,24 @@ function installPresentationPatch(): PresentationPatchState | undefined {
|
|
|
1440
1521
|
return undefined;
|
|
1441
1522
|
|
|
1442
1523
|
const state: PresentationPatchState = {
|
|
1524
|
+
owners: 1,
|
|
1443
1525
|
collapseParallel: envEnabled(COLLAPSE_PARALLEL_ENV, true),
|
|
1444
1526
|
groupCache: new WeakMap(),
|
|
1445
1527
|
collapsedCache: new WeakMap(),
|
|
1446
1528
|
rowVersions: new WeakMap(),
|
|
1447
1529
|
rowGroups: new WeakMap(),
|
|
1448
|
-
rowSignatures: new WeakMap(),
|
|
1449
1530
|
originalRender: proto.render,
|
|
1450
1531
|
originalUpdateDisplay: proto.updateDisplay,
|
|
1451
1532
|
};
|
|
1452
1533
|
const patchedUpdateDisplay = function updateDisplayWithCollapsedResult(
|
|
1453
1534
|
this: ToolExecutionRow,
|
|
1454
1535
|
): void {
|
|
1455
|
-
//
|
|
1456
|
-
//
|
|
1457
|
-
//
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
state.rowSignatures.get(this) !== signature
|
|
1462
|
-
) {
|
|
1463
|
-
state.rowSignatures.set(this, signature);
|
|
1464
|
-
state.rowVersions.set(this, (state.rowVersions.get(this) ?? 0) + 1);
|
|
1465
|
-
state.groupCache.delete(this);
|
|
1466
|
-
}
|
|
1536
|
+
// Transcript rerenders reuse settled caches, but a real component
|
|
1537
|
+
// display update can change args, results, renderer output, or images
|
|
1538
|
+
// without changing the row's shape signature.
|
|
1539
|
+
state.rowVersions.set(this, (state.rowVersions.get(this) ?? 0) + 1);
|
|
1540
|
+
state.groupCache.delete(this);
|
|
1541
|
+
state.collapsedCache.delete(this);
|
|
1467
1542
|
state.originalUpdateDisplay.call(this);
|
|
1468
1543
|
};
|
|
1469
1544
|
const patchedRender = function renderWithToolPresentation(
|
|
@@ -1488,12 +1563,11 @@ function installPresentationPatch(): PresentationPatchState | undefined {
|
|
|
1488
1563
|
// it touches. On a long session (~1,000 messages, hundreds of tool
|
|
1489
1564
|
// rows) that measured at ~80 ms per keystroke — visible input lag.
|
|
1490
1565
|
//
|
|
1491
|
-
// Settled rows (not partial, not expanded) have
|
|
1492
|
-
//
|
|
1493
|
-
// rowSignatureOf + rowVersions + theme sample.
|
|
1494
|
-
//
|
|
1495
|
-
// bypass the cache
|
|
1496
|
-
// mirrors the existing groupCache discipline; keep them in sync.
|
|
1566
|
+
// Settled rows (not partial, not expanded) have static output between
|
|
1567
|
+
// component display updates, so their collapsed lines are cached keyed
|
|
1568
|
+
// by width + rowSignatureOf + rowVersions + theme sample. Every real
|
|
1569
|
+
// updateDisplay call bumps rowVersions and clears both caches; partial
|
|
1570
|
+
// rows bypass the cache because their content streams.
|
|
1497
1571
|
// ================================================================
|
|
1498
1572
|
if (!this.isPartial) {
|
|
1499
1573
|
const signature = rowSignatureOf(this);
|
|
@@ -1565,7 +1639,9 @@ function installPresentationPatch(): PresentationPatchState | undefined {
|
|
|
1565
1639
|
function uninstallPresentationPatch(
|
|
1566
1640
|
state: PresentationPatchState | undefined,
|
|
1567
1641
|
): void {
|
|
1568
|
-
if (!state) return;
|
|
1642
|
+
if (!state || state.owners <= 0) return;
|
|
1643
|
+
state.owners--;
|
|
1644
|
+
if (state.owners > 0) return;
|
|
1569
1645
|
const proto =
|
|
1570
1646
|
ToolExecutionComponent?.prototype as unknown as ToolExecutionRow & {
|
|
1571
1647
|
[PRESENTATION_PATCHED]?: PresentationPatchState;
|
|
@@ -1584,10 +1660,20 @@ function uninstallPresentationPatch(
|
|
|
1584
1660
|
delete proto[PRESENTATION_PATCHED];
|
|
1585
1661
|
}
|
|
1586
1662
|
|
|
1663
|
+
// TODO(compaction cards): native/failed compaction cards in the transcript
|
|
1664
|
+
// are currently rendered by the pi-verbatim-compaction extension. Its
|
|
1665
|
+
// src/chat-log.ts is deliberately self-contained (public Pi imports only), so
|
|
1666
|
+
// porting here is: drop in that file, registerEntryRenderer(COMPACTION_LOG_TYPE),
|
|
1667
|
+
// and wire pi.on("session_compact") / pi.on("session_compact_failed") filtered
|
|
1668
|
+
// to fromExtension === false. Do this only if pi-verbatim-compaction is
|
|
1669
|
+
// removed or native cards should exist without it; while both are installed,
|
|
1670
|
+
// rendering here too would double-render every native compaction.
|
|
1587
1671
|
export default function (pi: ExtensionAPI) {
|
|
1588
1672
|
const patch = installPresentationPatch();
|
|
1589
1673
|
const grouping = patch ? installGroupingPatch(patch) : undefined;
|
|
1590
1674
|
const bashPatch = installBashBlockPatch();
|
|
1675
|
+
installInfoVisibility(pi);
|
|
1676
|
+
let released = false;
|
|
1591
1677
|
|
|
1592
1678
|
pi.on("session_start", (_event, ctx) => {
|
|
1593
1679
|
if (patch) patch.theme = ctx.ui.theme;
|
|
@@ -1596,6 +1682,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1596
1682
|
});
|
|
1597
1683
|
|
|
1598
1684
|
pi.on("session_shutdown", () => {
|
|
1685
|
+
if (released) return;
|
|
1686
|
+
released = true;
|
|
1599
1687
|
uninstallGroupingPatch(grouping);
|
|
1600
1688
|
uninstallPresentationPatch(patch);
|
|
1601
1689
|
uninstallBashBlockPatch(bashPatch);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Shared toggle state for /toggle-info. Pi loads extension entrypoints through
|
|
2
|
+
// jiti with moduleCache disabled, so each entrypoint's relative-import chain
|
|
3
|
+
// gets its OWN module instances — a plain module-level boolean here would be
|
|
4
|
+
// duplicated between the main entrypoint (the /toggle-info command) and the
|
|
5
|
+
// thinking-block-merger entrypoint (the strip). globalThis keeps it singular.
|
|
6
|
+
|
|
7
|
+
type InfoVisibilityState = { hidden: boolean };
|
|
8
|
+
|
|
9
|
+
const STATE_KEY = Symbol.for("kg.pi.toolCallMarkers.infoVisibility.v1");
|
|
10
|
+
|
|
11
|
+
function infoVisibilityState(): InfoVisibilityState {
|
|
12
|
+
const root = globalThis as Record<symbol, InfoVisibilityState | undefined>;
|
|
13
|
+
return (root[STATE_KEY] ??= { hidden: false });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function infoVisibilityHidden(): boolean {
|
|
17
|
+
return infoVisibilityState().hidden;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function setInfoVisibilityHidden(hidden: boolean): void {
|
|
21
|
+
infoVisibilityState().hidden = hidden;
|
|
22
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BashExecutionComponent,
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
ToolExecutionComponent,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { chatContainerHooks } from "./container-hooks.ts";
|
|
7
|
+
import {
|
|
8
|
+
infoVisibilityHidden,
|
|
9
|
+
setInfoVisibilityHidden,
|
|
10
|
+
} from "./info-visibility-state.ts";
|
|
11
|
+
import { refreshThinkingVisibility } from "./thinking-block-merger.ts";
|
|
12
|
+
|
|
13
|
+
function isExecutionRow(child: unknown): boolean {
|
|
14
|
+
return (
|
|
15
|
+
child instanceof ToolExecutionComponent ||
|
|
16
|
+
child instanceof BashExecutionComponent
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Chat-container hook: while hidden, tool and user-bash rows are lifted out of
|
|
21
|
+
// the child list for the render pass and restored afterwards, so grouping and
|
|
22
|
+
// insets run on prose only. Nested invocations are naturally idempotent: a
|
|
23
|
+
// second pass finds nothing left to remove.
|
|
24
|
+
function infoFilterHook(
|
|
25
|
+
_container: object,
|
|
26
|
+
children: unknown[],
|
|
27
|
+
): (() => void) | undefined {
|
|
28
|
+
if (!infoVisibilityHidden()) return undefined;
|
|
29
|
+
const removed: Array<[number, unknown]> = [];
|
|
30
|
+
for (let index = children.length - 1; index >= 0; index--) {
|
|
31
|
+
if (isExecutionRow(children[index])) {
|
|
32
|
+
removed.push([index, children[index]!]);
|
|
33
|
+
children.splice(index, 1);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (removed.length === 0) return undefined;
|
|
37
|
+
return () => {
|
|
38
|
+
for (const [index, child] of removed.reverse()) {
|
|
39
|
+
children.splice(index, 0, child);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function installInfoVisibility(pi: ExtensionAPI): void {
|
|
45
|
+
// The hook registry is process-global; dedupe by name so hot reloads swap
|
|
46
|
+
// rather than stack.
|
|
47
|
+
const hooks = chatContainerHooks();
|
|
48
|
+
for (const hook of [...hooks]) {
|
|
49
|
+
if (hook.name === infoFilterHook.name) hooks.delete(hook);
|
|
50
|
+
}
|
|
51
|
+
hooks.add(infoFilterHook);
|
|
52
|
+
|
|
53
|
+
pi.registerCommand("toggle-info", {
|
|
54
|
+
description: "Hide tool calls and thinking, or restore them collapsed",
|
|
55
|
+
handler: async (_args, ctx) => {
|
|
56
|
+
const hidden = !infoVisibilityHidden();
|
|
57
|
+
setInfoVisibilityHidden(hidden);
|
|
58
|
+
// Thinking labels/blocks only rebuild on content updates, so replay the
|
|
59
|
+
// last update per assistant row to apply the new state immediately.
|
|
60
|
+
refreshThinkingVisibility();
|
|
61
|
+
ctx.ui.notify(
|
|
62
|
+
hidden
|
|
63
|
+
? "Tool calls and thinking hidden — /toggle-info restores them"
|
|
64
|
+
: "Tool calls and thinking visible",
|
|
65
|
+
"info",
|
|
66
|
+
);
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Every session starts in the default collapsed-but-visible state. No
|
|
71
|
+
// shutdown reset: extra shutdown handlers would sit between this package's
|
|
72
|
+
// owner-counted patch releases.
|
|
73
|
+
pi.on("session_start", () => setInfoVisibilityHidden(false));
|
|
74
|
+
}
|
|
@@ -1,48 +1,42 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { AssistantMessageComponent } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { infoVisibilityHidden } from "./info-visibility-state.ts";
|
|
3
4
|
|
|
4
|
-
const THINKING_GROUPING_PATCHED = Symbol.for("kg.pi.thinkingGrouping.
|
|
5
|
+
const THINKING_GROUPING_PATCHED = Symbol.for("kg.pi.thinkingGrouping.v2");
|
|
5
6
|
const PI_SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
6
7
|
const SPINNER_INTERVAL_MS = 80;
|
|
7
8
|
|
|
8
|
-
// Visual
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// Visual treatment for the collapsed thinking label. The live "Thinking…"
|
|
10
|
+
// spinner tints with the session's active thinking-level color
|
|
11
|
+
// (thinkingOff…thinkingMax), so progress reads as activity; the settled
|
|
12
|
+
// "+ Thought" row drops to the muted token collapsed tool calls use, so
|
|
13
|
+
// finished reasoning stops advertising the level. "inherit" keeps Pi's
|
|
14
|
+
// native styling (italic + thinkingText) for both states, and "mdheading"
|
|
15
|
+
// rides the theme's mdHeading token for both. The env var overrides the
|
|
16
|
+
// default so variants can be compared without republishing. Only the
|
|
17
|
+
// collapsed label is restyled — the expanded thinking block keeps Pi's
|
|
18
|
+
// thinkingText.
|
|
12
19
|
const THOUGHT_LABEL_COLOR_ENV = "PI_TOOL_CALL_MARKERS_THOUGHT_COLOR";
|
|
13
|
-
type ThoughtLabelColor = "inherit" | "
|
|
14
|
-
const DEFAULT_THOUGHT_LABEL_COLOR: ThoughtLabelColor = "
|
|
15
|
-
// cobalt2's orange token (#ffb86c).
|
|
16
|
-
const THOUGHT_LABEL_ORANGE = { r: 255, g: 184, b: 108 };
|
|
20
|
+
type ThoughtLabelColor = "inherit" | "level" | "mdheading";
|
|
21
|
+
const DEFAULT_THOUGHT_LABEL_COLOR: ThoughtLabelColor = "level";
|
|
17
22
|
|
|
18
23
|
function thoughtLabelColorChoice(): ThoughtLabelColor {
|
|
19
24
|
const raw = process.env[THOUGHT_LABEL_COLOR_ENV];
|
|
20
|
-
return raw === "inherit" || raw === "
|
|
25
|
+
return raw === "inherit" || raw === "level" || raw === "mdheading"
|
|
21
26
|
? raw
|
|
22
27
|
: DEFAULT_THOUGHT_LABEL_COLOR;
|
|
23
28
|
}
|
|
24
29
|
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 };
|
|
30
|
+
// thinkingLevel values map onto theme tokens: "xhigh" → thinkingXhigh etc.
|
|
31
|
+
const LEVEL_TOKEN: Record<string, string> = {
|
|
32
|
+
off: "thinkingOff",
|
|
33
|
+
minimal: "thinkingMinimal",
|
|
34
|
+
low: "thinkingLow",
|
|
35
|
+
medium: "thinkingMedium",
|
|
36
|
+
high: "thinkingHigh",
|
|
37
|
+
xhigh: "thinkingXhigh",
|
|
38
|
+
max: "thinkingMax",
|
|
39
|
+
};
|
|
46
40
|
|
|
47
41
|
type ThemeDetail = {
|
|
48
42
|
fg(color: string, text: string): string;
|
|
@@ -51,128 +45,50 @@ type ThemeDetail = {
|
|
|
51
45
|
};
|
|
52
46
|
|
|
53
47
|
let activeTheme: ThemeDetail | undefined;
|
|
48
|
+
let activeLevel: (() => string) | undefined;
|
|
54
49
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
}
|
|
50
|
+
type LabelStyle = { prefix: string; suffix: string };
|
|
51
|
+
let liveLabelStyle: LabelStyle | undefined;
|
|
52
|
+
let settledLabelStyle: LabelStyle | undefined;
|
|
136
53
|
|
|
137
|
-
function
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
54
|
+
function styledWith(token: string): LabelStyle | undefined {
|
|
55
|
+
const ansi = activeTheme?.getFgAnsi?.(token);
|
|
56
|
+
if (!ansi) return undefined;
|
|
57
|
+
// The styled label replaces Pi's italicized Text node wholesale (see
|
|
58
|
+
// restyleHiddenThinkingLabel). The leading italic-off still matters: the
|
|
59
|
+
// TUI's diff renderer can skip bytes shared with the previously drawn
|
|
60
|
+
// italic line, leaving the terminal in italic state otherwise.
|
|
61
|
+
return { prefix: `\x1b[23m${ansi}`, suffix: "\x1b[39m" };
|
|
143
62
|
}
|
|
144
63
|
|
|
145
|
-
let thoughtLabelPrefix = "";
|
|
146
|
-
let thoughtLabelSuffix = "";
|
|
147
|
-
|
|
148
64
|
function updateThoughtLabelStyle(): void {
|
|
149
|
-
|
|
150
|
-
|
|
65
|
+
liveLabelStyle = undefined;
|
|
66
|
+
settledLabelStyle = undefined;
|
|
151
67
|
const choice = thoughtLabelColorChoice();
|
|
152
68
|
if (choice === "inherit" || !activeTheme) return;
|
|
153
69
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (!rgb) return;
|
|
70
|
+
const token =
|
|
71
|
+
choice === "mdheading"
|
|
72
|
+
? "mdHeading"
|
|
73
|
+
: (LEVEL_TOKEN[activeLevel?.() ?? "off"] ?? "thinkingOff");
|
|
74
|
+
liveLabelStyle = styledWith(token);
|
|
75
|
+
settledLabelStyle =
|
|
76
|
+
choice === "mdheading" ? liveLabelStyle : styledWith("muted");
|
|
77
|
+
}
|
|
163
78
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
thoughtLabelPrefix = `\x1b[23m${fgRgbAnsi(rgb)}`;
|
|
169
|
-
thoughtLabelSuffix = "\x1b[39m";
|
|
79
|
+
// The settled label is the finalized "+ Thought" row; every other label is
|
|
80
|
+
// the live spinner.
|
|
81
|
+
function isSettledThoughtLabel(label: string): boolean {
|
|
82
|
+
return label.startsWith("+ Thought");
|
|
170
83
|
}
|
|
171
84
|
|
|
172
85
|
export function visibleThoughtLabel(label: string): string {
|
|
173
|
-
|
|
86
|
+
const style = isSettledThoughtLabel(label)
|
|
87
|
+
? settledLabelStyle
|
|
88
|
+
: liveLabelStyle;
|
|
89
|
+
if (!style) return label;
|
|
174
90
|
const raw = label.replace(/\x1b\[[0-9;]*m/g, "");
|
|
175
|
-
return `${
|
|
91
|
+
return `${style.prefix}${raw}${style.suffix}`;
|
|
176
92
|
}
|
|
177
93
|
|
|
178
94
|
type AssistantMessageLike = {
|
|
@@ -198,7 +114,6 @@ type TextLikeChild = {
|
|
|
198
114
|
// terminal. Swap the node's text for a self-contained styled version after
|
|
199
115
|
// each native render pass instead.
|
|
200
116
|
function restyleHiddenThinkingLabel(row: AssistantMessageRow): void {
|
|
201
|
-
if (!thoughtLabelPrefix) return;
|
|
202
117
|
if (
|
|
203
118
|
row.hideThinkingBlock !== true ||
|
|
204
119
|
typeof row.hiddenThinkingLabel !== "string"
|
|
@@ -208,6 +123,7 @@ function restyleHiddenThinkingLabel(row: AssistantMessageRow): void {
|
|
|
208
123
|
const label = row.hiddenThinkingLabel;
|
|
209
124
|
const children = row.contentContainer?.children;
|
|
210
125
|
if (!Array.isArray(children)) return;
|
|
126
|
+
if (!liveLabelStyle && !settledLabelStyle) return;
|
|
211
127
|
const styled = visibleThoughtLabel(label);
|
|
212
128
|
for (const child of children) {
|
|
213
129
|
const textChild = child as TextLikeChild | undefined;
|
|
@@ -228,6 +144,7 @@ type ThinkingTiming = {
|
|
|
228
144
|
};
|
|
229
145
|
|
|
230
146
|
type ThinkingGroupingPatchState = {
|
|
147
|
+
owners: number;
|
|
231
148
|
originalUpdateContent: (
|
|
232
149
|
message: AssistantMessageLike,
|
|
233
150
|
...args: unknown[]
|
|
@@ -237,6 +154,12 @@ type ThinkingGroupingPatchState = {
|
|
|
237
154
|
...args: unknown[]
|
|
238
155
|
) => void;
|
|
239
156
|
timings: WeakMap<AssistantMessageRow, ThinkingTiming>;
|
|
157
|
+
// Last update per row, so /toggle-info can replay rows without waiting for
|
|
158
|
+
// new content. Cleared on session start; rows are session-lived anyway.
|
|
159
|
+
rows: Map<
|
|
160
|
+
AssistantMessageRow,
|
|
161
|
+
{ message: AssistantMessageLike; args: unknown[] }
|
|
162
|
+
>;
|
|
240
163
|
};
|
|
241
164
|
|
|
242
165
|
type ThinkingContentLike = {
|
|
@@ -330,6 +253,40 @@ function lifecycleLabel(
|
|
|
330
253
|
return "+ Thought";
|
|
331
254
|
}
|
|
332
255
|
|
|
256
|
+
function stripThinkingBlocks(
|
|
257
|
+
message: AssistantMessageLike,
|
|
258
|
+
): AssistantMessageLike {
|
|
259
|
+
const content = message.content;
|
|
260
|
+
if (!Array.isArray(content) || !content.some(isThinkingContent)) {
|
|
261
|
+
return message;
|
|
262
|
+
}
|
|
263
|
+
return { ...message, content: content.filter((c) => !isThinkingContent(c)) };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Replays the last update on every tracked assistant row so a /toggle-info
|
|
267
|
+
// flip applies immediately instead of waiting for the next content pass.
|
|
268
|
+
export function refreshThinkingVisibility(): void {
|
|
269
|
+
try {
|
|
270
|
+
const proto = AssistantMessageComponent?.prototype as unknown as {
|
|
271
|
+
[THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
|
|
272
|
+
};
|
|
273
|
+
const state = proto?.[THINKING_GROUPING_PATCHED];
|
|
274
|
+
if (!state?.patchedUpdateContent) return;
|
|
275
|
+
for (const [row, tracked] of state.rows) {
|
|
276
|
+
try {
|
|
277
|
+
Reflect.apply(state.patchedUpdateContent, row, [
|
|
278
|
+
tracked.message,
|
|
279
|
+
...tracked.args,
|
|
280
|
+
]);
|
|
281
|
+
} catch {
|
|
282
|
+
// A row that changed shape mid-flight keeps its last render.
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
} catch {
|
|
286
|
+
// Cosmetic replay; never break the toggle.
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
333
290
|
function applyHiddenThinkingLabel(
|
|
334
291
|
row: AssistantMessageRow,
|
|
335
292
|
message: AssistantMessageLike,
|
|
@@ -367,11 +324,17 @@ function installThinkingGroupingPatch():
|
|
|
367
324
|
if (!proto || typeof proto.updateContent !== "function") return undefined;
|
|
368
325
|
|
|
369
326
|
const existing = proto[THINKING_GROUPING_PATCHED];
|
|
370
|
-
if (existing)
|
|
327
|
+
if (existing) {
|
|
328
|
+
existing.owners++;
|
|
329
|
+
existing.rows ??= new Map();
|
|
330
|
+
return existing;
|
|
331
|
+
}
|
|
371
332
|
|
|
372
333
|
const state: ThinkingGroupingPatchState = {
|
|
334
|
+
owners: 1,
|
|
373
335
|
originalUpdateContent: proto.updateContent,
|
|
374
336
|
timings: new WeakMap(),
|
|
337
|
+
rows: new Map(),
|
|
375
338
|
};
|
|
376
339
|
const patchedUpdateContent = function updateContentWithCombinedThinking(
|
|
377
340
|
this: AssistantMessageRow,
|
|
@@ -380,9 +343,14 @@ function installThinkingGroupingPatch():
|
|
|
380
343
|
): void {
|
|
381
344
|
// Grouping and labels are cosmetic. Each fails open independently, and
|
|
382
345
|
// the original renderer is still called exactly once with every arg.
|
|
346
|
+
state.rows.set(this, { message, args });
|
|
383
347
|
let combined = message;
|
|
384
348
|
try {
|
|
385
|
-
|
|
349
|
+
// Pi builds the hidden label and the visible block only when thinking
|
|
350
|
+
// blocks exist, so stripping them hides both at once.
|
|
351
|
+
combined = infoVisibilityHidden()
|
|
352
|
+
? stripThinkingBlocks(message)
|
|
353
|
+
: combineAdjacentThinking(message);
|
|
386
354
|
} catch {
|
|
387
355
|
// Preserve the original message intact.
|
|
388
356
|
}
|
|
@@ -414,8 +382,10 @@ function installThinkingGroupingPatch():
|
|
|
414
382
|
|
|
415
383
|
function uninstallThinkingGroupingPatch(
|
|
416
384
|
state: ThinkingGroupingPatchState | undefined,
|
|
417
|
-
):
|
|
418
|
-
if (!state) return;
|
|
385
|
+
): boolean {
|
|
386
|
+
if (!state || state.owners <= 0) return false;
|
|
387
|
+
state.owners--;
|
|
388
|
+
if (state.owners > 0) return false;
|
|
419
389
|
const proto =
|
|
420
390
|
AssistantMessageComponent?.prototype as unknown as AssistantMessageRow & {
|
|
421
391
|
[THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
|
|
@@ -428,23 +398,44 @@ function uninstallThinkingGroupingPatch(
|
|
|
428
398
|
proto[THINKING_GROUPING_PATCHED] !== state ||
|
|
429
399
|
proto.updateContent !== state.patchedUpdateContent
|
|
430
400
|
) {
|
|
431
|
-
return;
|
|
401
|
+
return true;
|
|
432
402
|
}
|
|
433
403
|
proto.updateContent = state.originalUpdateContent;
|
|
434
404
|
delete proto[THINKING_GROUPING_PATCHED];
|
|
405
|
+
return true;
|
|
435
406
|
}
|
|
436
407
|
|
|
437
408
|
export default function (pi: ExtensionAPI) {
|
|
438
409
|
const patch = installThinkingGroupingPatch();
|
|
410
|
+
let released = false;
|
|
439
411
|
pi.on("session_start", (_event, ctx) => {
|
|
440
412
|
if (ctx.mode !== "tui") return;
|
|
413
|
+
try {
|
|
414
|
+
const proto = AssistantMessageComponent?.prototype as unknown as {
|
|
415
|
+
[THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
|
|
416
|
+
};
|
|
417
|
+
proto?.[THINKING_GROUPING_PATCHED]?.rows.clear();
|
|
418
|
+
} catch {
|
|
419
|
+
// Best effort; stale rows would only linger until the process exits.
|
|
420
|
+
}
|
|
441
421
|
activeTheme = ctx.ui.theme as unknown as ThemeDetail;
|
|
422
|
+
activeLevel = () => {
|
|
423
|
+
try {
|
|
424
|
+
return pi.getThinkingLevel();
|
|
425
|
+
} catch {
|
|
426
|
+
return "off";
|
|
427
|
+
}
|
|
428
|
+
};
|
|
442
429
|
updateThoughtLabelStyle();
|
|
443
430
|
});
|
|
431
|
+
pi.on("thinking_level_select", () => updateThoughtLabelStyle());
|
|
444
432
|
pi.on("session_shutdown", () => {
|
|
433
|
+
if (released) return;
|
|
434
|
+
released = true;
|
|
435
|
+
if (patch && !uninstallThinkingGroupingPatch(patch)) return;
|
|
445
436
|
activeTheme = undefined;
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
437
|
+
activeLevel = undefined;
|
|
438
|
+
liveLabelStyle = undefined;
|
|
439
|
+
settledLabelStyle = undefined;
|
|
449
440
|
});
|
|
450
441
|
}
|