@pi-kaush/pi-tool-call-markers 0.3.13 → 0.3.15
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 +17 -0
- package/README.md +4 -4
- package/package.json +1 -1
- package/src/muted.ts +1 -1
- package/src/thinking-block-merger.ts +152 -20
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- Fix assistant replies vanishing behind the thinking label. The label
|
|
6
|
+
restyle matched any text node containing the label, so a reply that quoted
|
|
7
|
+
`Thinking...` or `│ * Thought` was replaced by the label until Ctrl+T. Only
|
|
8
|
+
Pi's thinking-run label nodes are restyled now, on an exact match, and only
|
|
9
|
+
on rows that carry thinking.
|
|
10
|
+
- Keep the live thinking spinner moving between thinking deltas. The frame is
|
|
11
|
+
read from the clock on each redraw that Pi's working indicator already
|
|
12
|
+
drives, instead of only when new thinking text arrives.
|
|
13
|
+
|
|
14
|
+
- Anchor the settled thinking label like a collapsed tool call:
|
|
15
|
+
`│ * Thought · 2.5s` instead of `+ Thought · 2.5s`. The faint rail and
|
|
16
|
+
bold glyph match the tool rows around it, so a turn that alternates
|
|
17
|
+
thinking and calls reads as one column. The live label takes the same
|
|
18
|
+
anchor, `│ ⠋ Thinking…`, with the spinner in the glyph slot, so settling
|
|
19
|
+
swaps only the glyph, the words, and the tint instead of shifting the text
|
|
20
|
+
two columns and drawing a rail in.
|
|
21
|
+
|
|
5
22
|
- Paint collapsed rows with the theme's `dim` token instead of
|
|
6
23
|
`syntaxComment`. Pi's generated `system` theme solves `syntaxComment` to
|
|
7
24
|
the same contrast as `muted` and `toolOutput`, so collapsed tool calls
|
package/README.md
CHANGED
|
@@ -97,11 +97,11 @@ The second package entrypoint, `src/thinking-block-merger.ts`, combines only dir
|
|
|
97
97
|
When Pi exposes its per-row hidden-thinking and streaming fields, hidden reasoning uses these native-themed labels:
|
|
98
98
|
|
|
99
99
|
```text
|
|
100
|
-
⠋ Thinking… → ⠙ Thinking… → …
|
|
101
|
-
|
|
100
|
+
│ ⠋ Thinking… → │ ⠙ Thinking… → …
|
|
101
|
+
│ * Thought · 2.5s
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
-
The live label samples Pi's native braille spinner sequence from the
|
|
104
|
+
The live label samples Pi's native braille spinner sequence from the clock each time the TUI draws it, so it steps with the redraws Pi's working indicator already drives instead of freezing between bursty thinking deltas. It does not add a timer; with the working indicator hidden, the spinner advances on each content update. 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`. Both labels borrow the collapsed tool-row anchor — a faint `│` rail and a bold glyph — so reasoning reads like the tool calls around it. The spinner holds the glyph slot and settles into `*` in place: the rail and the label text keep their columns, and the rail keeps the settled tone throughout. There is no interval, timeout, render request, model call, or network work.
|
|
105
105
|
|
|
106
106
|
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.
|
|
107
107
|
|
|
@@ -135,7 +135,7 @@ Names are comma-separated and matched exactly. Listed rows never join a group, a
|
|
|
135
135
|
|
|
136
136
|
### Collapsed-row colors
|
|
137
137
|
|
|
138
|
-
Collapsed rows (tool calls,
|
|
138
|
+
Collapsed rows (tool calls, `│ * Thought`, subagents) default to the theme's `dim` color — it ships with every Pi theme and sits below `muted`, including in the generated `system` theme — with the row's anchor and the call content sharing it. Failures stay error-colored and live spinners keep their existing tints.
|
|
139
139
|
|
|
140
140
|
A theme can override each collapsed kind independently with optional color tokens:
|
|
141
141
|
|
package/package.json
CHANGED
package/src/muted.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Collapsed-row color policy.
|
|
2
2
|
//
|
|
3
|
-
// Collapsed blocks (tool calls, settled "
|
|
3
|
+
// Collapsed blocks (tool calls, settled "│ * Thought" labels, subagent rows)
|
|
4
4
|
// default to the theme's `dim` token — it ships with every Pi theme and sits
|
|
5
5
|
// below `muted` — instead of the louder muted/toolTitle/toolOutput split.
|
|
6
6
|
// Pi's generated `system` theme solves `syntaxComment` to the same contrast
|
|
@@ -10,7 +10,7 @@ const SPINNER_INTERVAL_MS = 80;
|
|
|
10
10
|
// Visual treatment for the collapsed thinking label. The live "Thinking…"
|
|
11
11
|
// spinner tints with the session's active thinking-level color
|
|
12
12
|
// (thinkingOff…thinkingMax), so progress reads as activity; the settled
|
|
13
|
-
// "
|
|
13
|
+
// "│ * Thought" row drops to the muted token collapsed tool calls use, so
|
|
14
14
|
// finished reasoning stops advertising the level. "inherit" keeps Pi's
|
|
15
15
|
// native styling (italic + thinkingText) for both states, and "mdheading"
|
|
16
16
|
// rides the theme's mdHeading token for both. The env var overrides the
|
|
@@ -105,21 +105,45 @@ function labelStyle(
|
|
|
105
105
|
return styledWith(token, theme);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
//
|
|
109
|
-
// the live spinner
|
|
108
|
+
// Both labels wear the collapsed tool-row anchor — faint rail, bold glyph —
|
|
109
|
+
// so reasoning reads like the tool calls around it. The live spinner holds
|
|
110
|
+
// the glyph slot and settles into `*` in place: the rail and the label text
|
|
111
|
+
// keep their columns, and only the glyph, the words, and the tint change.
|
|
112
|
+
const THOUGHT_RAIL = "│";
|
|
113
|
+
const THOUGHT_GLYPH = "*";
|
|
114
|
+
const SETTLED_THOUGHT = `${THOUGHT_RAIL} ${THOUGHT_GLYPH} Thought`;
|
|
115
|
+
const ANCHORED_LABEL_RE = /^│ (\S) (.*)$/su;
|
|
116
|
+
|
|
110
117
|
function isSettledThoughtLabel(label: string): boolean {
|
|
111
|
-
return label.startsWith(
|
|
118
|
+
return label.startsWith(SETTLED_THOUGHT);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The rail keeps the settled collapsed tone in both states, like a pending
|
|
122
|
+
// tool row's rail, so settling never repaints it.
|
|
123
|
+
function styledAnchoredLabel(
|
|
124
|
+
raw: string,
|
|
125
|
+
style: LabelStyle,
|
|
126
|
+
theme: ThemeDetail,
|
|
127
|
+
): string {
|
|
128
|
+
const match = ANCHORED_LABEL_RE.exec(raw);
|
|
129
|
+
if (!match) return `${style.prefix}${raw}${style.suffix}`;
|
|
130
|
+
const rail = settledMutedStyle(theme) ?? style;
|
|
131
|
+
const railText = `\x1b[2m${THOUGHT_RAIL}\x1b[22m`;
|
|
132
|
+
const body = ` \x1b[1m${match[1]}\x1b[22m ${match[2]}${style.suffix}`;
|
|
133
|
+
return rail.prefix === style.prefix
|
|
134
|
+
? `${style.prefix}${railText}${body}`
|
|
135
|
+
: `${rail.prefix}${railText}${rail.suffix}${style.prefix}${body}`;
|
|
112
136
|
}
|
|
113
137
|
|
|
114
138
|
export function visibleThoughtLabel(label: string): string {
|
|
115
139
|
if (!stylesThinkingLabels()) return label;
|
|
116
140
|
const raw = label.replace(/\x1b\[[0-9;]*m/g, "");
|
|
117
141
|
const theme = themeProvider?.();
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
: undefined;
|
|
142
|
+
const settled = isSettledThoughtLabel(raw);
|
|
143
|
+
const style = theme ? labelStyle(settled, theme) : undefined;
|
|
121
144
|
// A theme that cannot resolve a color still drops Pi's italics.
|
|
122
|
-
|
|
145
|
+
if (!style || !theme) return `${ITALIC_OFF}${raw}`;
|
|
146
|
+
return styledAnchoredLabel(raw, style, theme);
|
|
123
147
|
}
|
|
124
148
|
|
|
125
149
|
type AssistantMessageLike = {
|
|
@@ -136,6 +160,7 @@ type AssistantMessageRow = {
|
|
|
136
160
|
type TextLikeChild = {
|
|
137
161
|
text?: unknown;
|
|
138
162
|
setText?(text: string): void;
|
|
163
|
+
render?(width: number): string[];
|
|
139
164
|
};
|
|
140
165
|
|
|
141
166
|
type MarkdownLikeChild = {
|
|
@@ -179,7 +204,63 @@ function unitalicizeSettledThinking(row: AssistantMessageRow): void {
|
|
|
179
204
|
// previous (italic) frame, so an in-line italic reset may never reach the
|
|
180
205
|
// terminal. Swap the node's text for a self-contained styled version after
|
|
181
206
|
// each native render pass instead.
|
|
182
|
-
|
|
207
|
+
//
|
|
208
|
+
// Only label nodes qualify. Answer prose and a click-revealed trace are
|
|
209
|
+
// Markdown nodes that also carry `text` and `setText`, so Markdown never
|
|
210
|
+
// matches, and a candidate's plain text must equal the label exactly. Pi 1.0
|
|
211
|
+
// wraps each thinking run in a MouseRegion; each child is judged on its own,
|
|
212
|
+
// unwrapped when it has a wrapped inner.
|
|
213
|
+
function hiddenLabelNodes(row: AssistantMessageRow): unknown[] {
|
|
214
|
+
const children = row.contentContainer?.children;
|
|
215
|
+
if (!Array.isArray(children)) return [];
|
|
216
|
+
return children
|
|
217
|
+
.map((child) => (child as { child?: unknown } | undefined)?.child ?? child)
|
|
218
|
+
.filter((node) => !isMarkdownLike(node));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Duck-typed, not `instanceof`: a locally loaded extension can resolve its
|
|
222
|
+
// own pi-tui copy, so class identity is not shared with Pi's. pi-tui's
|
|
223
|
+
// Markdown declares `theme` and `defaultTextStyle` fields; Text has neither.
|
|
224
|
+
function isMarkdownLike(node: unknown): boolean {
|
|
225
|
+
return (
|
|
226
|
+
!!node &&
|
|
227
|
+
typeof node === "object" &&
|
|
228
|
+
("defaultTextStyle" in node || "theme" in node)
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function plainText(text: string): string {
|
|
233
|
+
return text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// A live label re-reads the clock whenever the TUI draws it, so the spinner
|
|
237
|
+
// steps with the redraws Pi's working indicator already drives instead of
|
|
238
|
+
// freezing between bursty thinking deltas. Pi rebuilds the label node on
|
|
239
|
+
// every content update, so the override dies with the node; no timer is
|
|
240
|
+
// added, and with no redraws the spinner still advances on each delta.
|
|
241
|
+
function animateLiveLabel(node: TextLikeChild, startedAt: number): void {
|
|
242
|
+
const render = node.render;
|
|
243
|
+
if (typeof render !== "function") return;
|
|
244
|
+
node.render = function renderLiveThinkingLabel(
|
|
245
|
+
this: TextLikeChild,
|
|
246
|
+
width: number,
|
|
247
|
+
): string[] {
|
|
248
|
+
try {
|
|
249
|
+
const styled = visibleThoughtLabel(
|
|
250
|
+
liveThinkingLabel(startedAt, Date.now()),
|
|
251
|
+
);
|
|
252
|
+
if (this.text !== styled) this.setText?.(styled);
|
|
253
|
+
} catch {
|
|
254
|
+
// Keep the last frame if styling fails mid-render.
|
|
255
|
+
}
|
|
256
|
+
return Reflect.apply(render, this, [width]) as string[];
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function restyleHiddenThinkingLabel(
|
|
261
|
+
row: AssistantMessageRow,
|
|
262
|
+
liveSince?: number,
|
|
263
|
+
): void {
|
|
183
264
|
if (
|
|
184
265
|
row.hideThinkingBlock !== true ||
|
|
185
266
|
typeof row.hiddenThinkingLabel !== "string"
|
|
@@ -189,16 +270,18 @@ function restyleHiddenThinkingLabel(row: AssistantMessageRow): void {
|
|
|
189
270
|
const label = row.hiddenThinkingLabel;
|
|
190
271
|
if (!stylesThinkingLabels()) return;
|
|
191
272
|
const styled = visibleThoughtLabel(label);
|
|
192
|
-
|
|
273
|
+
const plainLabel = plainText(label);
|
|
274
|
+
for (const child of hiddenLabelNodes(row)) {
|
|
193
275
|
const textChild = child as TextLikeChild | undefined;
|
|
194
276
|
if (
|
|
195
277
|
typeof textChild?.text !== "string" ||
|
|
196
278
|
typeof textChild.setText !== "function" ||
|
|
197
|
-
|
|
279
|
+
plainText(textChild.text) !== plainLabel
|
|
198
280
|
) {
|
|
199
281
|
continue;
|
|
200
282
|
}
|
|
201
283
|
textChild.setText(styled);
|
|
284
|
+
if (liveSince !== undefined) animateLiveLabel(textChild, liveSince);
|
|
202
285
|
}
|
|
203
286
|
}
|
|
204
287
|
|
|
@@ -243,7 +326,9 @@ function trackReplayRow(
|
|
|
243
326
|
args: unknown[],
|
|
244
327
|
): void {
|
|
245
328
|
try {
|
|
246
|
-
|
|
329
|
+
// A tracked row keeps tracking even when its final content drops
|
|
330
|
+
// thinking, so a replay never resurrects an earlier streaming snapshot.
|
|
331
|
+
if (!hasThinkingContent(message) && !state.rows.has(row)) return;
|
|
247
332
|
state.rows.set(row, { message, args });
|
|
248
333
|
while (state.rows.size > MAX_REPLAY_ROWS) {
|
|
249
334
|
const oldest = state.rows.keys().next().value;
|
|
@@ -308,6 +393,10 @@ function formatThoughtDuration(startedAt: number, finishedAt: number): string {
|
|
|
308
393
|
return `${(Math.max(0, finishedAt - startedAt) / 1000).toFixed(1)}s`;
|
|
309
394
|
}
|
|
310
395
|
|
|
396
|
+
function liveThinkingLabel(startedAt: number, now: number): string {
|
|
397
|
+
return `${THOUGHT_RAIL} ${thinkingSpinner(startedAt, now)} Thinking…`;
|
|
398
|
+
}
|
|
399
|
+
|
|
311
400
|
function thinkingSpinner(startedAt: number, now: number): string {
|
|
312
401
|
const frame = Math.floor(Math.max(0, now - startedAt) / SPINNER_INTERVAL_MS);
|
|
313
402
|
return PI_SPINNER_FRAMES[frame % PI_SPINNER_FRAMES.length]!;
|
|
@@ -329,31 +418,67 @@ function thinkingStillLive(
|
|
|
329
418
|
);
|
|
330
419
|
}
|
|
331
420
|
|
|
421
|
+
// Thinking ends when the answer starts: visible text or a tool call after
|
|
422
|
+
// the last thinking block. Settling there keeps the duration honest and
|
|
423
|
+
// keeps a live label as the transcript's last line, so its spinner never
|
|
424
|
+
// changes a line scrolled above the viewport (which forces a full replay).
|
|
425
|
+
function answerStarted(message: AssistantMessageLike): boolean {
|
|
426
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
427
|
+
let lastThinking = -1;
|
|
428
|
+
content.forEach((block, index) => {
|
|
429
|
+
if (isThinkingContent(block)) lastThinking = index;
|
|
430
|
+
});
|
|
431
|
+
return content.slice(lastThinking + 1).some((block) => {
|
|
432
|
+
const b = block as { type?: unknown; text?: unknown } | undefined;
|
|
433
|
+
return (
|
|
434
|
+
b?.type === "toolCall" ||
|
|
435
|
+
(b?.type === "text" && typeof b.text === "string" && b.text.trim() !== "")
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function timingLive(
|
|
441
|
+
streaming: boolean | undefined,
|
|
442
|
+
timing: ThinkingTiming | undefined,
|
|
443
|
+
): boolean {
|
|
444
|
+
return (
|
|
445
|
+
streaming !== false &&
|
|
446
|
+
timing !== undefined &&
|
|
447
|
+
timing.finishedAt === undefined
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
|
|
332
451
|
function lifecycleLabel(
|
|
333
452
|
row: AssistantMessageRow,
|
|
453
|
+
message: AssistantMessageLike,
|
|
334
454
|
streaming: boolean | undefined,
|
|
335
455
|
timings: WeakMap<AssistantMessageRow, ThinkingTiming>,
|
|
336
456
|
): string {
|
|
337
457
|
const now = Date.now();
|
|
338
458
|
const previous = timings.get(row);
|
|
339
|
-
|
|
459
|
+
const answering = answerStarted(message);
|
|
460
|
+
if (!answering && thinkingStillLive(streaming, previous)) {
|
|
340
461
|
const current =
|
|
341
462
|
previous && previous.finishedAt === undefined
|
|
342
463
|
? previous
|
|
343
464
|
: { startedAt: now };
|
|
344
465
|
timings.set(row, current);
|
|
345
|
-
return
|
|
466
|
+
return liveThinkingLabel(current.startedAt, now);
|
|
346
467
|
}
|
|
347
468
|
|
|
348
|
-
if (
|
|
469
|
+
if (
|
|
470
|
+
(streaming === false || answering) &&
|
|
471
|
+
previous &&
|
|
472
|
+
previous.finishedAt === undefined
|
|
473
|
+
) {
|
|
349
474
|
previous.finishedAt = now;
|
|
350
475
|
}
|
|
351
476
|
|
|
352
477
|
const settled = timings.get(row);
|
|
353
478
|
if (settled?.finishedAt !== undefined) {
|
|
354
|
-
return
|
|
479
|
+
return `${SETTLED_THOUGHT} · ${formatThoughtDuration(settled.startedAt, settled.finishedAt)}`;
|
|
355
480
|
}
|
|
356
|
-
return
|
|
481
|
+
return SETTLED_THOUGHT;
|
|
357
482
|
}
|
|
358
483
|
|
|
359
484
|
function stripThinkingBlocks(
|
|
@@ -398,7 +523,7 @@ function applyHiddenThinkingLabel(
|
|
|
398
523
|
): void {
|
|
399
524
|
// Record the component's first streaming update even when the provider has
|
|
400
525
|
// not emitted a non-empty thinking block yet.
|
|
401
|
-
const label = lifecycleLabel(row, streaming, timings);
|
|
526
|
+
const label = lifecycleLabel(row, message, streaming, timings);
|
|
402
527
|
if (!hasThinkingContent(message)) return;
|
|
403
528
|
if (
|
|
404
529
|
row.hideThinkingBlock !== true ||
|
|
@@ -470,14 +595,21 @@ function installThinkingGroupingPatch():
|
|
|
470
595
|
}
|
|
471
596
|
Reflect.apply(state.originalUpdateContent, this, [combined, ...args]);
|
|
472
597
|
try {
|
|
473
|
-
|
|
598
|
+
if (hasThinkingContent(combined)) {
|
|
599
|
+
const timing = state.timings.get(this);
|
|
600
|
+
const liveSince =
|
|
601
|
+
timing && timingLive(streaming, timing)
|
|
602
|
+
? timing.startedAt
|
|
603
|
+
: undefined;
|
|
604
|
+
restyleHiddenThinkingLabel(this, liveSince);
|
|
605
|
+
}
|
|
474
606
|
} catch {
|
|
475
607
|
// Keep Pi's native label styling if the row shape changes.
|
|
476
608
|
}
|
|
477
609
|
try {
|
|
478
610
|
if (
|
|
479
611
|
hasThinkingContent(combined) &&
|
|
480
|
-
!
|
|
612
|
+
!timingLive(streaming, state.timings.get(this))
|
|
481
613
|
) {
|
|
482
614
|
unitalicizeSettledThinking(this);
|
|
483
615
|
}
|