@quandev104/pi-style 0.2.7 → 0.2.10

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.
Files changed (25) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +2 -2
  3. package/dist/extensions/pi-style.js +8900 -8464
  4. package/dist/extensions/pi-style.js.map +1 -1
  5. package/extension-src/pi-style/app/command-service.ts +2 -0
  6. package/extension-src/pi-style/domain/config-normalization.ts +3 -0
  7. package/extension-src/pi-style/domain/config-types.ts +8 -0
  8. package/extension-src/pi-style/features/messages/index.ts +451 -37
  9. package/extension-src/pi-style/features/messages/special-blocks.ts +2 -22
  10. package/extension-src/pi-style/features/startup/index.ts +24 -4
  11. package/extension-src/pi-style/features/startup/logo.ts +22 -18
  12. package/extension-src/pi-style/features/tools/bash-execution.ts +24 -8
  13. package/extension-src/pi-style/features/tools/boxed/edit.ts +25 -30
  14. package/extension-src/pi-style/features/tools/boxed/git.ts +21 -13
  15. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +35 -38
  16. package/extension-src/pi-style/features/tools/boxed/shared.ts +34 -0
  17. package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +114 -38
  18. package/extension-src/pi-style/features/tools/boxed/write.ts +1 -0
  19. package/extension-src/pi-style/features/tools/index.ts +10 -6
  20. package/extension-src/pi-style/pi/compatibility-coordinator.ts +2 -0
  21. package/extension-src/pi-style/pi/compatibility-probe.ts +88 -19
  22. package/extension-src/pi-style/pi/session-coordinator.ts +20 -1
  23. package/extension-src/pi-style/shared/ansi.ts +45 -0
  24. package/extension-src/pi-style/shared/box.ts +126 -47
  25. package/package.json +10 -9
