@pi-kaush/pi-tool-call-markers 0.3.5 → 0.3.6
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 +56 -0
- package/README.md +12 -6
- package/package.json +1 -1
- package/src/bash-block.ts +22 -3
- package/src/container-hooks.ts +6 -0
- package/src/index.ts +101 -146
- package/src/info-visibility-state.ts +16 -2
- package/src/info-visibility.ts +15 -3
- package/src/muted.ts +25 -16
- package/src/thinking-block-merger.ts +141 -64
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,62 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- Read colors from the live palette instead of caching them against the
|
|
6
|
+
theme object. Pi swaps the palette behind a stable theme Proxy, so every
|
|
7
|
+
identity-keyed cache survived a theme switch: collapsed rows, the `+ Thought`
|
|
8
|
+
label, and user `!` blocks kept the old colors until their content changed.
|
|
9
|
+
Probes are map lookups, so the render caches now key on the resolved colors
|
|
10
|
+
(`paletteSample`, including the bash-block line cache) and the label
|
|
11
|
+
resolves its style per call.
|
|
12
|
+
|
|
13
|
+
- Stop grouping from resurrecting a tool that opted out of the transcript. A
|
|
14
|
+
self-rendered tool that draws no lines stays hidden whether it renders alone
|
|
15
|
+
or beside another call: the singleton and grouped paths share one
|
|
16
|
+
draws-nothing rule.
|
|
17
|
+
|
|
18
|
+
- Go inert at the final owner's shutdown. Every prototype wrapper — tool
|
|
19
|
+
presentation, container grouping, user bash blocks, thinking — delegates to
|
|
20
|
+
the original when another extension's wrapper has buried it and removal is
|
|
21
|
+
impossible, so collapsed rows and restyled labels cannot outlive their owner.
|
|
22
|
+
A later install re-enables the same wrappers.
|
|
23
|
+
|
|
24
|
+
- Roll back a container hook that mutates the child list and then throws.
|
|
25
|
+
Its edit used to stick, dropping those rows from every later render. Both
|
|
26
|
+
copies of the shared hook contract changed together.
|
|
27
|
+
|
|
28
|
+
- Own and release `/toggle-info` with the rest of the extension: the
|
|
29
|
+
process-global filter hook and the hidden flag now leave with the last owner
|
|
30
|
+
instead of filtering a transcript this package no longer renders.
|
|
31
|
+
|
|
32
|
+
- Track thinking rows for the `/toggle-info` replay instead of every assistant
|
|
33
|
+
row, capped at 400 entries, so a long session or an abandoned branch cannot
|
|
34
|
+
pin every message the transcript ever showed.
|
|
35
|
+
|
|
36
|
+
- Delete state nothing reads (the row→group map) and the duplicate
|
|
37
|
+
`session_start` handler that applied the collapsed default twice.
|
|
38
|
+
|
|
39
|
+
- Drop the colon after every collapsed-row anchor: glyph rows read
|
|
40
|
+
`│ ● src/a.ts` and bash rows `│ $ npm test`. The anchor runs bold with a
|
|
41
|
+
single space before the call text, so the anchor alone separates the two.
|
|
42
|
+
The whole seam — glyph map, `$` prompt, subagent fallback heading, and the
|
|
43
|
+
tool-token drop — now lives in one function that both the singleton and
|
|
44
|
+
grouped render paths call, and self-rendered labels no longer leak their
|
|
45
|
+
tool name into the row under themes that tag instead of color.
|
|
46
|
+
|
|
47
|
+
- Drop the italics Pi puts on thinking. The hidden `+ Thought` label renders
|
|
48
|
+
plain in every theme — the live spinner keeps its level tint, the settled
|
|
49
|
+
row its muted tone, and a theme that resolves no color still loses the
|
|
50
|
+
italics — and the visible trace stays italic only while it streams, then
|
|
51
|
+
renders as ordinary transcript text once the row settles. The live/settled
|
|
52
|
+
rule is shared with the label, so un-flagged row rebuilds (resize, theme
|
|
53
|
+
switch) keep a live trace italic and a finished one plain.
|
|
54
|
+
`PI_TOOL_CALL_MARKERS_THOUGHT_COLOR=inherit` remains the only opt-out that
|
|
55
|
+
keeps Pi's native italic styling.
|
|
56
|
+
|
|
57
|
+
- Fail open when a theme rejects a thinking token. Pi's Theme throws on
|
|
58
|
+
tokens it does not know, so the label probe now falls back to the plain
|
|
59
|
+
treatment instead of throwing out of the row's update pass.
|
|
60
|
+
|
|
5
61
|
- Restore Pi 0.85's click-to-expand under the custom collapsed rows. Pi routes
|
|
6
62
|
mouse clicks by per-child rendered line heights, and the grouping render
|
|
7
63
|
bypassed the native render that refreshes that cache, so clicks on anything
|
package/README.md
CHANGED
|
@@ -15,13 +15,13 @@ Restart Pi or run `/reload`.
|
|
|
15
15
|
Collapsed tool rows use semantic theme colors with no gear, background fill, box padding, or filled blank rows:
|
|
16
16
|
|
|
17
17
|
```text
|
|
18
|
-
│
|
|
19
|
-
│
|
|
20
|
-
│
|
|
18
|
+
│ ● src/a.ts 42 lines
|
|
19
|
+
│ ● src/b.ts 18 lines
|
|
20
|
+
│ $ npm test → done
|
|
21
21
|
```
|
|
22
22
|
|
|
23
23
|
- **Two-column outer inset.** Tool markers and image output align with an inset conversation surface. Very narrow terminals reduce the decoration before useful content.
|
|
24
|
-
- **`│` tool-call rail.** Every collapsed call anchors on a glyph instead of its tool name
|
|
24
|
+
- **`│` tool-call rail.** Every collapsed call anchors on a bold glyph instead of its tool name — `●` read, `+` write, `±` edit, `○` local search, `≡` ls, `↗` web search and fetch, and `$` bash; unmapped tools fall back to `*` — with a single space between the anchor and the call text, no joining colon. Grouped rows flow directly across tool types.
|
|
25
25
|
- **Semantic, low-contrast status.** The anchor and the call content share the collapsed mute, pending state is warning-colored, and failures remain error-colored. Ordinary tool states have no background.
|
|
26
26
|
- **Width-safe outcome tails.** Long summaries truncate before useful tails such as `→ done`, `→ 42 lines`, `→ +2/-1`, or a `bash` duration.
|
|
27
27
|
- **Stable running groups.** Adjacent calls group as they appear, including settled failures, which stay error-colored inside the group. Pending state and elapsed `bash` time settle into the final outcome without changing the row count.
|
|
@@ -78,7 +78,9 @@ When Pi exposes its per-row hidden-thinking and streaming fields, hidden reasoni
|
|
|
78
78
|
+ Thought · 2.5s
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
-
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`.
|
|
81
|
+
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`. There is no interval, timeout, render request, model call, or network work.
|
|
82
|
+
|
|
83
|
+
Pi renders thinking labels and traces italic. This package drops those italics: both labels read as plain collapsed rows (the live spinner keeps its thinking-level tint, the settled row the muted tone, and a theme that resolves no color still loses the italics), and a visible trace stays italic only while it streams, then settles into ordinary transcript text. `PI_TOOL_CALL_MARKERS_THOUGHT_COLOR=inherit` is the opt-out that keeps Pi's native italic `thinkingText` styling.
|
|
82
84
|
|
|
83
85
|
## Local development
|
|
84
86
|
|
|
@@ -131,10 +133,14 @@ Pi has no public hook for native tool rows, transcript grouping, or per-message
|
|
|
131
133
|
|
|
132
134
|
- `ToolExecutionComponent` for collapsed presentation;
|
|
133
135
|
- `Container` for adjacent grouping; and
|
|
134
|
-
- `AssistantMessageComponent.updateContent` for display-only thinking merging and
|
|
136
|
+
- `AssistantMessageComponent.updateContent` for display-only thinking merging, lifecycle labels, and the settled trace's italics.
|
|
135
137
|
|
|
136
138
|
Each adapter feature-detects the fields and methods it needs, keeps the original method, uses an idempotency symbol, catches cosmetic failures, and restores the original on `session_shutdown` when it still owns the patch. Unsupported shapes fail open to Pi's native rendering. The thinking adapter continues adjacent merging even when the private label shape is unavailable.
|
|
137
139
|
|
|
140
|
+
Teardown is owner-counted and inert: when another extension's wrapper sits above one of these, removal is impossible, so the buried wrapper delegates to the original instead of leaving collapsed rows or restyled labels behind after its owner shuts down. A later install re-enables it. The shared chat-container hook contract applies the same fail-open rule to hooks: a hook that throws after editing the child list has its edit rolled back, so a failed hook cannot drop rows from the transcript.
|
|
141
|
+
|
|
142
|
+
Colors are read live from the theme, so a mid-session theme switch repaints collapsed rows, labels, and `!` blocks; cached render output keys on the resolved palette rather than on the theme object, which Pi keeps stable across a switch.
|
|
143
|
+
|
|
138
144
|
Expanded tools always use Pi's native renderer. The collapsed tool shell owns its two-column inset directly; transcript layout extensions should leave tool rows unchanged, preventing load-order-dependent double padding.
|
|
139
145
|
|
|
140
146
|
> TODO: migrate these adapters to public Pi transcript and tool-rendering APIs when available.
|
package/package.json
CHANGED
package/src/bash-block.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { BashExecutionComponent } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { paletteSample } from "./muted.ts";
|
|
3
4
|
|
|
4
5
|
type ThemeLike = {
|
|
5
6
|
bold(text: string): string;
|
|
@@ -199,8 +200,15 @@ type BashRow = { status?: unknown };
|
|
|
199
200
|
|
|
200
201
|
type BashBlockPatchState = {
|
|
201
202
|
owners: number;
|
|
203
|
+
// Set at the final owner's shutdown. A wrapper another extension has buried
|
|
204
|
+
// cannot be uninstalled, so it delegates instead of outliving its owner; a
|
|
205
|
+
// later install re-enables it.
|
|
206
|
+
disabled?: boolean;
|
|
202
207
|
theme?: ThemeLike;
|
|
203
|
-
cache: WeakMap<
|
|
208
|
+
cache: WeakMap<
|
|
209
|
+
object,
|
|
210
|
+
{ width: number; src: string[]; lines: string[]; palette: string }
|
|
211
|
+
>;
|
|
204
212
|
originalRender: (width: number) => string[];
|
|
205
213
|
patchedRender?: (width: number) => string[];
|
|
206
214
|
};
|
|
@@ -213,6 +221,7 @@ type BashBlockPatchState = {
|
|
|
213
221
|
// TODO: Replace prototype patching with a public Pi rendering API when available.
|
|
214
222
|
function installBashBlockPatch(): BashBlockPatchState | undefined {
|
|
215
223
|
try {
|
|
224
|
+
console.error("BB new state");
|
|
216
225
|
const proto = BashExecutionComponent?.prototype as unknown as BashRow & {
|
|
217
226
|
[BASH_BLOCK_PATCHED]?: BashBlockPatchState;
|
|
218
227
|
render?: (width: number) => string[];
|
|
@@ -222,6 +231,8 @@ function installBashBlockPatch(): BashBlockPatchState | undefined {
|
|
|
222
231
|
const existing = proto[BASH_BLOCK_PATCHED];
|
|
223
232
|
if (existing) {
|
|
224
233
|
existing.owners++;
|
|
234
|
+
console.error("BB reuse owners", existing.owners);
|
|
235
|
+
existing.disabled = false;
|
|
225
236
|
return existing;
|
|
226
237
|
}
|
|
227
238
|
|
|
@@ -235,17 +246,24 @@ function installBashBlockPatch(): BashBlockPatchState | undefined {
|
|
|
235
246
|
width: number,
|
|
236
247
|
): string[] {
|
|
237
248
|
const theme = state.theme;
|
|
238
|
-
if (!theme)
|
|
249
|
+
if (state.disabled || !theme) {
|
|
250
|
+
return state.originalRender.call(this, width);
|
|
251
|
+
}
|
|
239
252
|
const inset = bashBlockInset(width);
|
|
240
253
|
const bodyWidth = width - inset * 2 - visibleWidth(PROMPT_RAIL);
|
|
241
254
|
if (inset === 0 || bodyWidth < 8) {
|
|
242
255
|
return state.originalRender.call(this, width);
|
|
243
256
|
}
|
|
244
257
|
const lines = state.originalRender.call(this, Math.max(1, bodyWidth));
|
|
258
|
+
// Decorated lines carry resolved colors, so the cache keys on the
|
|
259
|
+
// palette too: Pi swaps the colors behind a stable theme Proxy, which a
|
|
260
|
+
// theme-identity check would never notice.
|
|
261
|
+
const palette = paletteSample(theme);
|
|
245
262
|
const entry = state.cache.get(this);
|
|
246
263
|
if (
|
|
247
264
|
entry &&
|
|
248
265
|
entry.width === width &&
|
|
266
|
+
entry.palette === palette &&
|
|
249
267
|
entry.src.length === lines.length &&
|
|
250
268
|
entry.src.every((line, index) => line === lines[index])
|
|
251
269
|
) {
|
|
@@ -259,7 +277,7 @@ function installBashBlockPatch(): BashBlockPatchState | undefined {
|
|
|
259
277
|
typeof status === "string" ? status : undefined,
|
|
260
278
|
inset,
|
|
261
279
|
);
|
|
262
|
-
state.cache.set(this, { width, src: lines, lines: decorated });
|
|
280
|
+
state.cache.set(this, { width, src: lines, lines: decorated, palette });
|
|
263
281
|
return decorated;
|
|
264
282
|
};
|
|
265
283
|
|
|
@@ -285,6 +303,7 @@ function uninstallBashBlockPatch(state: BashBlockPatchState | undefined): void {
|
|
|
285
303
|
if (!state || state.owners <= 0) return;
|
|
286
304
|
state.owners--;
|
|
287
305
|
if (state.owners > 0) return;
|
|
306
|
+
state.disabled = true;
|
|
288
307
|
const proto = BashExecutionComponent?.prototype as unknown as BashRow & {
|
|
289
308
|
[BASH_BLOCK_PATCHED]?: BashBlockPatchState;
|
|
290
309
|
render?: (width: number) => string[];
|
package/src/container-hooks.ts
CHANGED
|
@@ -37,11 +37,17 @@ export function runChatContainerHooks(
|
|
|
37
37
|
const restores: Array<() => void> = [];
|
|
38
38
|
if (Array.isArray(children)) {
|
|
39
39
|
for (const hook of chatContainerHooks()) {
|
|
40
|
+
// A hook rewrites `children` and hands back its own undo. Snapshot first:
|
|
41
|
+
// a hook that throws after splicing rows out would otherwise leave them
|
|
42
|
+
// missing from every later render, with nothing left to restore them.
|
|
43
|
+
const before = [...children];
|
|
40
44
|
try {
|
|
41
45
|
const restore = hook(container, children, width);
|
|
42
46
|
if (typeof restore === "function") restores.push(restore);
|
|
43
47
|
} catch {
|
|
44
48
|
// Hooks are cosmetic; one failing hook must not break rendering.
|
|
49
|
+
children.length = 0;
|
|
50
|
+
children.push(...before);
|
|
45
51
|
}
|
|
46
52
|
}
|
|
47
53
|
}
|
package/src/index.ts
CHANGED
|
@@ -15,15 +15,19 @@ import {
|
|
|
15
15
|
uninstallBashBlockPatch,
|
|
16
16
|
} from "./bash-block.ts";
|
|
17
17
|
import { runChatContainerHooks } from "./container-hooks.ts";
|
|
18
|
-
import {
|
|
19
|
-
|
|
18
|
+
import {
|
|
19
|
+
installInfoVisibility,
|
|
20
|
+
uninstallInfoVisibility,
|
|
21
|
+
} from "./info-visibility.ts";
|
|
22
|
+
import { fgCollapsed, fgCollapsedRail, paletteSample } from "./muted.ts";
|
|
20
23
|
|
|
21
24
|
const OUTER_INSET = 2;
|
|
22
25
|
const SUBAGENT_MARKER = "↪";
|
|
23
26
|
const GROUP_CALL_MARKER = "│";
|
|
24
27
|
// Collapsed rows anchor with a glyph instead of repeating the tool name;
|
|
25
28
|
// tools that read as one family share a glyph, and unmapped tools fall back
|
|
26
|
-
// to `*`.
|
|
29
|
+
// to `*`. A single space separates the glyph from the call content: the
|
|
30
|
+
// anchor is punctuation enough without a joining colon.
|
|
27
31
|
const TOOL_CALL_GLYPHS: ReadonlyMap<string, string> = new Map([
|
|
28
32
|
["read", "●"],
|
|
29
33
|
["write", "+"],
|
|
@@ -87,13 +91,16 @@ type ToolExecutionRow = {
|
|
|
87
91
|
|
|
88
92
|
type PresentationPatchState = {
|
|
89
93
|
owners: number;
|
|
94
|
+
// Set at the final owner's shutdown. A wrapper that another extension has
|
|
95
|
+
// buried cannot be uninstalled, so it delegates instead of outliving its
|
|
96
|
+
// owner; a later install re-enables it.
|
|
97
|
+
disabled?: boolean;
|
|
90
98
|
theme?: ThemeLike;
|
|
91
99
|
collapseParallel: boolean;
|
|
92
100
|
expandedTools: ReadonlySet<string>;
|
|
93
101
|
groupCache: WeakMap<ToolExecutionRow, GroupRenderCache>;
|
|
94
102
|
collapsedCache: WeakMap<ToolExecutionRow, CollapsedRenderCache>;
|
|
95
103
|
rowVersions: WeakMap<ToolExecutionRow, number>;
|
|
96
|
-
rowGroups: WeakMap<ToolExecutionRow, ToolExecutionRow[]>;
|
|
97
104
|
originalRender: (width: number) => string[];
|
|
98
105
|
originalUpdateDisplay: () => void;
|
|
99
106
|
originalHandleMouse?: (this: ToolExecutionRow, event: unknown) => unknown;
|
|
@@ -182,22 +189,12 @@ function rowSignatureOf(row: ToolExecutionRow): string {
|
|
|
182
189
|
return `${row.isPartial}|${row.expanded}|${row.result ? 1 : 0}|${row.result?.isError ? 1 : 0}`;
|
|
183
190
|
}
|
|
184
191
|
|
|
185
|
-
//
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
|
|
192
|
+
// Render caches hold lines with resolved colors baked in, so they key on the
|
|
193
|
+
// palette's values rather than the theme object: Pi swaps the colors behind a
|
|
194
|
+
// stable Proxy, which made an identity key survive a theme switch. Probing is
|
|
195
|
+
// a handful of map lookups per row per frame.
|
|
189
196
|
function themeSampleFor(theme: ThemeLike): string {
|
|
190
|
-
|
|
191
|
-
if (!sample) {
|
|
192
|
-
sample =
|
|
193
|
-
theme.fg("toolTitle", "x") +
|
|
194
|
-
theme.fg("muted", "x") +
|
|
195
|
-
theme.fg("dim", "x") +
|
|
196
|
-
theme.fg("warning", "x") +
|
|
197
|
-
theme.fg("error", "x");
|
|
198
|
-
themeSamples.set(theme, sample);
|
|
199
|
-
}
|
|
200
|
-
return sample;
|
|
197
|
+
return paletteSample(theme);
|
|
201
198
|
}
|
|
202
199
|
|
|
203
200
|
type InsetLayout = {
|
|
@@ -461,10 +458,6 @@ function selfRenderedCallLabel(row: ToolExecutionRow): string {
|
|
|
461
458
|
return title ?? token;
|
|
462
459
|
}
|
|
463
460
|
|
|
464
|
-
function selfRenderedSummary(row: ToolExecutionRow, width: number): string {
|
|
465
|
-
return fitSummary(selfRenderedCallLabel(row), width);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
461
|
function isToolExecutionRow(
|
|
469
462
|
component: unknown,
|
|
470
463
|
): component is ToolExecutionRow & ComponentLike {
|
|
@@ -558,6 +551,16 @@ function hasToolSiblingInAssistantBatch(
|
|
|
558
551
|
return false;
|
|
559
552
|
}
|
|
560
553
|
|
|
554
|
+
// A self-rendered tool opts out of the transcript by drawing no lines. The
|
|
555
|
+
// singleton and grouped paths must agree on that, or grouping resurrects a
|
|
556
|
+
// row the tool deliberately hid.
|
|
557
|
+
function drawsNoRowLines(
|
|
558
|
+
row: ToolExecutionRow,
|
|
559
|
+
nativeLines: string[],
|
|
560
|
+
): boolean {
|
|
561
|
+
return nativeLines.length === 0 && (row.imageComponents?.length ?? 0) === 0;
|
|
562
|
+
}
|
|
563
|
+
|
|
561
564
|
function isGroupableToolRow(
|
|
562
565
|
row: ToolExecutionRow,
|
|
563
566
|
children: unknown[],
|
|
@@ -566,6 +569,7 @@ function isGroupableToolRow(
|
|
|
566
569
|
state: PresentationPatchState,
|
|
567
570
|
): boolean {
|
|
568
571
|
if (row.toolName === "subagent") return false;
|
|
572
|
+
if (drawsNoRowLines(row, renderAt(index))) return false;
|
|
569
573
|
if (
|
|
570
574
|
!isLiveRow(row) &&
|
|
571
575
|
!isCollapsibleSuccess(row) &&
|
|
@@ -796,11 +800,6 @@ function renderedCallSummary(
|
|
|
796
800
|
width: number,
|
|
797
801
|
theme: ThemeLike,
|
|
798
802
|
): string {
|
|
799
|
-
// Self-rendered call components change shape across the live/settled
|
|
800
|
-
// boundary (the adapter's call disappears once settled), so always build
|
|
801
|
-
// the summary from the stable args label instead of scraping the preview.
|
|
802
|
-
if (row.getRenderShell?.() === "self") return selfRenderedSummary(row, width);
|
|
803
|
-
|
|
804
803
|
let component = row.callRendererComponent;
|
|
805
804
|
if (!component && Array.isArray(row.contentBox?.children)) {
|
|
806
805
|
component = row.contentBox.children[0] as ComponentLike | undefined;
|
|
@@ -851,56 +850,60 @@ function renderedCallSummary(
|
|
|
851
850
|
: fgCollapsed(theme, "muted", "(no arguments)");
|
|
852
851
|
}
|
|
853
852
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
const match = /^(\S+)(.*)$/s.exec(plain);
|
|
861
|
-
if (!match) return fgCollapsed(theme, color, plain);
|
|
862
|
-
const rest = match[2] ?? "";
|
|
863
|
-
// The colon joins the bolded name to its content. Failed rows stay
|
|
864
|
-
// uniformly error-colored; settled rows use the collapsed mute — same
|
|
865
|
-
// color for the bolded name and the call content.
|
|
866
|
-
return (
|
|
867
|
-
fgCollapsed(theme, color, match[1] ?? "", true) +
|
|
868
|
-
(rest ? fgCollapsed(theme, color, `:${rest}`) : "")
|
|
869
|
-
);
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
function collapsedCallLabel(
|
|
853
|
+
// The collapsed-row seam lives here and nowhere else: which anchor a row
|
|
854
|
+
// takes, how that anchor is styled, and the call text that follows it.
|
|
855
|
+
// Anchors always render bold and take no joining colon — `│ ● src/a.ts`,
|
|
856
|
+
// `│ $ npm test` — and subagent fallbacks keep their `↪ subagent …`
|
|
857
|
+
// reading instead of a glyph. Callers append the outcome tail.
|
|
858
|
+
function collapsedSeam(
|
|
873
859
|
row: ToolExecutionRow,
|
|
874
860
|
width: number,
|
|
875
861
|
theme: ThemeLike,
|
|
876
|
-
color
|
|
877
|
-
): string {
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
const
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
862
|
+
color: string,
|
|
863
|
+
): { anchor: string; content: string } {
|
|
864
|
+
const isBash = row.toolName === "bash";
|
|
865
|
+
const isSubagent = row.toolName === "subagent";
|
|
866
|
+
const glyph = isBash
|
|
867
|
+
? "$"
|
|
868
|
+
: (TOOL_CALL_GLYPHS.get(row.toolName ?? "") ?? "*");
|
|
869
|
+
const anchor = isSubagent
|
|
870
|
+
? `${fgCollapsed(theme, color, SUBAGENT_MARKER, true)} `
|
|
871
|
+
: `${fgCollapsedRail(theme, GROUP_CALL_MARKER)} ${fgCollapsed(
|
|
872
|
+
theme,
|
|
873
|
+
color,
|
|
874
|
+
glyph,
|
|
875
|
+
true,
|
|
876
|
+
)} `;
|
|
877
|
+
const budget = Math.max(1, width - visibleWidth(anchor));
|
|
878
|
+
// Self-rendered rows build their own `tool {args}` label — Pi's call title
|
|
879
|
+
// is gone once settled — while ordinary rows scrape the rendered call
|
|
880
|
+
// line, which already drops its heading token.
|
|
881
|
+
const selfRendered = row.getRenderShell?.() === "self";
|
|
882
|
+
const source = selfRendered
|
|
883
|
+
? selfRenderedCallLabel(row)
|
|
884
|
+
: renderedCallSummary(row, budget, theme);
|
|
885
|
+
const plain = sanitizeInline(stripAnsi(source)).trim();
|
|
886
|
+
// The anchor stands in for the label's leading token. Only self-rendered
|
|
887
|
+
// labels still carry it; a subagent fallback keeps `subagent` as its own
|
|
888
|
+
// heading, which the scrape dropped.
|
|
889
|
+
const text = isSubagent
|
|
890
|
+
? `subagent${plain ? ` ${plain}` : ""}`
|
|
891
|
+
: selfRendered
|
|
892
|
+
? dropCallToken(plain, row.toolName ?? "tool")
|
|
893
|
+
: plain;
|
|
894
|
+
return {
|
|
895
|
+
anchor,
|
|
896
|
+
content: fgCollapsed(theme, color, text || "(no arguments)"),
|
|
897
|
+
};
|
|
887
898
|
}
|
|
888
899
|
|
|
889
|
-
//
|
|
890
|
-
// the
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
color: string,
|
|
897
|
-
): string {
|
|
898
|
-
if (row.toolName === "bash" || row.toolName === "subagent") return "";
|
|
899
|
-
return fgCollapsed(
|
|
900
|
-
theme,
|
|
901
|
-
color,
|
|
902
|
-
`${TOOL_CALL_GLYPHS.get(row.toolName ?? "") ?? "*"}: `,
|
|
903
|
-
);
|
|
900
|
+
// Drops the leading token an anchor now stands for, with whichever separator
|
|
901
|
+
// the label joined it with.
|
|
902
|
+
function dropCallToken(plain: string, token: string): string {
|
|
903
|
+
if (!plain.startsWith(token)) return plain;
|
|
904
|
+
const rest = plain.slice(token.length);
|
|
905
|
+
if (!/^:?\s|^:$/.test(rest)) return plain;
|
|
906
|
+
return rest.replace(/^:?\s*/, "");
|
|
904
907
|
}
|
|
905
908
|
|
|
906
909
|
function collapsedOutcome(
|
|
@@ -927,31 +930,28 @@ function collapsedOutcome(
|
|
|
927
930
|
return renderedOutcome(row, theme);
|
|
928
931
|
}
|
|
929
932
|
|
|
930
|
-
|
|
933
|
+
// One collapsed row — head and outcome tail — at the given width. Singleton
|
|
934
|
+
// and grouped rows differ only in what surrounds the line.
|
|
935
|
+
function collapsedRowLine(
|
|
931
936
|
row: ToolExecutionRow,
|
|
932
937
|
width: number,
|
|
933
938
|
theme: ThemeLike,
|
|
934
939
|
): string {
|
|
935
940
|
// Failed rows render entirely in error so the row reads as the one that
|
|
936
941
|
// failed, not just its outcome tail. Only the row's anchor stays bold.
|
|
937
|
-
const
|
|
938
|
-
const
|
|
939
|
-
|
|
940
|
-
? `${fgCollapsed(theme, tone, SUBAGENT_MARKER)} `
|
|
941
|
-
: `${fgCollapsedRail(theme, GROUP_CALL_MARKER)} `;
|
|
942
|
-
const glyph = collapsedCallMarker(row, theme, tone);
|
|
943
|
-
const budget = Math.max(1, width - visibleWidth(marker));
|
|
944
|
-
const label = glyph + collapsedChildLabel(row, budget, theme, tone);
|
|
942
|
+
const color = rowHasFailed(row) ? "error" : "muted";
|
|
943
|
+
const seam = collapsedSeam(row, width, theme, color);
|
|
944
|
+
const budget = Math.max(1, width - visibleWidth(seam.anchor));
|
|
945
945
|
const outcome = collapsedOutcome(row, budget, theme);
|
|
946
946
|
// The truncation suffix inherits the row tone; pi-tui's truncation resets
|
|
947
947
|
// around a plain suffix, which would render it in the terminal default
|
|
948
948
|
// foreground instead of the row color.
|
|
949
|
-
const suffix = fgCollapsed(theme,
|
|
949
|
+
const suffix = fgCollapsed(theme, color, "…");
|
|
950
950
|
return (
|
|
951
|
-
|
|
951
|
+
seam.anchor +
|
|
952
952
|
(outcome
|
|
953
|
-
? fitSummaryTail(
|
|
954
|
-
: fitSummary(
|
|
953
|
+
? fitSummaryTail(seam.content, outcome, budget, suffix)
|
|
954
|
+
: fitSummary(seam.content, budget, suffix))
|
|
955
955
|
);
|
|
956
956
|
}
|
|
957
957
|
|
|
@@ -1251,40 +1251,11 @@ function renderCollapsedToolRow(
|
|
|
1251
1251
|
row.toolName === "subagent"
|
|
1252
1252
|
? renderSubagentPlan(row, layout.contentWidth, theme)
|
|
1253
1253
|
: undefined;
|
|
1254
|
-
const body = plan ?? [
|
|
1254
|
+
const body = plan ?? [collapsedRowLine(row, layout.contentWidth, theme)];
|
|
1255
1255
|
body.push(...imageResultLines(row, layout.contentWidth, theme));
|
|
1256
1256
|
return ["", ...insetLines(body, width)];
|
|
1257
1257
|
}
|
|
1258
1258
|
|
|
1259
|
-
// Drops the call label's tool-name token: the row's glyph anchors the call
|
|
1260
|
-
// instead. The `↪` subagent fallback keeps its own heading.
|
|
1261
|
-
function collapsedChildLabel(
|
|
1262
|
-
row: ToolExecutionRow,
|
|
1263
|
-
width: number,
|
|
1264
|
-
theme: ThemeLike,
|
|
1265
|
-
color: string = "muted",
|
|
1266
|
-
): string {
|
|
1267
|
-
const label = collapsedCallLabel(row, width, theme, color);
|
|
1268
|
-
if (row.toolName === "subagent") return label;
|
|
1269
|
-
const plain = stripAnsi(label);
|
|
1270
|
-
const toolName = row.toolName ?? "tool";
|
|
1271
|
-
let prefix = "";
|
|
1272
|
-
if (plain === toolName) {
|
|
1273
|
-
prefix = toolName;
|
|
1274
|
-
} else if (plain.startsWith(`${toolName}:`)) {
|
|
1275
|
-
const whitespace = /^\s*/.exec(plain.slice(toolName.length + 1))?.[0] ?? "";
|
|
1276
|
-
prefix = `${toolName}:${whitespace}`;
|
|
1277
|
-
}
|
|
1278
|
-
const prefixWidth = visibleWidth(prefix);
|
|
1279
|
-
const child = sliceByColumn(
|
|
1280
|
-
label,
|
|
1281
|
-
prefixWidth,
|
|
1282
|
-
Math.max(0, visibleWidth(label) - prefixWidth),
|
|
1283
|
-
true,
|
|
1284
|
-
);
|
|
1285
|
-
return child || fgCollapsed(theme, color, "(no arguments)");
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
1259
|
function renderGroupedCallLines(
|
|
1289
1260
|
rows: ToolExecutionRow[],
|
|
1290
1261
|
width: number,
|
|
@@ -1292,26 +1263,7 @@ function renderGroupedCallLines(
|
|
|
1292
1263
|
): Array<{ row: ToolExecutionRow; lines: string[] }> {
|
|
1293
1264
|
const segments: Array<{ row: ToolExecutionRow; lines: string[] }> = [];
|
|
1294
1265
|
for (const row of rows) {
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
const color = rowHasFailed(row) ? "error" : "muted";
|
|
1298
|
-
const prefix = `${fgCollapsedRail(theme, GROUP_CALL_MARKER)} `;
|
|
1299
|
-
const marker = collapsedCallMarker(row, theme, color);
|
|
1300
|
-
const budget = Math.max(
|
|
1301
|
-
1,
|
|
1302
|
-
width - visibleWidth(prefix) - visibleWidth(marker),
|
|
1303
|
-
);
|
|
1304
|
-
const label = collapsedChildLabel(row, budget, theme, color);
|
|
1305
|
-
const outcome = collapsedOutcome(row, budget, theme);
|
|
1306
|
-
const suffix = fgCollapsed(theme, color, "…");
|
|
1307
|
-
lines.push(
|
|
1308
|
-
prefix +
|
|
1309
|
-
marker +
|
|
1310
|
-
(outcome
|
|
1311
|
-
? fitSummaryTail(label, outcome, budget, suffix)
|
|
1312
|
-
: fitSummary(label, budget, suffix)),
|
|
1313
|
-
);
|
|
1314
|
-
segments.push({ row, lines });
|
|
1266
|
+
segments.push({ row, lines: [collapsedRowLine(row, width, theme)] });
|
|
1315
1267
|
}
|
|
1316
1268
|
return segments;
|
|
1317
1269
|
}
|
|
@@ -1471,7 +1423,6 @@ function renderContainerWithToolGroups(
|
|
|
1471
1423
|
continue;
|
|
1472
1424
|
}
|
|
1473
1425
|
|
|
1474
|
-
for (const member of group) presentation.rowGroups.set(member, group);
|
|
1475
1426
|
// Use a live member while any call is pending, then the first member once
|
|
1476
1427
|
// settled. Either way, the grouped row keeps the same number of lines.
|
|
1477
1428
|
const shellRow = group.find(isLiveRow) ?? child;
|
|
@@ -1592,6 +1543,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
|
|
|
1592
1543
|
const existing = proto[PRESENTATION_PATCHED];
|
|
1593
1544
|
if (existing) {
|
|
1594
1545
|
existing.owners++;
|
|
1546
|
+
existing.disabled = false;
|
|
1595
1547
|
return existing;
|
|
1596
1548
|
}
|
|
1597
1549
|
const legacy = proto[LEGACY_PRESENTATION_PATCHED];
|
|
@@ -1612,13 +1564,16 @@ function installPresentationPatch(): PresentationPatchState | undefined {
|
|
|
1612
1564
|
groupCache: new WeakMap(),
|
|
1613
1565
|
collapsedCache: new WeakMap(),
|
|
1614
1566
|
rowVersions: new WeakMap(),
|
|
1615
|
-
rowGroups: new WeakMap(),
|
|
1616
1567
|
originalRender: proto.render,
|
|
1617
1568
|
originalUpdateDisplay: proto.updateDisplay,
|
|
1618
1569
|
};
|
|
1619
1570
|
const patchedUpdateDisplay = function updateDisplayWithToolPresentation(
|
|
1620
1571
|
this: ToolExecutionRow,
|
|
1621
1572
|
): void {
|
|
1573
|
+
if (state.disabled) {
|
|
1574
|
+
state.originalUpdateDisplay.call(this);
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1622
1577
|
// Transcript rerenders reuse settled caches, but a real component
|
|
1623
1578
|
// display update can change args, results, renderer output, or images
|
|
1624
1579
|
// without changing the row's shape signature.
|
|
@@ -1651,6 +1606,9 @@ function installPresentationPatch(): PresentationPatchState | undefined {
|
|
|
1651
1606
|
event: unknown,
|
|
1652
1607
|
): unknown {
|
|
1653
1608
|
const mouseEvent = event as { type?: unknown; button?: unknown };
|
|
1609
|
+
if (state.disabled) {
|
|
1610
|
+
return state.originalHandleMouse?.call(this, event);
|
|
1611
|
+
}
|
|
1654
1612
|
if (
|
|
1655
1613
|
state.theme &&
|
|
1656
1614
|
this.expanded === false &&
|
|
@@ -1672,6 +1630,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
|
|
|
1672
1630
|
): string[] {
|
|
1673
1631
|
const theme = state.theme;
|
|
1674
1632
|
if (
|
|
1633
|
+
state.disabled ||
|
|
1675
1634
|
typeof this.expanded !== "boolean" ||
|
|
1676
1635
|
typeof this.isPartial !== "boolean" ||
|
|
1677
1636
|
this.expanded ||
|
|
@@ -1709,7 +1668,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
|
|
|
1709
1668
|
return cached.lines;
|
|
1710
1669
|
}
|
|
1711
1670
|
const lines = state.originalRender.call(this, width);
|
|
1712
|
-
if (
|
|
1671
|
+
if (drawsNoRowLines(this, lines)) {
|
|
1713
1672
|
return lines;
|
|
1714
1673
|
}
|
|
1715
1674
|
try {
|
|
@@ -1730,7 +1689,7 @@ function installPresentationPatch(): PresentationPatchState | undefined {
|
|
|
1730
1689
|
// Partial (streaming) rows keep the collapsed presentation but are
|
|
1731
1690
|
// never cached: their content changes with every streamed chunk.
|
|
1732
1691
|
const lines = state.originalRender.call(this, width);
|
|
1733
|
-
if (
|
|
1692
|
+
if (drawsNoRowLines(this, lines)) {
|
|
1734
1693
|
return lines;
|
|
1735
1694
|
}
|
|
1736
1695
|
try {
|
|
@@ -1767,6 +1726,7 @@ function uninstallPresentationPatch(
|
|
|
1767
1726
|
if (!state || state.owners <= 0) return;
|
|
1768
1727
|
state.owners--;
|
|
1769
1728
|
if (state.owners > 0) return;
|
|
1729
|
+
state.disabled = true;
|
|
1770
1730
|
const proto =
|
|
1771
1731
|
ToolExecutionComponent?.prototype as unknown as ToolExecutionRow & {
|
|
1772
1732
|
[PRESENTATION_PATCHED]?: PresentationPatchState;
|
|
@@ -1817,17 +1777,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
1817
1777
|
ctx.ui.setToolsExpanded(false);
|
|
1818
1778
|
});
|
|
1819
1779
|
|
|
1820
|
-
pi.on("session_start", (_event, ctx) => {
|
|
1821
|
-
if (patch) patch.theme = ctx.ui.theme;
|
|
1822
|
-
if (bashPatch) bashPatch.theme = ctx.ui.theme;
|
|
1823
|
-
ctx.ui.setToolsExpanded(false);
|
|
1824
|
-
});
|
|
1825
|
-
|
|
1826
1780
|
pi.on("session_shutdown", () => {
|
|
1827
1781
|
if (released) return;
|
|
1828
1782
|
released = true;
|
|
1829
1783
|
uninstallGroupingPatch(grouping);
|
|
1830
1784
|
uninstallPresentationPatch(patch);
|
|
1831
1785
|
uninstallBashBlockPatch(bashPatch);
|
|
1786
|
+
uninstallInfoVisibility();
|
|
1832
1787
|
});
|
|
1833
1788
|
}
|
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
// duplicated between the main entrypoint (the /toggle-info command) and the
|
|
5
5
|
// thinking-block-merger entrypoint (the strip). globalThis keeps it singular.
|
|
6
6
|
|
|
7
|
-
type InfoVisibilityState = { hidden: boolean };
|
|
7
|
+
type InfoVisibilityState = { hidden: boolean; owners: number };
|
|
8
8
|
|
|
9
9
|
const STATE_KEY = Symbol.for("kg.pi.toolCallMarkers.infoVisibility.v1");
|
|
10
10
|
|
|
11
11
|
function infoVisibilityState(): InfoVisibilityState {
|
|
12
12
|
const root = globalThis as Record<symbol, InfoVisibilityState | undefined>;
|
|
13
|
-
return (root[STATE_KEY] ??= { hidden: false });
|
|
13
|
+
return (root[STATE_KEY] ??= { hidden: false, owners: 0 });
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export function infoVisibilityHidden(): boolean {
|
|
@@ -20,3 +20,17 @@ export function infoVisibilityHidden(): boolean {
|
|
|
20
20
|
export function setInfoVisibilityHidden(hidden: boolean): void {
|
|
21
21
|
infoVisibilityState().hidden = hidden;
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
// The filter hook lives in a process-global registry, so it is owned like the
|
|
25
|
+
// prototype patches: it outlives every install that loaded this module and
|
|
26
|
+
// disappears with the last one.
|
|
27
|
+
export function retainInfoVisibilityOwner(): void {
|
|
28
|
+
infoVisibilityState().owners += 1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Returns the owners left after this release.
|
|
32
|
+
export function releaseInfoVisibilityOwner(): number {
|
|
33
|
+
const state = infoVisibilityState();
|
|
34
|
+
state.owners = Math.max(0, state.owners - 1);
|
|
35
|
+
return state.owners;
|
|
36
|
+
}
|
package/src/info-visibility.ts
CHANGED
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
import { chatContainerHooks } from "./container-hooks.ts";
|
|
7
7
|
import {
|
|
8
8
|
infoVisibilityHidden,
|
|
9
|
+
releaseInfoVisibilityOwner,
|
|
10
|
+
retainInfoVisibilityOwner,
|
|
9
11
|
setInfoVisibilityHidden,
|
|
10
12
|
} from "./info-visibility-state.ts";
|
|
11
13
|
import { refreshThinkingVisibility } from "./thinking-block-merger.ts";
|
|
@@ -42,6 +44,7 @@ function infoFilterHook(
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
export function installInfoVisibility(pi: ExtensionAPI): void {
|
|
47
|
+
retainInfoVisibilityOwner();
|
|
45
48
|
// The hook registry is process-global; dedupe by name so hot reloads swap
|
|
46
49
|
// rather than stack.
|
|
47
50
|
const hooks = chatContainerHooks();
|
|
@@ -67,8 +70,17 @@ export function installInfoVisibility(pi: ExtensionAPI): void {
|
|
|
67
70
|
},
|
|
68
71
|
});
|
|
69
72
|
|
|
70
|
-
// Every session starts in the default collapsed-but-visible state.
|
|
71
|
-
// shutdown reset: extra shutdown handlers would sit between this package's
|
|
72
|
-
// owner-counted patch releases.
|
|
73
|
+
// Every session starts in the default collapsed-but-visible state.
|
|
73
74
|
pi.on("session_start", () => setInfoVisibilityHidden(false));
|
|
74
75
|
}
|
|
76
|
+
|
|
77
|
+
// Released from the entrypoint's own shutdown handler, so a session still
|
|
78
|
+
// tears down with one handler per install. The registry and the toggle are
|
|
79
|
+
// process-global: while any owner remains, the hook stays; the last owner
|
|
80
|
+
// removes it, so execution rows cannot keep disappearing from a transcript
|
|
81
|
+
// this package no longer renders.
|
|
82
|
+
export function uninstallInfoVisibility(): void {
|
|
83
|
+
if (releaseInfoVisibilityOwner() > 0) return;
|
|
84
|
+
chatContainerHooks().delete(infoFilterHook);
|
|
85
|
+
setInfoVisibilityHidden(false);
|
|
86
|
+
}
|
package/src/muted.ts
CHANGED
|
@@ -19,27 +19,36 @@ type CollapsedTheme = {
|
|
|
19
19
|
getFgAnsi?(color: string): string;
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
22
|
+
// Probed live, never cached: Pi swaps the colors behind a stable theme
|
|
23
|
+
// Proxy, so a value cached against the theme object outlives a theme switch.
|
|
24
|
+
// A probe is one map lookup, and the Theme throws on tokens it does not know
|
|
25
|
+
// (themes without getFgAnsi have no overrides at all).
|
|
27
26
|
function tokenAnsi(theme: CollapsedTheme, token: string): string | null {
|
|
28
|
-
let cache = ansiCache.get(theme);
|
|
29
|
-
if (!cache) {
|
|
30
|
-
cache = new Map();
|
|
31
|
-
ansiCache.set(theme, cache);
|
|
32
|
-
}
|
|
33
|
-
if (cache.has(token)) return cache.get(token)!;
|
|
34
|
-
let ansi: string | null = null;
|
|
35
27
|
try {
|
|
36
28
|
const resolved = theme.getFgAnsi?.(token);
|
|
37
|
-
|
|
29
|
+
return typeof resolved === "string" ? resolved : null;
|
|
38
30
|
} catch {
|
|
39
|
-
|
|
31
|
+
return null;
|
|
40
32
|
}
|
|
41
|
-
|
|
42
|
-
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The tokens this package resolves from a palette.
|
|
36
|
+
const PALETTE_TOKENS = [
|
|
37
|
+
TOOL_OVERRIDE_TOKEN,
|
|
38
|
+
THINKING_OVERRIDE_TOKEN,
|
|
39
|
+
DEFAULT_TOKEN,
|
|
40
|
+
"muted",
|
|
41
|
+
"toolOutput",
|
|
42
|
+
"accent",
|
|
43
|
+
"warning",
|
|
44
|
+
"error",
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
// Revision key for rendered output that bakes resolved colors in. Compares
|
|
48
|
+
// the colors themselves, so a mid-session theme switch is visible to every
|
|
49
|
+
// cache that keys on it.
|
|
50
|
+
export function paletteSample(theme: CollapsedTheme): string {
|
|
51
|
+
return PALETTE_TOKENS.map((token) => tokenAnsi(theme, token) ?? "").join(",");
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
// Override token when defined, else the theme's syntaxComment color, else
|
|
@@ -49,56 +49,60 @@ type ThemeDetail = {
|
|
|
49
49
|
// styles current across mid-session theme switches, which fire no event.
|
|
50
50
|
let themeProvider: (() => ThemeDetail | undefined) | undefined;
|
|
51
51
|
let activeLevel: (() => string) | undefined;
|
|
52
|
-
//
|
|
53
|
-
|
|
52
|
+
// Pi builds both thinking labels italic. The default treatment replaces that
|
|
53
|
+
// with a plain row: italic-off always leads — the TUI diff renderer can skip
|
|
54
|
+
// bytes shared with the previous italic frame, which would leave the terminal
|
|
55
|
+
// italic — plus the resolved color when the theme supplies one. The `inherit`
|
|
56
|
+
// variant is the only choice that keeps Pi's native italic styling.
|
|
57
|
+
const ITALIC_OFF = "\x1b[23m";
|
|
54
58
|
|
|
55
59
|
type LabelStyle = { prefix: string; suffix: string };
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
|
|
61
|
+
// The package styles labels only inside a session it can read the theme from.
|
|
62
|
+
// `inherit` is the other native case: it keeps Pi's italic labels.
|
|
63
|
+
function stylesThinkingLabels(): boolean {
|
|
64
|
+
return themeProvider !== undefined && thoughtLabelColorChoice() !== "inherit";
|
|
65
|
+
}
|
|
58
66
|
|
|
59
67
|
function styledWith(
|
|
60
68
|
token: string,
|
|
61
|
-
theme: ThemeDetail | undefined
|
|
69
|
+
theme: ThemeDetail | undefined,
|
|
62
70
|
): LabelStyle | undefined {
|
|
63
|
-
|
|
71
|
+
// Pi's Theme throws on a token it does not know, so probe the way muted.ts
|
|
72
|
+
// does: an unresolvable color costs the label its tint, never the row.
|
|
73
|
+
let ansi: string | undefined;
|
|
74
|
+
try {
|
|
75
|
+
const resolved = theme?.getFgAnsi?.(token);
|
|
76
|
+
if (typeof resolved === "string") ansi = resolved;
|
|
77
|
+
} catch {
|
|
78
|
+
ansi = undefined;
|
|
79
|
+
}
|
|
64
80
|
if (!ansi) return undefined;
|
|
65
|
-
|
|
66
|
-
// restyleHiddenThinkingLabel). The leading italic-off still matters: the
|
|
67
|
-
// TUI's diff renderer can skip bytes shared with the previously drawn
|
|
68
|
-
// italic line, leaving the terminal in italic state otherwise.
|
|
69
|
-
return { prefix: `\x1b[23m${ansi}`, suffix: "\x1b[39m" };
|
|
81
|
+
return { prefix: `${ITALIC_OFF}${ansi}`, suffix: "\x1b[39m" };
|
|
70
82
|
}
|
|
71
83
|
|
|
72
84
|
// The theme's collapsedThinkingCall override when defined, else its comment
|
|
73
85
|
// color, else the muted token.
|
|
74
|
-
function settledMutedStyle(
|
|
75
|
-
|
|
76
|
-
):
|
|
77
|
-
const ansi = theme ? collapsedThinkingAnsi(theme) : null;
|
|
78
|
-
if (ansi) return { prefix: `\x1b[23m${ansi}`, suffix: "\x1b[39m" };
|
|
86
|
+
function settledMutedStyle(theme: ThemeDetail): LabelStyle | undefined {
|
|
87
|
+
const ansi = collapsedThinkingAnsi(theme);
|
|
88
|
+
if (ansi) return { prefix: `${ITALIC_OFF}${ansi}`, suffix: "\x1b[39m" };
|
|
79
89
|
return styledWith("muted", theme);
|
|
80
90
|
}
|
|
81
|
-
function updateThoughtLabelStyle(): void {
|
|
82
|
-
liveLabelStyle = undefined;
|
|
83
|
-
settledLabelStyle = undefined;
|
|
84
|
-
const theme = themeProvider?.();
|
|
85
|
-
stylesTheme = theme;
|
|
86
|
-
const choice = thoughtLabelColorChoice();
|
|
87
|
-
if (choice === "inherit" || !theme) return;
|
|
88
91
|
|
|
92
|
+
// Resolved per label, never cached: Pi swaps the colors behind a stable theme
|
|
93
|
+
// Proxy, so a cached style would survive a theme switch and repaint the
|
|
94
|
+
// transcript in the old palette.
|
|
95
|
+
function labelStyle(
|
|
96
|
+
settled: boolean,
|
|
97
|
+
theme: ThemeDetail,
|
|
98
|
+
): LabelStyle | undefined {
|
|
99
|
+
const choice = thoughtLabelColorChoice();
|
|
100
|
+
if (settled && choice !== "mdheading") return settledMutedStyle(theme);
|
|
89
101
|
const token =
|
|
90
102
|
choice === "mdheading"
|
|
91
103
|
? "mdHeading"
|
|
92
104
|
: (LEVEL_TOKEN[activeLevel?.() ?? "off"] ?? "thinkingOff");
|
|
93
|
-
|
|
94
|
-
settledLabelStyle =
|
|
95
|
-
choice === "mdheading" ? liveLabelStyle : settledMutedStyle(theme);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Recompute label styles whenever the theme object has swapped (theme switch
|
|
99
|
-
// or /reload); those transitions fire no extension event.
|
|
100
|
-
function ensureLabelStyles(): void {
|
|
101
|
-
if (themeProvider?.() !== stylesTheme) updateThoughtLabelStyle();
|
|
105
|
+
return styledWith(token, theme);
|
|
102
106
|
}
|
|
103
107
|
|
|
104
108
|
// The settled label is the finalized "+ Thought" row; every other label is
|
|
@@ -108,13 +112,14 @@ function isSettledThoughtLabel(label: string): boolean {
|
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
export function visibleThoughtLabel(label: string): string {
|
|
111
|
-
|
|
112
|
-
const style = isSettledThoughtLabel(label)
|
|
113
|
-
? settledLabelStyle
|
|
114
|
-
: liveLabelStyle;
|
|
115
|
-
if (!style) return label;
|
|
115
|
+
if (!stylesThinkingLabels()) return label;
|
|
116
116
|
const raw = label.replace(/\x1b\[[0-9;]*m/g, "");
|
|
117
|
-
|
|
117
|
+
const theme = themeProvider?.();
|
|
118
|
+
const style = theme
|
|
119
|
+
? labelStyle(isSettledThoughtLabel(label), theme)
|
|
120
|
+
: undefined;
|
|
121
|
+
// A theme that cannot resolve a color still drops Pi's italics.
|
|
122
|
+
return style ? `${style.prefix}${raw}${style.suffix}` : `${ITALIC_OFF}${raw}`;
|
|
118
123
|
}
|
|
119
124
|
|
|
120
125
|
type AssistantMessageLike = {
|
|
@@ -133,6 +138,28 @@ type TextLikeChild = {
|
|
|
133
138
|
setText?(text: string): void;
|
|
134
139
|
};
|
|
135
140
|
|
|
141
|
+
type MarkdownLikeChild = {
|
|
142
|
+
defaultTextStyle?: { italic?: boolean };
|
|
143
|
+
invalidate?(): void;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// Pi renders a visible thinking trace as italic markdown. Italic is the live
|
|
147
|
+
// cue — a settled trace drops it and reads as ordinary transcript text — and
|
|
148
|
+
// the italic default style is what identifies the trace: Pi passes it for
|
|
149
|
+
// thinking content and for nothing else. Callers gate on the row actually
|
|
150
|
+
// carrying thinking, so no other italic block is touched.
|
|
151
|
+
function unitalicizeSettledThinking(row: AssistantMessageRow): void {
|
|
152
|
+
const children = row.contentContainer?.children;
|
|
153
|
+
if (!Array.isArray(children)) return;
|
|
154
|
+
for (const child of children) {
|
|
155
|
+
const markdown = child as MarkdownLikeChild | undefined;
|
|
156
|
+
const style = markdown?.defaultTextStyle;
|
|
157
|
+
if (style?.italic !== true) continue;
|
|
158
|
+
style.italic = false;
|
|
159
|
+
markdown?.invalidate?.();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
136
163
|
// Pi renders the hidden-thinking label as an italic Text node built from the
|
|
137
164
|
// plain label field. Embedding style codes in the field itself is not
|
|
138
165
|
// enough: the TUI diff renderer reuses the byte prefix shared with the
|
|
@@ -149,7 +176,7 @@ function restyleHiddenThinkingLabel(row: AssistantMessageRow): void {
|
|
|
149
176
|
const label = row.hiddenThinkingLabel;
|
|
150
177
|
const children = row.contentContainer?.children;
|
|
151
178
|
if (!Array.isArray(children)) return;
|
|
152
|
-
if (!
|
|
179
|
+
if (!stylesThinkingLabels()) return;
|
|
153
180
|
const styled = visibleThoughtLabel(label);
|
|
154
181
|
for (const child of children) {
|
|
155
182
|
const textChild = child as TextLikeChild | undefined;
|
|
@@ -171,6 +198,10 @@ type ThinkingTiming = {
|
|
|
171
198
|
|
|
172
199
|
type ThinkingGroupingPatchState = {
|
|
173
200
|
owners: number;
|
|
201
|
+
// Set at the final owner's shutdown. A wrapper another extension has buried
|
|
202
|
+
// cannot be uninstalled, so it delegates instead of outliving its owner; a
|
|
203
|
+
// later install re-enables it.
|
|
204
|
+
disabled?: boolean;
|
|
174
205
|
originalUpdateContent: (
|
|
175
206
|
message: AssistantMessageLike,
|
|
176
207
|
...args: unknown[]
|
|
@@ -180,14 +211,39 @@ type ThinkingGroupingPatchState = {
|
|
|
180
211
|
...args: unknown[]
|
|
181
212
|
) => void;
|
|
182
213
|
timings: WeakMap<AssistantMessageRow, ThinkingTiming>;
|
|
183
|
-
// Last update per row, so /toggle-info can replay rows without
|
|
184
|
-
// new content.
|
|
214
|
+
// Last update per tracked row, so /toggle-info can replay rows without
|
|
215
|
+
// waiting for new content. Only rows that carry thinking change under the
|
|
216
|
+
// toggle, and the map is capped so a long session or an abandoned branch
|
|
217
|
+
// cannot pin every message the transcript ever showed.
|
|
185
218
|
rows: Map<
|
|
186
219
|
AssistantMessageRow,
|
|
187
220
|
{ message: AssistantMessageLike; args: unknown[] }
|
|
188
221
|
>;
|
|
189
222
|
};
|
|
190
223
|
|
|
224
|
+
const MAX_REPLAY_ROWS = 400;
|
|
225
|
+
|
|
226
|
+
// Bookkeeping only, so it fails open: a message whose content shape throws
|
|
227
|
+
// must not take the row's update pass down with it.
|
|
228
|
+
function trackReplayRow(
|
|
229
|
+
state: ThinkingGroupingPatchState,
|
|
230
|
+
row: AssistantMessageRow,
|
|
231
|
+
message: AssistantMessageLike,
|
|
232
|
+
args: unknown[],
|
|
233
|
+
): void {
|
|
234
|
+
try {
|
|
235
|
+
if (!hasThinkingContent(message)) return;
|
|
236
|
+
state.rows.set(row, { message, args });
|
|
237
|
+
while (state.rows.size > MAX_REPLAY_ROWS) {
|
|
238
|
+
const oldest = state.rows.keys().next().value;
|
|
239
|
+
if (oldest === undefined) return;
|
|
240
|
+
state.rows.delete(oldest);
|
|
241
|
+
}
|
|
242
|
+
} catch {
|
|
243
|
+
// The row keeps its native rendering; only /toggle-info replay misses it.
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
191
247
|
type ThinkingContentLike = {
|
|
192
248
|
type: "thinking";
|
|
193
249
|
thinking: string;
|
|
@@ -246,30 +302,40 @@ function thinkingSpinner(startedAt: number, now: number): string {
|
|
|
246
302
|
return PI_SPINNER_FRAMES[frame % PI_SPINNER_FRAMES.length]!;
|
|
247
303
|
}
|
|
248
304
|
|
|
305
|
+
// A row is live while it streams, and stays live through un-flagged rebuilds
|
|
306
|
+
// (resize, theme switch, /reload) until an explicit final update arrives.
|
|
307
|
+
// Rows that never carry the flag — restored history, older runtimes — count
|
|
308
|
+
// as settled.
|
|
309
|
+
function thinkingStillLive(
|
|
310
|
+
streaming: boolean | undefined,
|
|
311
|
+
timing: ThinkingTiming | undefined,
|
|
312
|
+
): boolean {
|
|
313
|
+
if (streaming === true) return true;
|
|
314
|
+
return (
|
|
315
|
+
streaming === undefined &&
|
|
316
|
+
timing !== undefined &&
|
|
317
|
+
timing.finishedAt === undefined
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
249
321
|
function lifecycleLabel(
|
|
250
322
|
row: AssistantMessageRow,
|
|
251
323
|
streaming: boolean | undefined,
|
|
252
324
|
timings: WeakMap<AssistantMessageRow, ThinkingTiming>,
|
|
253
325
|
): string {
|
|
254
326
|
const now = Date.now();
|
|
255
|
-
|
|
256
|
-
if (streaming
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
327
|
+
const previous = timings.get(row);
|
|
328
|
+
if (thinkingStillLive(streaming, previous)) {
|
|
329
|
+
const current =
|
|
330
|
+
previous && previous.finishedAt === undefined
|
|
331
|
+
? previous
|
|
332
|
+
: { startedAt: now };
|
|
333
|
+
timings.set(row, current);
|
|
261
334
|
return `${thinkingSpinner(current.startedAt, now)} Thinking…`;
|
|
262
335
|
}
|
|
263
336
|
|
|
264
|
-
if (streaming ===
|
|
265
|
-
|
|
266
|
-
// the optional streaming flag. Keep its active label until an explicit
|
|
267
|
-
// final update arrives.
|
|
268
|
-
return `${thinkingSpinner(current.startedAt, now)} Thinking…`;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
if (streaming === false && current?.finishedAt === undefined) {
|
|
272
|
-
if (current) current.finishedAt = now;
|
|
337
|
+
if (streaming === false && previous && previous.finishedAt === undefined) {
|
|
338
|
+
previous.finishedAt = now;
|
|
273
339
|
}
|
|
274
340
|
|
|
275
341
|
const settled = timings.get(row);
|
|
@@ -353,6 +419,7 @@ function installThinkingGroupingPatch():
|
|
|
353
419
|
if (existing) {
|
|
354
420
|
existing.owners++;
|
|
355
421
|
existing.rows ??= new Map();
|
|
422
|
+
existing.disabled = false;
|
|
356
423
|
return existing;
|
|
357
424
|
}
|
|
358
425
|
|
|
@@ -367,9 +434,14 @@ function installThinkingGroupingPatch():
|
|
|
367
434
|
message: AssistantMessageLike,
|
|
368
435
|
...args: unknown[]
|
|
369
436
|
): void {
|
|
370
|
-
// Grouping and
|
|
437
|
+
// Grouping and styling are cosmetic. Each fails open independently, and
|
|
371
438
|
// the original renderer is still called exactly once with every arg.
|
|
372
|
-
state.
|
|
439
|
+
if (state.disabled) {
|
|
440
|
+
Reflect.apply(state.originalUpdateContent, this, [message, ...args]);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
trackReplayRow(state, this, message, args);
|
|
444
|
+
const streaming = typeof args[0] === "boolean" ? args[0] : undefined;
|
|
373
445
|
let combined = message;
|
|
374
446
|
try {
|
|
375
447
|
// Pi builds the hidden label and the visible block only when thinking
|
|
@@ -381,7 +453,6 @@ function installThinkingGroupingPatch():
|
|
|
381
453
|
// Preserve the original message intact.
|
|
382
454
|
}
|
|
383
455
|
try {
|
|
384
|
-
const streaming = typeof args[0] === "boolean" ? args[0] : undefined;
|
|
385
456
|
applyHiddenThinkingLabel(this, combined, streaming, state.timings);
|
|
386
457
|
} catch {
|
|
387
458
|
// Preserve Pi's native label if its private row shape changes.
|
|
@@ -392,6 +463,16 @@ function installThinkingGroupingPatch():
|
|
|
392
463
|
} catch {
|
|
393
464
|
// Keep Pi's native label styling if the row shape changes.
|
|
394
465
|
}
|
|
466
|
+
try {
|
|
467
|
+
if (
|
|
468
|
+
hasThinkingContent(combined) &&
|
|
469
|
+
!thinkingStillLive(streaming, state.timings.get(this))
|
|
470
|
+
) {
|
|
471
|
+
unitalicizeSettledThinking(this);
|
|
472
|
+
}
|
|
473
|
+
} catch {
|
|
474
|
+
// Keep Pi's native italic trace if the row shape changes.
|
|
475
|
+
}
|
|
395
476
|
};
|
|
396
477
|
|
|
397
478
|
state.patchedUpdateContent = patchedUpdateContent;
|
|
@@ -412,6 +493,7 @@ function uninstallThinkingGroupingPatch(
|
|
|
412
493
|
if (!state || state.owners <= 0) return false;
|
|
413
494
|
state.owners--;
|
|
414
495
|
if (state.owners > 0) return false;
|
|
496
|
+
state.disabled = true;
|
|
415
497
|
const proto =
|
|
416
498
|
AssistantMessageComponent?.prototype as unknown as AssistantMessageRow & {
|
|
417
499
|
[THINKING_GROUPING_PATCHED]?: ThinkingGroupingPatchState;
|
|
@@ -453,17 +535,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
453
535
|
return "off";
|
|
454
536
|
}
|
|
455
537
|
};
|
|
456
|
-
updateThoughtLabelStyle();
|
|
457
538
|
});
|
|
458
|
-
pi.on("thinking_level_select", () => updateThoughtLabelStyle());
|
|
459
539
|
pi.on("session_shutdown", () => {
|
|
460
540
|
if (released) return;
|
|
461
541
|
released = true;
|
|
462
542
|
if (patch && !uninstallThinkingGroupingPatch(patch)) return;
|
|
463
543
|
themeProvider = undefined;
|
|
464
544
|
activeLevel = undefined;
|
|
465
|
-
stylesTheme = undefined;
|
|
466
|
-
liveLabelStyle = undefined;
|
|
467
|
-
settledLabelStyle = undefined;
|
|
468
545
|
});
|
|
469
546
|
}
|