@pi-kaush/pi-tool-call-markers 0.3.14 → 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 CHANGED
@@ -2,11 +2,22 @@
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
+
5
14
  - Anchor the settled thinking label like a collapsed tool call:
6
15
  `│ * Thought · 2.5s` instead of `+ Thought · 2.5s`. The faint rail and
7
16
  bold glyph match the tool rows around it, so a turn that alternates
8
- thinking and calls reads as one column. The live `⠋ Thinking…` spinner is
9
- unchanged.
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.
10
21
 
11
22
  - Paint collapsed rows with the theme's `dim` token instead of
12
23
  `syntaxComment`. Pi's generated `system` theme solves `syntaxComment` to
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… → …
100
+ │ ⠋ Thinking… → │ ⠙ Thinking… → …
101
101
  │ * Thought · 2.5s
102
102
  ```
103
103
 
104
- 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`. The settled label borrows the collapsed tool-row anchor — a faint `│` rail and a bold `*` — so finished reasoning reads like the tool calls around it. There is no interval, timeout, render request, model call, or network work.
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-kaush/pi-tool-call-markers",
3
- "version": "0.3.14",
3
+ "version": "0.3.15",
4
4
  "description": "Compact and group Pi tool calls, with a bundled extension for adjacent thinking blocks.",
5
5
  "license": "MIT",
6
6
  "author": "Kaushik Gopal",
@@ -105,21 +105,34 @@ function labelStyle(
105
105
  return styledWith(token, theme);
106
106
  }
107
107
 
108
- // The settled label wears the collapsed tool-row anchor — faint rail, bold
109
- // glyph — so finished reasoning reads like the tool calls around it. Every
110
- // other label is 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.
111
112
  const THOUGHT_RAIL = "│";
112
113
  const THOUGHT_GLYPH = "*";
113
- const THOUGHT_ANCHOR = `${THOUGHT_RAIL} ${THOUGHT_GLYPH}`;
114
- const SETTLED_THOUGHT = `${THOUGHT_ANCHOR} Thought`;
114
+ const SETTLED_THOUGHT = `${THOUGHT_RAIL} ${THOUGHT_GLYPH} Thought`;
115
+ const ANCHORED_LABEL_RE = /^│ (\S) (.*)$/su;
115
116
 
116
117
  function isSettledThoughtLabel(label: string): boolean {
117
118
  return label.startsWith(SETTLED_THOUGHT);
118
119
  }
119
120
 
