@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.
- package/CHANGELOG.md +23 -0
- package/README.md +2 -2
- package/dist/extensions/pi-style.js +8900 -8464
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/command-service.ts +2 -0
- package/extension-src/pi-style/domain/config-normalization.ts +3 -0
- package/extension-src/pi-style/domain/config-types.ts +8 -0
- package/extension-src/pi-style/features/messages/index.ts +451 -37
- package/extension-src/pi-style/features/messages/special-blocks.ts +2 -22
- package/extension-src/pi-style/features/startup/index.ts +24 -4
- package/extension-src/pi-style/features/startup/logo.ts +22 -18
- package/extension-src/pi-style/features/tools/bash-execution.ts +24 -8
- package/extension-src/pi-style/features/tools/boxed/edit.ts +25 -30
- package/extension-src/pi-style/features/tools/boxed/git.ts +21 -13
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +35 -38
- package/extension-src/pi-style/features/tools/boxed/shared.ts +34 -0
- package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +114 -38
- package/extension-src/pi-style/features/tools/boxed/write.ts +1 -0
- package/extension-src/pi-style/features/tools/index.ts +10 -6
- package/extension-src/pi-style/pi/compatibility-coordinator.ts +2 -0
- package/extension-src/pi-style/pi/compatibility-probe.ts +88 -19
- package/extension-src/pi-style/pi/session-coordinator.ts +20 -1
- package/extension-src/pi-style/shared/ansi.ts +45 -0
- package/extension-src/pi-style/shared/box.ts +126 -47
- 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 } =
|
|
371
|
-
|
|
372
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
if (
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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)
|
|
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
|
|
613
|
-
*
|
|
614
|
-
*
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
if (
|
|
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);
|