@heyhuynhgiabuu/pi-pretty 0.6.25 → 0.6.26

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.
@@ -366,6 +366,22 @@ export function buildWorkingFrames(
366
366
  return { frames, intervalMs };
367
367
  }
368
368
 
369
+ /** Estimate streamed output tokens: provider `usage.output` when exposed, else visible chars ÷ 4. */
370
+ export function workingTokens(message: unknown): number {
371
+ const usage = (message as { usage?: { output?: unknown } } | undefined)?.usage;
372
+ const output = Number(usage?.output);
373
+ if (Number.isFinite(output) && output > 0) return Math.round(output);
374
+ const content = (message as { content?: Array<{ type?: string; text?: unknown; thinking?: unknown }> } | undefined)
375
+ ?.content;
376
+ if (!Array.isArray(content)) return 0;
377
+ let chars = 0;
378
+ for (const block of content) {
379
+ if (block?.type === "text" && typeof block.text === "string") chars += block.text.length;
380
+ else if (block?.type === "thinking" && typeof block.thinking === "string") chars += block.thinking.length;
381
+ }
382
+ return Math.round(chars / 4);
383
+ }
384
+
369
385
  // ─── Widget ──────────────────────────────────────────────────────────────────
370
386
 
371
387
  /** Slice of the pi-tui TUI instance the widget needs. */