120
- function styledSettledThought(raw: string, style: LabelStyle): string {
121
- const rest = raw.slice(THOUGHT_ANCHOR.length);
122
- return `${style.prefix}\x1b[2m${THOUGHT_RAIL}\x1b[22m \x1b[1m${THOUGHT_GLYPH}\x1b[22m${rest}${style.suffix}`;
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}`;
123
136
  }
124
137
 
125
138
  export function visibleThoughtLabel(label: string): string {
@@ -129,10 +142,8 @@ export function visibleThoughtLabel(label: string): string {
129
142
  const settled = isSettledThoughtLabel(raw);
130
143
  const style = theme ? labelStyle(settled, theme) : undefined;
131
144
  // A theme that cannot resolve a color still drops Pi's italics.
132
- if (!style) return `${ITALIC_OFF}${raw}`;
133
- return settled
134
- ? styledSettledThought(raw, style)
135
- : `${style.prefix}${raw}${style.suffix}`;
145
+ if (!style || !theme) return `${ITALIC_OFF}${raw}`;
146
+ return styledAnchoredLabel(raw, style, theme);
136
147
  }
137
148
 
138
149
  type AssistantMessageLike = {
@@ -149,6 +160,7 @@ type AssistantMessageRow = {
149
160
  type TextLikeChild = {
150
161
  text?: unknown;
151
162
  setText?(text: string): void;
163
+ render?(width: number): string[];
152
164
  };
153
165
 
154
166
  type MarkdownLikeChild = {
@@ -192,7 +204,63 @@ function unitalicizeSettledThinking(row: AssistantMessageRow): void {
192
204
  // previous (italic) frame, so an in-line italic reset may never reach the
193
205
  // terminal. Swap the node's text for a self-contained styled version after
194
206
  // each native render pass instead.
195
- function restyleHiddenThinkingLabel(row: AssistantMessageRow): void {
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 {
196
264
  if (
197
265
  row.hideThinkingBlock !== true ||
198
266
  typeof row.hiddenThinkingLabel !== "string"
@@ -202,16 +270,18 @@ function restyleHiddenThinkingLabel(row: AssistantMessageRow): void {
202
270
  const label = row.hiddenThinkingLabel;
203
271
  if (!stylesThinkingLabels()) return;
204
272
  const styled = visibleThoughtLabel(label);
205
- for (const child of contentNodes(row)) {
273
+ const plainLabel = plainText(label);
274
+ for (const child of hiddenLabelNodes(row)) {
206
275
  const textChild = child as TextLikeChild | undefined;
207
276
  if (
208
277
  typeof textChild?.text !== "string" ||
209
278
  typeof textChild.setText !== "function" ||
210
- !textChild.text.includes(label)
279
+ plainText(textChild.text) !== plainLabel
211
280
  ) {
212
281
  continue;
213
282
  }
214
283
  textChild.setText(styled);
284
+ if (liveSince !== undefined) animateLiveLabel(textChild, liveSince);
215
285
  }
216
286
  }
217
287
 
@@ -256,7 +326,9 @@ function trackReplayRow(
256
326
  args: unknown[],
257
327
  ): void {
258
328
  try {
259
- if (!hasThinkingContent(message)) return;
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;
260
332
  state.rows.set(row, { message, args });
261
333
  while (state.rows.size > MAX_REPLAY_ROWS) {
262
334
  const oldest = state.rows.keys().next().value;
@@ -321,6 +393,10 @@ function formatThoughtDuration(startedAt: number, finishedAt: number): string {
321
393
  return `${(Math.max(0, finishedAt - startedAt) / 1000).toFixed(1)}s`;
322
394
  }
323
395
 
396
+ function liveThinkingLabel(startedAt: number, now: number): string {
397
+ return `${THOUGHT_RAIL} ${thinkingSpinner(startedAt, now)} Thinking…`;
398
+ }
399
+
324
400
  function thinkingSpinner(startedAt: number, now: number): string {
325
401
  const frame = Math.floor(Math.max(0, now - startedAt) / SPINNER_INTERVAL_MS);
326
402
  return PI_SPINNER_FRAMES[frame % PI_SPINNER_FRAMES.length]!;
@@ -342,23 +418,59 @@ function thinkingStillLive(
342
418
  );
343
419
  }
344
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
+
345
451
  function lifecycleLabel(
346
452
  row: AssistantMessageRow,
453
+ message: AssistantMessageLike,
347
454
  streaming: boolean | undefined,
348
455
  timings: WeakMap<AssistantMessageRow, ThinkingTiming>,
349
456
  ): string {
350
457
  const now = Date.now();
351
458
  const previous = timings.get(row);
352
- if (thinkingStillLive(streaming, previous)) {
459
+ const answering = answerStarted(message);
460
+ if (!answering && thinkingStillLive(streaming, previous)) {
353
461
  const current =
354
462
  previous && previous.finishedAt === undefined
355
463
  ? previous
356
464
  : { startedAt: now };
357
465
  timings.set(row, current);
358
- return `${thinkingSpinner(current.startedAt, now)} Thinking…`;
466
+ return liveThinkingLabel(current.startedAt, now);
359
467
  }
360
468
 
361
- if (streaming === false && previous && previous.finishedAt === undefined) {
469
+ if (
470
+ (streaming === false || answering) &&
471
+ previous &&
472
+ previous.finishedAt === undefined
473
+ ) {
362
474
  previous.finishedAt = now;
363
475
  }
364
476
 
@@ -411,7 +523,7 @@ function applyHiddenThinkingLabel(
411
523
  ): void {
412
524
  // Record the component's first streaming update even when the provider has
413
525
  // not emitted a non-empty thinking block yet.
414
- const label = lifecycleLabel(row, streaming, timings);
526
+ const label = lifecycleLabel(row, message, streaming, timings);
415
527
  if (!hasThinkingContent(message)) return;
416
528
  if (
417
529
  row.hideThinkingBlock !== true ||
@@ -483,14 +595,21 @@ function installThinkingGroupingPatch():
483
595
  }
484
596
  Reflect.apply(state.originalUpdateContent, this, [combined, ...args]);
485
597
  try {
486
- restyleHiddenThinkingLabel(this);
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
+ }
487
606
  } catch {
488
607
  // Keep Pi's native label styling if the row shape changes.
489
608
  }
490
609
  try {
491
610
  if (
492
611
  hasThinkingContent(combined) &&
493
- !thinkingStillLive(streaming, state.timings.get(this))
612
+ !timingLive(streaming, state.timings.get(this))
494
613
  ) {
495
614
  unitalicizeSettledThinking(this);
496
615
  }