@@ -48,6 +48,7 @@ const allowedPaths = new Set([
48
48
  "messages.assistantPrefix",
49
49
  "messages.specialBlocks",
50
50
  "messages.hideThinkingLabel",
51
+ "messages.thoughtSummary",
51
52
  "messages.showImagePreviews",
52
53
  "messages.clipboardImages",
53
54
  "messages.previewMaxWidth",
@@ -83,6 +84,7 @@ function validatePathValue(path: string, value: unknown): boolean {
83
84
  "messages.assistantPrefix",
84
85
  "messages.specialBlocks",
85
86
  "messages.hideThinkingLabel",
87
+ "messages.thoughtSummary",
86
88
  "messages.showImagePreviews",
87
89
  "messages.clipboardImages",
88
90
  "messages.previewMaxWidth",
@@ -33,6 +33,7 @@ export const DEFAULT_CONFIG: NormalizedPiStyleConfig = Object.freeze({
33
33
  assistantPrefix: true,
34
34
  specialBlocks: true,
35
35
  hideThinkingLabel: true,
36
+ thoughtSummary: true,
36
37
  showImagePreviews: true,
37
38
  clipboardImages: true,
38
39
  previewMaxWidth: 30,
@@ -179,6 +180,7 @@ export function normalizeConfig(
179
180
  assistantPrefix: bool(messages.assistantPrefix, defaults.messages.assistantPrefix),
180
181
  specialBlocks: bool(messages.specialBlocks, defaults.messages.specialBlocks),
181
182
  hideThinkingLabel: bool(messages.hideThinkingLabel, defaults.messages.hideThinkingLabel),
183
+ thoughtSummary: bool(messages.thoughtSummary, defaults.messages.thoughtSummary),
182
184
  showImagePreviews: bool(messages.showImagePreviews, defaults.messages.showImagePreviews),
183
185
  clipboardImages: bool(messages.clipboardImages, defaults.messages.clipboardImages),
184
186
  previewMaxWidth: boundedInt(messages.previewMaxWidth, defaults.messages.previewMaxWidth, 8, 60),
@@ -242,6 +244,7 @@ const BOOL_PATHS = new Set([
242
244
  "messages.assistantPrefix",
243
245
  "messages.specialBlocks",
244
246
  "messages.hideThinkingLabel",
247
+ "messages.thoughtSummary",
245
248
  "messages.showImagePreviews",
246
249
  "messages.clipboardImages",
247
250
  "tools.enabled",
@@ -37,6 +37,10 @@ export interface PiStyleConfig {
37
37
  assistantPrefix?: boolean;
38
38
  specialBlocks?: boolean;
39
39
  hideThinkingLabel?: boolean;
40
+ /** Surface completed thinking runs as a clickable `▸ Thought for <n>s`
41
+ * summary row instead of the zero-trace collapse (requires
42
+ * hideThinkingLabel; duration only for runs streamed live). */
43
+ thoughtSummary?: boolean;
40
44
  /** Inline previews for user-prompt images (ADR 0008); gates append and render. */
41
45
  showImagePreviews?: boolean;
42
46
  /** Clipboard image input (ADR 0009): upgrade built-in paste temp paths to attachments. */
@@ -101,6 +105,10 @@ export interface NormalizedPiStyleConfig {
101
105
  assistantPrefix: boolean;
102
106
  specialBlocks: boolean;
103
107
  hideThinkingLabel: boolean;
108
+ /** Surface completed thinking runs as a clickable `▸ Thought for <n>s`
109
+ * summary row instead of the zero-trace collapse (requires
110
+ * hideThinkingLabel; duration only for runs streamed live). */
111
+ thoughtSummary: boolean;
104
112
  /** Inline previews for user-prompt images (ADR 0008); gates append and render. */
105
113
  showImagePreviews: boolean;
106
114
  /** Clipboard image input (ADR 0009): upgrade built-in paste temp paths to attachments. */
@@ -1,4 +1,7 @@
1
+ import { Text } from "@earendil-works/pi-tui";
1
2
  import { visibleWidth } from "../../shared/ansi.js";
3
+ import type { BoxTheme } from "../../shared/box.js";
4
+ import { formatElapsedMs } from "../../shared/elapsed.js";
2
5
 
3
6
  const OSC133_ZONE_START = "\x1b]133;A\x07";
4
7
  const OSC133_ZONE_END = "\x1b]133;B\x07";
@@ -364,12 +367,18 @@ function decorateMessageLine(
364
367
  prefix: string;
365
368
  prefixWidth: number;
366
369
  continuationLead: string;
370
+ /** Rail range (inclusive): lines of an expanded leading thinking region
371
+ * that render with the role-prefix rail instead of no lead. */
372
+ railStart: number | undefined;
373
+ railEnd: number | undefined;
367
374
  },
368
375
  analysis = getLineAnalysis(line),
369
376
  ): string {
370
- const { firstEnvelope, firstHasStart, multilineEnvelope, prefix, prefixWidth, continuationLead } = options;
371
- const lead = index === contentIndex ? prefix : index > contentIndex ? continuationLead : "";
372
- const leadWidth = index < contentIndex ? 0 : prefixWidth;
377
+ const { firstEnvelope, firstHasStart, multilineEnvelope, prefix, prefixWidth, continuationLead, railStart, railEnd } =
378
+ options;
379
+ const railed = railStart !== undefined && railEnd !== undefined && index >= railStart && index <= railEnd;
380
+ const lead = railed ? prefix : index === contentIndex ? prefix : index > contentIndex ? continuationLead : "";
381
+ const leadWidth = railed || index >= contentIndex ? prefixWidth : 0;
373
382
  if (index === contentIndex && firstEnvelope)
374
383
  return `${firstEnvelope.start}${rebuildAtWidth(firstEnvelope.body, width, prefix, prefixWidth)}${firstEnvelope.end}`;
375
384
  if (index === contentIndex && firstHasStart)
@@ -405,8 +414,8 @@ function sameLines(left: readonly string[], right: readonly string[]): boolean {
405
414
  return true;
406
415
  }
407
416
 
408
- function cacheKey(width: number, prefix: string): string {
409
- return `${width}\u0000${prefix}`;
417
+ function cacheKey(width: number, prefix: string, skip: number | undefined): string {
418
+ return `${width}\u0000${prefix}\u0000${skip ?? ""}`;
410
419
  }
411
420
 
412
421
  function getRenderCache(instance: object): Map<string, DecoratedRenderCacheEntry> {
@@ -424,9 +433,10 @@ function storeRenderCache(
424
433
  prefix: string,
425
434
  native: readonly string[],
426
435
  result: readonly string[],
436
+ skip: number | undefined,
427
437
  ): void {
428
438
  const cache = getRenderCache(instance);
429
- const key = cacheKey(width, prefix);
439
+ const key = cacheKey(width, prefix, skip);
430
440
  if (cache.has(key)) cache.delete(key);
431
441
  cache.set(key, { nativeRef: native, nativeLines: [...native], result: [...result] });
432
442
  while (cache.size > MAX_RENDER_CACHE_KEYS_PER_INSTANCE) {
@@ -436,7 +446,51 @@ function storeRenderCache(
436
446
  }
437
447
  }
438
448
 
439
- function prefixNative(lines: unknown, width: number, prefix: string): string[] | undefined {
449
+ /** Whether a rendered line's visible text is a thought-summary row (collapsed
450
+ * label or expanded header). Such rows carry their own `▸`/`∨` (or ASCII
451
+ * `>`/`v`) state glyph, so the assistant-role prefix skips them: the glyph
452
+ * marks the row, `│ ` keeps marking the message's first real content line. */
453
+ function isThoughtSummaryRenderLine(line: string, glyph: string): boolean {
454
+ return contentText(line).trim().startsWith(`${glyph} Thought`);
455
+ }
456
+
457
+ /** Leading thought-region children (top spacer, label/header rows, expanded
458
+ * thinking content, trailing spacer) of instances whose message STARTS with
459
+ * an expanded thinking run. At render time their line count tells the prefix
460
+ * decoration exactly where the first answer line begins, so thinking content
461
+ * never receives the `│ ` role prefix. */
462
+ type ThoughtLeadingSkip = { children: readonly object[] };
463
+
464
+ let thoughtLeadingSkipByInstance = new WeakMap<object, ThoughtLeadingSkip>();
465
+
466
+ /** Render-time line accounting over the recorded leading children; `undefined`
467
+ * when the state is absent or a child cannot be rendered (fall back to the
468
+ * content scan). Children render deterministically and pi-tui caches renders
469
+ * by width, so the accounting re-uses the upcoming full-render work. */
470
+ function leadingSkipLineCount(instance: object, width: number): number | undefined {
471
+ const state = thoughtLeadingSkipByInstance.get(instance);
472
+ if (!state || state.children.length === 0) return undefined;
473
+ let count = 0;
474
+ for (const child of state.children) {
475
+ const render = (child as { render?: unknown }).render;
476
+ if (typeof render !== "function") return undefined;
477
+ const lines = (render as (width: number) => unknown).call(child, width);
478
+ if (!Array.isArray(lines) || !lines.every((line) => typeof line === "string")) return undefined;
479
+ count += lines.length;
480
+ }
481
+ return count;
482
+ }
483
+
484
+ function prefixNative(
485
+ lines: unknown,
486
+ width: number,
487
+ prefix: string,
488
+ thoughtGlyph: string | undefined,
489
+ /** Structural first-content override: when an expanded thinking block leads
490
+ * the message, the prefix must land on the answer's first line, not on the
491
+ * thinking content — computed by child accounting in decorateMessageRender. */
492
+ forcedFirstContentIndex: number | undefined,
493
+ ): string[] | undefined {
440
494
  if (!Array.isArray(lines) || lines.length === 0 || !lines.every((line) => typeof line === "string")) return undefined;
441
495
  messageDecorationTestState.decoratePasses++;
442
496
  const nativeLines = lines as string[];
@@ -455,30 +509,81 @@ function prefixNative(lines: unknown, width: number, prefix: string): string[] |
455
509
  // sits on the final line ([OSC133_A, OSC133_END+FINAL+body]); excluding it
456
510
  // would drop the prefix for every short assistant reply.
457
511
  let firstContentIndex = -1;
458
- for (let index = 0; index < nativeLines.length; index++) {
459
- const analysis = analyses[index] ?? getLineAnalysis(nativeLines[index] ?? "");
460
- if (index !== nativeLines.length - 1 || !multilineEnvelope) {
461
- if (analysis.hasContent) {
462
- firstContentIndex = index;
463
- break;
464
- }
465
- continue;
512
+ let scanForFirstContent = true;
513
+ if (forcedFirstContentIndex !== undefined) {
514
+ if (forcedFirstContentIndex >= nativeLines.length) return nativeLines;
515
+ const forcedAnalysis =
516
+ analyses[forcedFirstContentIndex] ?? getLineAnalysis(nativeLines[forcedFirstContentIndex] ?? "");
517
+ if (forcedAnalysis.hasContent) {
518
+ firstContentIndex = forcedFirstContentIndex;
519
+ scanForFirstContent = false;
466
520
  }
467
- let earlierHasContent = false;
468
- for (let earlier = 0; earlier < index; earlier++) {
469
- if ((analyses[earlier] ?? getLineAnalysis(nativeLines[earlier] ?? "")).hasContent) {
470
- earlierHasContent = true;
471
- break;
521
+ }
522
+ if (scanForFirstContent)
523
+ for (let index = 0; index < nativeLines.length; index++) {
524
+ const analysis = analyses[index] ?? getLineAnalysis(nativeLines[index] ?? "");
525
+ if (index !== nativeLines.length - 1 || !multilineEnvelope) {
526
+ if (analysis.hasContent) {
527
+ // Thought-summary rows render with their own state glyph and take the
528
+ // continuation indent instead of the role prefix.
529
+ if (thoughtGlyph && isThoughtSummaryRenderLine(nativeLines[index] ?? "", thoughtGlyph)) continue;
530
+ firstContentIndex = index;
531
+ break;
532
+ }
533
+ continue;
534
+ }
535
+ let earlierHasContent = false;
536
+ for (let earlier = 0; earlier < index; earlier++) {
537
+ const earlierAnalysis = analyses[earlier] ?? getLineAnalysis(nativeLines[earlier] ?? "");
538
+ // Thought-summary rows are content-start candidates themselves, so they do
539
+ // not count as "earlier content" either — otherwise the skipped row would
540
+ // suppress the prefix on the first real content line.
541
+ if (
542
+ earlierAnalysis.hasContent &&
543
+ !(thoughtGlyph && isThoughtSummaryRenderLine(nativeLines[earlier] ?? "", thoughtGlyph))
544
+ ) {
545
+ earlierHasContent = true;
546
+ break;
547
+ }
548
+ }
549
+ if (
550
+ !earlierHasContent &&
551
+ lastAnalysis.hasContent &&
552
+ !(thoughtGlyph && isThoughtSummaryRenderLine(last, thoughtGlyph))
553
+ ) {
554
+ firstContentIndex = index;
472
555
  }
556
+ break;
473
557
  }
474
- if (!earlierHasContent && lastAnalysis.hasContent) firstContentIndex = index;
475
- break;
476
- }
477
558
  if (firstContentIndex < 0) return nativeLines;
478
559
  const firstAnalysis = analyses[0] ?? getLineAnalysis(nativeLines[0] ?? "");
479
560
  const firstEnvelope = firstContentIndex === 0 ? firstAnalysis.oscEnvelope : undefined;
480
561
  const firstHasStart = firstContentIndex === 0 && firstAnalysis.hasOscStart;
481
562
  const continuationLead = " ".repeat(prefixWidth);
563
+ // Rail range: when a leading expanded thinking block precedes the first
564
+ // content line, its content lines (after the summary header, up to the
565
+ // region's last content line — blank lines inside keep the rail for a
566
+ // continuous quote bar, trailing blanks do not) render with the `│ ` rail.
567
+ let railStart: number | undefined;
568
+ let railEnd: number | undefined;
569
+ if (forcedFirstContentIndex !== undefined && forcedFirstContentIndex > 0) {
570
+ let headerIndex = -1;
571
+ let lastContent = -1;
572
+ for (let regionIndex = 0; regionIndex < forcedFirstContentIndex; regionIndex++) {
573
+ const regionLine = nativeLines[regionIndex] ?? "";
574
+ const regionAnalysis = analyses[regionIndex] ?? getLineAnalysis(regionLine);
575
+ if (!regionAnalysis.hasContent) continue;
576
+ if (headerIndex < 0 && thoughtGlyph && isThoughtSummaryRenderLine(regionLine, thoughtGlyph)) {
577
+ headerIndex = regionIndex;
578
+ continue;
579
+ }
580
+ lastContent = regionIndex;
581
+ }
582
+ if (headerIndex >= 0 && lastContent > headerIndex) {
583
+ railStart = headerIndex + 1;
584
+ railEnd = lastContent;
585
+ }
586
+ }
482
587
  const decorated = nativeLines.map((line, index) =>
483
588
  decorateMessageLine(
484
589
  line,
@@ -493,6 +598,8 @@ function prefixNative(lines: unknown, width: number, prefix: string): string[] |
493
598
  prefix,
494
599
  prefixWidth,
495
600
  continuationLead,
601
+ railStart,
602
+ railEnd,
496
603
  },
497
604
  analyses[index],
498
605
  ),
@@ -507,6 +614,15 @@ export type MessageDecorationSnapshot = Readonly<{
507
614
  assistantEnabled: boolean;
508
615
  /** Drop the hidden-thinking label row and its trailing spacer (zero-trace collapse). */
509
616
  collapseHiddenThinking: boolean;
617
+ /** Replace the blanked label of a COMPLETED thinking run with a clickable
618
+ * `<glyph> Thought for <n>s` summary (runs still streaming keep the zero-trace
619
+ * collapse). Requires collapseHiddenThinking; no session theme → zero-trace. */
620
+ thoughtSummary?: boolean;
621
+ /** Glyph for the thought summary row — one glyph for both states (the
622
+ * content below an expanded header is what distinguishes them). Unicode
623
+ * `◈` by default (`>` in ASCII mode; U+23F5 ⏵ was rejected for spotty
624
+ * monospace-font coverage — swap here if a variant is ever wanted). */
625
+ thoughtGlyph?: string;
510
626
  }>;
511
627
 
512
628
  export function __getMessageDecorationTestState(): Readonly<MessageDecorationTestState> {
@@ -522,6 +638,10 @@ export function __resetMessageDecorationTestState(): void {
522
638
  renderCacheByInstance = new WeakMap<object, Map<string, DecoratedRenderCacheEntry>>();
523
639
  lineAnalysisCache = new Map<string, LineAnalysis>();
524
640
  lineCacheEvictionCursor = undefined;
641
+ thoughtTimingByInstance = new WeakMap<object, ThoughtTimingState>();
642
+ thoughtDurationsBySignature = new Map<string, number>();
643
+ thoughtLeadingSkipByInstance = new WeakMap<object, ThoughtLeadingSkip>();
644
+ sessionThoughtTheme = undefined;
525
645
  childrenScanByInstance = new WeakMap<object, ChildrenScanState>();
526
646
  }
527
647
 
@@ -546,14 +666,17 @@ export function decorateMessageRender(
546
666
  const reducedWidth = width - prefixWidth;
547
667
  const native = Reflect.apply(original, instance, [reducedWidth, ...args.slice(1)]);
548
668
  if (!Array.isArray(native) || !native.every((line) => typeof line === "string")) return native;
549
- const cached = getRenderCache(instance).get(cacheKey(width, prefix));
669
+ // One accounting per pass (children render deterministically; pi-tui caches
670
+ // renders by width, so the accounting re-uses the full-render work).
671
+ const leadingSkip = leadingSkipLineCount(instance, reducedWidth);
672
+ const cached = getRenderCache(instance).get(cacheKey(width, prefix, leadingSkip));
550
673
  if (cached && (cached.nativeRef === native || sameLines(cached.nativeLines, native))) {
551
674
  messageDecorationTestState.cacheHits++;
552
675
  return [...cached.result];
553
676
  }
554
677
  messageDecorationTestState.cacheMisses++;
555
- const decorated = prefixNative(native, width, prefix) ?? native;
556
- storeRenderCache(instance, width, prefix, native, decorated);
678
+ const decorated = prefixNative(native, width, prefix, snapshot.thoughtGlyph, leadingSkip) ?? native;
679
+ storeRenderCache(instance, width, prefix, native, decorated, leadingSkip);
557
680
  return decorated;
558
681
  }
559
682
 
@@ -567,9 +690,21 @@ function isSpacerChild(child: unknown): boolean {
567
690
  * rendered content is empty. Duck-typed on the public shape because
568
691
  * pi-coding-agent may resolve its own nested pi-tui copy, so `instanceof`
569
692
  * across that module boundary is unreliable.
693
+ *
694
+ * Pi 0.85.0 wraps every thinking-run component (the hidden label Text or the
695
+ * visible thinking Markdown) in a `MouseRegion` for click-to-toggle visibility.
696
+ * `MouseRegion` is render-transparent, so the placeholder check unwraps it first
697
+ * (duck-typed: a `handleMouse` function plus a `child`), keeping the same
698
+ * Text detection for the pre-0.85 bare-Text layout.
570
699
  */
700
+ function unwrapMouseRegion(child: unknown): unknown {
701
+ const candidate = child as { handleMouse?: unknown; child?: unknown } | undefined;
702
+ if (typeof candidate?.handleMouse !== "function" || candidate.child === undefined) return child;
703
+ return candidate.child;
704
+ }
705
+
571
706
  function isBlankTextChild(child: unknown): boolean {
572
- const candidate = child as
707
+ const candidate = unwrapMouseRegion(child) as
573
708
  | { setCustomBgFn?: unknown; render?: (width: number) => string[]; text?: unknown }
574
709
  | undefined;
575
710
  if (typeof candidate?.setCustomBgFn !== "function" || typeof candidate.render !== "function") return false;
@@ -601,17 +736,209 @@ function markChildrenScanned(instance: object, children: readonly unknown[]): vo
601
736
  }
602
737
 
603
738
  /**
604
- * Collapse Pi's hidden-thinking placeholder row to zero trace.
739
+ * Per-instance thinking-run timing for the thought summary label. `startedAt`
740
+ * is the first updateContent pass that observed the run, `endedAt` the first
741
+ * pass that observed it complete, and `streamed` whether a pass ever saw the
742
+ * run mid-stream. Durations are only shown for runs the extension watched
743
+ * stream live: messages restored from history (resume/scroll-back rebuilds)
744
+ * are first observed already complete, so they fall back to a duration-less
745
+ * `Thought` label instead of a fabricated number.
746
+ */
747
+ interface ThoughtRunTiming {
748
+ startedAt: number;
749
+ endedAt: number | undefined;
750
+ streamed: boolean;
751
+ }
752
+ type ThoughtTimingState = { runs: Map<number, ThoughtRunTiming> };
753
+
754
+ let thoughtTimingByInstance = new WeakMap<object, ThoughtTimingState>();
755
+
756
+ /**
757
+ * Completed-run durations keyed by thinking-content signature. Pi's `agent_end`
758
+ * removes the streaming AssistantMessageComponent and the history re-render
759
+ * rebuilds every message as a fresh component, so per-instance timing never
760
+ * reaches the component the user actually sees. A finalized run's duration is
761
+ * therefore also recorded under a content signature (length + head/tail of the
762
+ * run's thinking text), and a replacement component first observing the run
763
+ * already complete looks the duration up instead of falling back to the
764
+ * duration-less label. Insertion-order LRU, bounded: real reasoning text never
765
+ * repeats across messages, so a collision would require identical content
766
+ * (harmless — identical content earns the same duration). Truly new processes
767
+ * (resume/restart) start with an empty registry and keep the duration-less
768
+ * fallback by design.
769
+ */
770
+ const THOUGHT_DURATION_REGISTRY_LIMIT = 256;
771
+ let thoughtDurationsBySignature = new Map<string, number>();
772
+
773
+ function thoughtDurationKey(runIndex: number, text: string): string {
774
+ const head = text.slice(0, 64);
775
+ const tail = text.length > 64 ? text.slice(-64) : "";
776
+ return `#${runIndex}:${text.length}:${head}⋮${tail}`;
777
+ }
778
+
779
+ function recordThoughtDuration(key: string, durationMs: number): void {
780
+ if (thoughtDurationsBySignature.has(key)) thoughtDurationsBySignature.delete(key);
781
+ thoughtDurationsBySignature.set(key, durationMs);
782
+ if (thoughtDurationsBySignature.size > THOUGHT_DURATION_REGISTRY_LIMIT) {
783
+ const oldest = thoughtDurationsBySignature.keys().next().value;
784
+ if (oldest !== undefined) thoughtDurationsBySignature.delete(oldest);
785
+ }
786
+ }
787
+
788
+ /** Session theme for the thought summary label, cached per session by the
789
+ * session coordinator (never read during render). No theme → zero-trace. */
790
+ let sessionThoughtTheme: BoxTheme | undefined;
791
+
792
+ export function setThoughtLabelTheme(theme: BoxTheme | undefined): void {
793
+ sessionThoughtTheme = theme;
794
+ }
795
+
796
+ /** Thinking-run layout of an assistant message, mirroring the native
797
+ * `updateContent` grouping: maximal runs of consecutive thinking blocks with
798
+ * at least one non-empty string (all-empty groups render no child and do not
799
+ * consume a run index). `complete[k]` marks runs that can no longer grow;
800
+ * `texts[k]` is the run's joined thinking text (content-signature key). */
801
+ function parseThinkingRuns(
802
+ message: unknown,
803
+ streaming: boolean,
804
+ ): { count: number; complete: boolean[]; texts: string[] } {
805
+ const complete: boolean[] = [];
806
+ const texts: string[] = [];
807
+ const content = (message as { content?: unknown } | undefined)?.content;
808
+ if (!Array.isArray(content)) return { count: 0, complete, texts };
809
+ // One pass, tracking whether any substantive non-thinking block follows the
810
+ // current (still-open) run — that, another run starting, or a finalized
811
+ // message (isStreaming false / stopReason set) closes it.
812
+ let openRun = -1;
813
+ let openText = "";
814
+ const stopReason = (message as { stopReason?: unknown } | undefined)?.stopReason;
815
+ const closeRun = () => {
816
+ if (openRun < 0) return;
817
+ texts[openRun] = openText;
818
+ openRun = -1;
819
+ openText = "";
820
+ };
821
+ for (let index = 0; index < content.length; index++) {
822
+ const block = content[index] as { type?: unknown; thinking?: unknown; text?: unknown } | undefined;
823
+ if (!block || typeof block !== "object") continue;
824
+ if (block.type === "thinking") {
825
+ if (typeof block.thinking === "string" && block.thinking.trim() !== "") {
826
+ if (openRun < 0) {
827
+ openRun = complete.length;
828
+ complete.push(false);
829
+ texts.push("");
830
+ }
831
+ openText = openText === "" ? block.thinking : `${openText}\n\n${block.thinking}`;
832
+ }
833
+ continue;
834
+ }
835
+ // Non-thinking block: a substantive text block or any tool call closes the
836
+ // open run (whitespace-only text renders nothing and is not a boundary).
837
+ const closes =
838
+ block.type === "toolCall" ||
839
+ (block.type === "text" && typeof block.text === "string" && block.text.trim() !== "");
840
+ if (openRun >= 0 && closes) {
841
+ complete[openRun] = true;
842
+ closeRun();
843
+ }
844
+ }
845
+ if (openRun >= 0 && (!streaming || stopReason)) {
846
+ complete[openRun] = true;
847
+ closeRun();
848
+ } else if (openRun >= 0) {
849
+ texts[openRun] = openText;
850
+ }
851
+ return { count: complete.length, complete, texts };
852
+ }
853
+
854
+ /** Fold one observed pass into the timing state. Returns per-run durations:
855
+ * a live-streamed run carries its measured duration (also recorded in the
856
+ * content-signature registry), and a replacement component first observing a
857
+ * run already complete recovers the recorded duration from the registry —
858
+ * only truly unknown runs (new process / resume) stay `undefined`. */
859
+ function updateThoughtTiming(
860
+ instance: object,
861
+ runs: { count: number; complete: boolean[]; texts: string[] },
862
+ ): (number | undefined)[] {
863
+ let state = thoughtTimingByInstance.get(instance);
864
+ if (!state) {
865
+ state = { runs: new Map() };
866
+ thoughtTimingByInstance.set(instance, state);
867
+ }
868
+ const now = Date.now();
869
+ const durations: (number | undefined)[] = [];
870
+ for (let index = 0; index < runs.count; index++) {
871
+ const complete = runs.complete[index] === true;
872
+ const key = thoughtDurationKey(index, runs.texts[index] ?? "");
873
+ const entry = state.runs.get(index);
874
+ if (!entry) {
875
+ state.runs.set(index, { startedAt: now, endedAt: undefined, streamed: !complete });
876
+ // First observation already complete (history rebuild / replacement
877
+ // component): recover the recorded duration, if any.
878
+ if (complete) durations[index] = thoughtDurationsBySignature.get(key);
879
+ continue;
880
+ }
881
+ if (!complete) {
882
+ entry.streamed = true;
883
+ continue;
884
+ }
885
+ if (entry.endedAt === undefined) entry.endedAt = now;
886
+ if (entry.streamed) {
887
+ const duration = entry.endedAt - entry.startedAt;
888
+ recordThoughtDuration(key, duration);
889
+ durations[index] = duration;
890
+ } else {
891
+ // This instance never saw the run stream, but a predecessor may have.
892
+ durations[index] = thoughtDurationsBySignature.get(key);
893
+ }
894
+ }
895
+ return durations;
896
+ }
897
+
898
+ function thoughtLabelText(glyph: string, durationMs: number | undefined): string {
899
+ return durationMs === undefined ? `${glyph} Thought` : `${glyph} Thought for ${formatElapsedMs(durationMs)}`;
900
+ }
901
+
902
+ function styleThoughtText(text: string): string {
903
+ if (!sessionThoughtTheme) return text;
904
+ const colored = sessionThoughtTheme.fg("thinkingText", text);
905
+ return sessionThoughtTheme.italic ? sessionThoughtTheme.italic(colored) : colored;
906
+ }
907
+
908
+ /** Whether an (unwrapped) child is a Text-like component (the hidden label / error rows). */
909
+ function isTextComponent(child: unknown): boolean {
910
+ return typeof (child as { setCustomBgFn?: unknown } | undefined)?.setCustomBgFn === "function";
911
+ }
912
+
913
+ /**
914
+ * Collapse Pi's hidden-thinking placeholder row to zero trace, and — once a
915
+ * thinking run completes — surface it as a clickable `▸ Thought for <n>s`
916
+ * summary instead (`messages.thoughtSummary`).
605
917
  *
606
918
  * Native `AssistantMessageComponent.updateContent` renders the thinking block as
607
919
  * `Text(theme.italic(theme.fg("thinkingText", label)), outputPad, 0)` plus a
608
- * trailing `Spacer(1)`. An empty label is still wrapped in ANSI SGR codes, so
920
+ * trailing `Spacer(1)` wrapped in a render-transparent `MouseRegion` since
921
+ * Pi 0.85.0. An empty label is still wrapped in ANSI SGR codes, so
609
922
  * `Text.render` cannot treat it as empty (its check is `text.trim() === ""`,
610
923
  * and trim does not strip escape sequences) and emits one full-width invisible
611
924
  * line. That invisible row plus the surrounding spacers is the "gap" left when
612
- * the label is hidden. This wrapper runs the native layout, then drops the
613
- * invisible label row and the spacer the native layout appends after the
614
- * thinking run, leaving the same single top padding as a text-only message.
925
+ * the label is hidden. This wrapper runs the native layout, then:
926
+ *
927
+ * - a run still streaming (thinking is the trailing content and the message is
928
+ * not finalized) keeps the zero-trace collapse: the invisible label row and
929
+ * the spacer after it are dropped, leaving the same single top padding as a
930
+ * text-only message;
931
+ * - a completed run keeps its `MouseRegion`-wrapped label row and rewrites it
932
+ * to the styled summary, so Pi's native click-to-toggle keeps working (the
933
+ * `>`/`▸` glyph hints at it);
934
+ * - a completed run the user expanded re-gets a `∨ Thought for <n>s` header row
935
+ * above the thinking content, mirroring the collapsed summary.
936
+ *
937
+ * Runs first observed already complete by a NEW process (resume, restart) never
938
+ * carried a measured duration and render the duration-less `Thought` variant;
939
+ * within the same process, Pi's history rebuilds (which replace the streaming
940
+ * component at `agent_end`) recover the recorded duration via the
941
+ * content-signature registry.
615
942
  */
616
943
  export function decorateMessageUpdate(
617
944
  original: unknown,
@@ -628,6 +955,8 @@ export function decorateMessageUpdate(
628
955
  const target = instance as {
629
956
  hideThinkingBlock?: boolean;
630
957
  hiddenThinkingLabel?: string;
958
+ isStreaming?: boolean;
959
+ outputPad?: number;
631
960
  contentContainer?: { children?: unknown[] };
632
961
  };
633
962
  const children = target.contentContainer?.children;
@@ -635,12 +964,97 @@ export function decorateMessageUpdate(
635
964
  // Only meaningful when Pi renders the hidden-block label (hideThinkingBlock)
636
965
  // and the extension has blanked that label out ("" — the zero-trace mode).
637
966
  if (snapshot.collapseHiddenThinking && target.hideThinkingBlock === true && target.hiddenThinkingLabel === "") {
967
+ // `updateContent` stores the effective streaming flag on the instance
968
+ // (explicit arg or its own default), so it is current after the native call.
969
+ const runs = parseThinkingRuns(args[0], target.isStreaming !== false);
970
+ const durations = updateThoughtTiming(instance, runs);
971
+ const summary = Boolean(snapshot.thoughtSummary) && sessionThoughtTheme !== undefined;
972
+ const glyph = snapshot.thoughtGlyph ?? "◈";
973
+ // Children are laid out in content order, thinking runs (hidden label or
974
+ // expanded Markdown, each in a MouseRegion) in run order; walking backward
975
+ // keeps splice/insert indices valid and assigns runs from the last.
976
+ let runCursor = runs.count - 1;
977
+ let expandedRunSeen = false;
638
978
  for (let index = children.length - 1; index >= 0; index--) {
639
- if (!isBlankTextChild(children[index])) continue;
640
- children.splice(index, 1);
641
- // Drop the Spacer(1) the native layout appends after the thinking run when
642
- // another visible block follows; the message keeps only its shared top padding.
643
- if (isSpacerChild(children[index])) children.splice(index, 1);
979
+ const child = children[index];
980
+ if (isSpacerChild(child)) continue;
981
+ const region = unwrapMouseRegion(child) !== child ? (child as { child: unknown }) : undefined;
982
+ const inner = region?.child;
983
+ if (isBlankTextChild(child)) {
984
+ // Hidden thinking-run label (MouseRegion-wrapped blank Text).
985
+ const run = runCursor--;
986
+ if (run < 0) continue;
987
+ if (summary && runs.complete[run]) {
988
+ // Rewrite the label in place: the row (and its MouseRegion click
989
+ // toggle) stays, now carrying the completed-run summary.
990
+ const text = thoughtLabelText(glyph, durations[run]);
991
+ (inner as { setText?: (text: string) => void } | undefined)?.setText?.(styleThoughtText(text));
992
+ continue;
993
+ }
994
+ children.splice(index, 1);
995
+ // Drop the Spacer(1) the native layout appends after the thinking run when
996
+ // another visible block follows; the message keeps only its shared top padding.
997
+ if (isSpacerChild(children[index])) children.splice(index, 1);
998
+ } else if (
999
+ region &&
1000
+ inner !== undefined &&
1001
+ typeof (inner as { render?: unknown }).render === "function" &&
1002
+ !isTextComponent(inner)
1003
+ ) {
1004
+ // Expanded thinking run: MouseRegion(Markdown). Give a completed run a
1005
+ // summary header above its content, mirroring the collapsed label.
1006
+ const run = runCursor--;
1007
+ expandedRunSeen = true;
1008
+ if (run >= 0 && summary && runs.complete[run]) {
1009
+ const marker = new Text(
1010
+ styleThoughtText(thoughtLabelText(glyph, durations[run])),
1011
+ typeof target.outputPad === "number" ? target.outputPad : 1,
1012
+ 0,
1013
+ );
1014
+ children.splice(index, 0, marker);
1015
+ }
1016
+ }
1017
+ }
1018
+ // When an expanded thinking block leads the message, the role prefix must
1019
+ // land on the answer's first line (not the thinking content): record the
1020
+ // leading thought-region children for render-time line accounting. The
1021
+ // collapsed-only layout needs no accounting — the summary row itself is
1022
+ // skipped as a thought-summary line by the render decoration.
1023
+ if (summary && expandedRunSeen) {
1024
+ const leading: object[] = [];
1025
+ for (const rawChild of children) {
1026
+ const child = rawChild as object;
1027
+ if (isSpacerChild(child)) {
1028
+ leading.push(child);
1029
+ continue;
1030
+ }
1031
+ const region = unwrapMouseRegion(child) !== child ? (child as { child?: unknown }) : undefined;
1032
+ const inner = region?.child;
1033
+ if (
1034
+ region &&
1035
+ inner !== undefined &&
1036
+ typeof (inner as { render?: unknown }).render === "function" &&
1037
+ !isTextComponent(inner)
1038
+ ) {
1039
+ // Expanded thinking content (MouseRegion-wrapped Markdown).
1040
+ leading.push(child);
1041
+ continue;
1042
+ }
1043
+ if (region && isTextComponent(inner)) {
1044
+ // Collapsed label row (MouseRegion-wrapped Text).
1045
+ leading.push(child);
1046
+ continue;
1047
+ }
1048
+ if (!region && isTextComponent(child)) {
1049
+ // Our inserted header row (bare Text).
1050
+ leading.push(child);
1051
+ continue;
1052
+ }
1053
+ break; // First answer Markdown (or anything unexpected) ends the region.
1054
+ }
1055
+ thoughtLeadingSkipByInstance.set(instance, { children: leading });
1056
+ } else {
1057
+ thoughtLeadingSkipByInstance.delete(instance);
644
1058
  }
645
1059
  }
646
1060
  markChildrenScanned(instance, children);