@@ -386,6 +402,7 @@ export class WorkingWidget {
386
402
  #tui: WidgetTuiLike | undefined;
387
403
  #started = false;
388
404
  #disposed = false;
405
+ #stats: string | undefined;
389
406
 
390
407
  setFrames(frames: string[], intervalMs: number): void {
391
408
  this.#frames = frames;
@@ -397,6 +414,19 @@ export class WorkingWidget {
397
414
  this.#tui = tui;
398
415
  }
399
416
 
417
+ /** Ask the host for a render — lets non-widget consumers (the thinking label
418
+ * ticker) reuse this widget's TUI handle instead of relying on natural
419
+ * streaming renders. */
420
+ requestRender(): void {
421
+ this.#tui?.requestRender();
422
+ }
423
+
424
+ /** Dynamic right-side status segment (e.g. live token count), styled dim at
425
+ * render time so theme changes don't bake stale ANSI into it. */
426
+ setStats(text: string | undefined): void {
427
+ this.#stats = text || undefined;
428
+ }
429
+
400
430
  start(): void {
401
431
  if (this.#disposed || this.#started) return;
402
432
  this.#started = true;
@@ -421,7 +451,8 @@ export class WorkingWidget {
421
451
  render(width: number): string[] {
422
452
  if (!this.#started || this.#frames.length === 0) return [];
423
453
  const frame = this.#frames[this.#index % this.#frames.length] ?? "";
424
- return [truncateToWidth(frame, Math.max(1, width))];
454
+ const suffix = this.#stats ? `${FG_DIM}${this.#stats}${RESET_FG}` : "";
455
+ return [truncateToWidth(frame + suffix, Math.max(1, width))];
425
456
  }
426
457
 
427
458
  invalidate(): void {
@@ -462,17 +493,158 @@ export interface ThinkingUiLike {
462
493
  setHiddenThinkingLabel(label?: string): void;
463
494
  }
464
495
 
496
+ // ─── Per-row hidden-thinking labels ──────────────────────────────────────────
497
+
498
+ /** Controller for the host-component label interception. */
499
+ export interface PerRowThinkingLabels {
500
+ /** Mark the message currently streaming a thinking block (its row animates). */
501
+ setActive(timestamp: number): void;
502
+ /** Freeze a message's completed thinking duration in ms (its row shows
503
+ * `Thought for Xs` derived from this). */
504
+ complete(timestamp: number, durationMs: number): void;
505
+ /** Drop the active mark (run ended); completed rows keep their durations. */
506
+ clearActive(): void;
507
+ /** Restore the original prototype setter. */
508
+ uninstall(): void;
509
+ }
510
+
511
+ const PER_ROW_PATCH = Symbol.for("pi-pretty.per-row-thinking-labels");
512
+
513
+ interface RowInternals {
514
+ lastMessage?: { timestamp?: unknown };
515
+ }
516
+
517
+ /**
518
+ * Intercept the host's per-row label fan-out method
519
+ * (`AssistantMessageComponent.prototype.setHiddenThinkingLabel`): each row
520
+ * substitutes the incoming global label with its own — the streaming row
521
+ * animates, completed rows stay frozen at their own `Thought for Xs`, unknown
522
+ * rows fall back to pi's default. Any per-call failure passes the incoming
523
+ * label through unchanged, so a host internals change degrades to pi-pretty's
524
+ * global-label behavior instead of breaking. Durations live per session.
525
+ */
526
+ export function installPerRowThinkingLabels(componentClass: unknown): PerRowThinkingLabels | undefined {
527
+ const proto = (componentClass as { prototype?: { setHiddenThinkingLabel?: unknown } } | undefined)?.prototype;
528
+ const original = proto?.setHiddenThinkingLabel;
529
+ if (typeof componentClass !== "function" || !proto || typeof original !== "function") return undefined;
530
+ const existing = (original as { [PER_ROW_PATCH]?: PerRowThinkingLabels })[PER_ROW_PATCH];
531
+ // Reuse the installed patch but start from clean state: a session_start
532
+ // without an intervening shutdown (defensive) must not resurrect stale rows.
533
+ if (existing) {
534
+ existing.clearActive();
535
+ return existing;
536
+ }
537
+
538
+ const state: { activeTs?: number; completed: Map<number, number> } = { completed: new Map() };
539
+ const patched = function (this: unknown, label: string | undefined): void {
540
+ const own = ownLabel(this, label);
541
+ (original as (this: unknown, label: string | undefined) => void).call(this, own);
542
+ };
543
+ const ownLabel = (component: unknown, incoming: string | undefined): string | undefined => {
544
+ try {
545
+ const ts = (component as RowInternals | undefined)?.lastMessage?.timestamp;
546
+ if (typeof ts !== "number") return incoming;
547
+ if (ts === state.activeTs) return incoming;
548
+ const ms = state.completed.get(ts);
549
+ return ms === undefined ? THINKING_LABEL : `Thought for ${formatThinkingDuration(ms)}`;
550
+ } catch {
551
+ return incoming;
552
+ }
553
+ };
554
+ const controller: PerRowThinkingLabels = {
555
+ setActive(timestamp: number): void {
556
+ state.activeTs = timestamp;
557
+ },
558
+ complete(timestamp: number, durationMs: number): void {
559
+ state.completed.set(timestamp, durationMs);
560
+ },
561
+ clearActive(): void {
562
+ state.activeTs = undefined;
563
+ },
564
+ uninstall(): void {
565
+ if (proto.setHiddenThinkingLabel === patched) {
566
+ proto.setHiddenThinkingLabel = original as { setHiddenThinkingLabel?: unknown }["setHiddenThinkingLabel"];
567
+ }
568
+ state.completed.clear();
569
+ state.activeTs = undefined;
570
+ },
571
+ };
572
+ (patched as { [PER_ROW_PATCH]?: PerRowThinkingLabels })[PER_ROW_PATCH] = controller;
573
+ proto.setHiddenThinkingLabel = patched as NonNullable<{ setHiddenThinkingLabel?: unknown }["setHiddenThinkingLabel"]>;
574
+ return controller;
575
+ }
576
+
465
577
  export interface ThinkingLabelAnimator {
466
- /** Apply the next shimmer frame to the hidden-thinking label. */
467
- tick(): void;
578
+ /** Apply the next shimmer frame, rebuilding frames when the label changes. */
579
+ tick(label?: string): void;
580
+ /** Apply a non-animated label (the host supplies its normal thinking style). */
581
+ show(label: string): void;
468
582
  /** Restore pi's default static label. */
469
583
  restore(): void;
470
- /** Pre-rendered label frames (exposed for diagnostics). */
584
+ /** Current pre-rendered label frames (exposed for diagnostics). */
471
585
  readonly frames: readonly string[];
472
586
  }
473
587
 
588
+ export interface ThinkingTimer {
589
+ /** Render the active label with the current elapsed duration. */
590
+ tick(): void;
591
+ /** Freeze the elapsed duration; returns the total in milliseconds, or
592
+ * undefined when the timer was already terminal. */
593
+ complete(): number | undefined;
594
+ /** Restore pi's default label and make the timer terminal. */
595
+ restore(): void;
596
+ }
597
+
474
598
  const THINKING_LABEL = "Thinking...";
475
599
 
600
+ /** Format elapsed milliseconds as whole seconds with compact padded units. */
601
+ export function formatThinkingDuration(elapsedMs: number): string {
602
+ const totalSeconds = Math.floor(elapsedMs / 1000);
603
+ const seconds = totalSeconds % 60;
604
+ const totalMinutes = Math.floor(totalSeconds / 60);
605
+ const minutes = totalMinutes % 60;
606
+ const hours = Math.floor(totalMinutes / 60);
607
+ if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`;
608
+ if (totalMinutes > 0) return `${totalMinutes}m ${String(seconds).padStart(2, "0")}s`;
609
+ return `${seconds}s`;
610
+ }
611
+
612
+ /**
613
+ * Couple elapsed-time wording to the label animator through an injected clock.
614
+ * The timer starts on the first thinking delta, freezes once, and is terminal
615
+ * after completion or restore. `startElapsedMs` resumes a later run in the
616
+ * same message from its accumulated thinking time (per-message semantics:
617
+ * the host renders one label line per thinking run, all sharing the message
618
+ * label, so a fresh-zero restart would visibly rewind earlier lines).
619
+ */
620
+ export function createThinkingTimer(
621
+ animator: ThinkingLabelAnimator,
622
+ now: () => number = Date.now,
623
+ startElapsedMs = 0,
624
+ ): ThinkingTimer {
625
+ const startedAt = now() - startElapsedMs;
626
+ let terminal = false;
627
+ const elapsed = (): number => now() - startedAt;
628
+ const duration = (): string => formatThinkingDuration(elapsed());
629
+ return {
630
+ tick(): void {
631
+ if (terminal) return;
632
+ animator.tick(`${THINKING_LABEL} ${duration()}`);
633
+ },
634
+ complete(): number | undefined {
635
+ if (terminal) return undefined;
636
+ terminal = true;
637
+ const ms = elapsed();
638
+ animator.show(`Thought for ${formatThinkingDuration(ms)}`);
639
+ return ms;
640
+ },
641
+ restore(): void {
642
+ terminal = true;
643
+ animator.restore();
644
+ },
645
+ };
646
+ }
647
+
476
648
  /**
477
649
  * True while a streaming assistant message is currently emitting a thinking
478
650
  * block (its last content block). Used to gate label ticks to the thinking
@@ -489,9 +661,9 @@ export function thinkingBlockActive(message: unknown): boolean {
489
661
  * Animate pi's hidden-thinking label ("Thinking...") with the same shimmer as
490
662
  * the working row. Pi renders the label as static italic `thinkingText` text;
491
663
  * the only extension lever is `setHiddenThinkingLabel(label)`, which rebuilds
492
- * chat children — the streaming component already rebuilds per delta, so the
493
- * animator is driven at a throttled cadence (100ms) from index.ts while the
494
- * agent streams and thinking blocks are hidden.
664
+ * chat children, so index.ts drives it at 30fps only while the streaming
665
+ * message's last block is thinking. The elapsed label is rebuilt only when its
666
+ * whole-second text changes; the shimmer frame still advances every tick.
495
667
  *
496
668
  * Tiers: low = theme `thinkingText` (pi's own label look), mid/high = session
497
669
  * accent when enabled; every frame is italic like pi's label; no spinner.
@@ -504,6 +676,7 @@ export function createThinkingLabelAnimator(
504
676
  ): ThinkingLabelAnimator {
505
677
  const noop: ThinkingLabelAnimator = {
506
678
  tick() {},
679
+ show() {},
507
680
  restore() {},
508
681
  frames: [],
509
682
  };
@@ -519,25 +692,37 @@ export function createThinkingLabelAnimator(
519
692
  const accent = hexToAnsiFg(sessionAccentHex(sessionName));
520
693
  if (accent) ansi = { ...ansi, mid: accent, high: accent };
521
694
  }
522
- const { frames } = buildWorkingFrames([THINKING_LABEL], {
523
- mode: workSettings.mode,
524
- ansi,
525
- bold: workSettings.bold,
526
- spinner: false,
527
- italic: true,
528
- });
529
- if (frames.length === 0) return noop;
695
+ const buildLabelFrames = (label: string): string[] =>
696
+ buildWorkingFrames([label], {
697
+ mode: workSettings.mode,
698
+ ansi,
699
+ bold: workSettings.bold,
700
+ spinner: false,
701
+ italic: true,
702
+ }).frames;
703
+
704
+ let currentLabel = THINKING_LABEL;
705
+ let frames = buildLabelFrames(currentLabel);
530
706
 
531
707
  let index = 0;
532
- let applied = false;
708
+ let appliedLabel: string | undefined;
533
709
  return {
534
- frames,
535
- tick(): void {
536
- // A single frame (static mode) never changes — skip redundant rebuilds.
537
- if (applied && frames.length === 1) return;
710
+ get frames(): readonly string[] {
711
+ return frames;
712
+ },
713
+ tick(label = THINKING_LABEL): void {
714
+ if (label !== currentLabel) {
715
+ frames = buildLabelFrames(label);
716
+ currentLabel = label;
717
+ }
718
+ // A static frame changes only when its elapsed-time label changes.
719
+ if (appliedLabel === currentLabel && frames.length === 1) return;
538
720
  ui.setHiddenThinkingLabel(frames[index % frames.length]);
539
721
  index++;
540
- applied = true;
722
+ appliedLabel = currentLabel;
723
+ },
724
+ show(label: string): void {
725
+ ui.setHiddenThinkingLabel(label);
541
726
  },
542
727
  restore(): void {
543
728
  // undefined → pi falls back to its default static label.
@@ -576,6 +761,10 @@ export interface WorkingIndicatorController {
576
761
  stop(): void;
577
762
  /** Remove the widget and stop the animation. */
578
763
  dispose(): void;
764
+ /** Update the dim right-side status segment (live token count). */
765
+ setStats(text: string | undefined): void;
766
+ /** Request a host render via the widget's TUI handle. */
767
+ requestRender(): void;
579
768
  /** Pre-rendered frames (exposed for diagnostics). */
580
769
  readonly frames: readonly string[];
581
770
  }
@@ -639,6 +828,8 @@ export async function installWorkingIndicator(
639
828
  start() {},
640
829
  stop() {},
641
830
  dispose() {},
831
+ setStats() {},
832
+ requestRender() {},
642
833
  frames: [],
643
834
  };
644
835
  if (!settings.enabled) return noopController;
@@ -686,6 +877,8 @@ export async function installWorkingIndicator(
686
877
  widget.dispose();
687
878
  ui.setWidget(WIDGET_KEY, undefined);
688
879
  },
880
+ setStats: (text: string | undefined) => widget.setStats(text),
881
+ requestRender: () => widget.requestRender(),
689
882
  frames,
690
883
  };
691
884
  }