@heyhuynhgiabuu/pi-pretty 0.6.25 → 0.6.27

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 (61) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +47 -17
  3. package/dist/config.d.ts +16 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +30 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/fff.d.ts +16 -1
  8. package/dist/fff.d.ts.map +1 -1
  9. package/dist/fff.js +139 -33
  10. package/dist/fff.js.map +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +192 -43
  13. package/dist/index.js.map +1 -1
  14. package/dist/prompt-editor.d.ts +11 -0
  15. package/dist/prompt-editor.d.ts.map +1 -0
  16. package/dist/prompt-editor.js +78 -0
  17. package/dist/prompt-editor.js.map +1 -0
  18. package/dist/render.d.ts +9 -1
  19. package/dist/render.d.ts.map +1 -1
  20. package/dist/render.js +25 -9
  21. package/dist/render.js.map +1 -1
  22. package/dist/tools/bash.d.ts.map +1 -1
  23. package/dist/tools/bash.js +73 -30
  24. package/dist/tools/bash.js.map +1 -1
  25. package/dist/tools/find.d.ts.map +1 -1
  26. package/dist/tools/find.js +29 -10
  27. package/dist/tools/find.js.map +1 -1
  28. package/dist/tools/grep.d.ts.map +1 -1
  29. package/dist/tools/grep.js +20 -10
  30. package/dist/tools/grep.js.map +1 -1
  31. package/dist/tools/ls.d.ts.map +1 -1
  32. package/dist/tools/ls.js +10 -5
  33. package/dist/tools/ls.js.map +1 -1
  34. package/dist/tools/metrics.d.ts +6 -1
  35. package/dist/tools/metrics.d.ts.map +1 -1
  36. package/dist/tools/metrics.js +21 -8
  37. package/dist/tools/metrics.js.map +1 -1
  38. package/dist/tools/read.d.ts +2 -1
  39. package/dist/tools/read.d.ts.map +1 -1
  40. package/dist/tools/read.js +93 -30
  41. package/dist/tools/read.js.map +1 -1
  42. package/dist/types.d.ts +13 -3
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/working-indicator.d.ts +91 -26
  45. package/dist/working-indicator.d.ts.map +1 -1
  46. package/dist/working-indicator.js +259 -77
  47. package/dist/working-indicator.js.map +1 -1
  48. package/package.json +7 -6
  49. package/src/config.ts +51 -0
  50. package/src/fff.ts +158 -29
  51. package/src/index.ts +207 -47
  52. package/src/prompt-editor.ts +102 -0
  53. package/src/render.ts +30 -11
  54. package/src/tools/bash.ts +94 -32
  55. package/src/tools/find.ts +39 -17
  56. package/src/tools/grep.ts +26 -15
  57. package/src/tools/ls.ts +19 -10
  58. package/src/tools/metrics.ts +28 -8
  59. package/src/tools/read.ts +122 -30
  60. package/src/types.ts +14 -3
  61. package/src/working-indicator.ts +309 -88
@@ -1,19 +1,17 @@
1
1
  /**
2
- * omp-style shimmer working indicator (widget takeover).
2
+ * omp-style shimmer working indicator.
3
3
  *
4
4
  * Ports the shimmer sweep from oh-my-pi's `modes/theme/shimmer.ts`: the sweep
5
5
  * is discretized into one pre-rendered frame per band position, cycled at
6
6
  * 1000/30 ms so the band travels 30 cells/second — omp's exact speed.
7
7
  *
8
- * Why a widget: the host `Loader` extends `Text` with a hardcoded paddingX=1,
9
- * and `setWorkingIndicator({ frames })` cannot change host component layout.
10
- * To render the row flush-left we hide the host loader
11
- * (`setWorkingVisible(false)`) and install our own zero-padding component via
12
- * `setWidget(..., "aboveEditor")`, animating it with a 33ms interval.
8
+ * On hosts that expose Pi's embedded working-status API, frames are delegated
9
+ * to the host so the row is rendered in the editor's top border. Older hosts
10
+ * without that API use the zero-padding widget fallback above the editor.
13
11
  *
14
- * Frame layout: `<dim spinner> <shimmer text> <dim interrupt hint>`, one full
15
- * sweep per phrase — `texts` rotates through phrases chapter by chapter with
16
- * a continuous spinner phase.
12
+ * Frame layout: `<dim spinner> <shimmer text>`, one full sweep per phrase —
13
+ * `texts` rotates through phrases chapter by chapter with a continuous spinner
14
+ * phase. The extension adds no interrupt hint or token-count suffix.
17
15
  */
18
16
 
19
17
  import { truncateToWidth } from "@earendil-works/pi-tui";
@@ -61,6 +59,7 @@ export interface WorkingIndicatorSettings {
61
59
  mode: WorkingIndicatorMode;
62
60
  palette: WorkingIndicatorPalette;
63
61
  bold: boolean;
62
+ /** @deprecated Retained for config compatibility; no interrupt hint is rendered. */
64
63
  hint: boolean;
65
64
  /** Tint mid/high tiers (and dim the spinner) with a per-session accent color. */
66
65
  sessionAccent: boolean;
@@ -261,7 +260,7 @@ export interface WorkingFrameOptions {
261
260
  spinnerColor?: string;
262
261
  /** Wrap every frame in italic (pi's hidden-thinking label is italic). */
263
262
  italic?: boolean;
264
- /** Pre-colored suffix (e.g. the dim interrupt hint) appended to every frame. */
263
+ /** @deprecated Retained for call-site compatibility; suffixes are not rendered. */
265
264
  hint?: string;
266
265
  intervalMs?: number;
267
266
  }
@@ -277,7 +276,6 @@ export function buildWorkingFrames(
277
276
  ): { frames: string[]; intervalMs: number } {
278
277
  const intervalMs = options.intervalMs ?? WORKING_INTERVAL_MS;
279
278
  const bold = options.bold ?? true;
280
- const suffix = options.hint ?? "";
281
279
  const withSpinner = options.spinner ?? true;
282
280
  const spinnerColor = options.spinnerColor ?? options.ansi.low;
283
281
  const highOpen = bold ? `${BOLD_OPEN}${options.ansi.high}` : options.ansi.high;
@@ -331,7 +329,7 @@ export function buildWorkingFrames(
331
329
  text,
332
330
  ranges,
333
331
  Array.from({ length: total }, () => "mid" as Tier),
334
- )}${suffix}`,
332
+ )}`,
335
333
  );
336
334
  return { frames, intervalMs };
337
335
  }
@@ -344,7 +342,7 @@ export function buildWorkingFrames(
344
342
  text,
345
343
  ranges,
346
344
  Array.from({ length: total }, () => "high" as Tier),
347
- )}${suffix}`,
345
+ )}`,
348
346
  );
349
347
  continue;
350
348
  }
@@ -353,19 +351,35 @@ export function buildWorkingFrames(
353
351
  const goingRight = sweep < range;
354
352
  const head = goingRight ? sweep : cycleCells - sweep;
355
353
  const tiers = Array.from({ length: total }, (_, i) => tierFor(kittIntensity(i, head, goingRight)));
356
- frames.push(`${spinnerPrefix(spinnerIndex++)}${paintFrame(text, ranges, tiers)}${suffix}`);
354
+ frames.push(`${spinnerPrefix(spinnerIndex++)}${paintFrame(text, ranges, tiers)}`);
357
355
  }
358
356
  } else {
359
357
  const period = total + CLASSIC_PADDING * 2;
360
358
  for (let pos = 0; pos < period; pos++) {
361
359
  const tiers = Array.from({ length: total }, (_, i) => tierFor(classicIntensity(i, pos)));
362
- frames.push(`${spinnerPrefix(spinnerIndex++)}${paintFrame(text, ranges, tiers)}${suffix}`);
360
+ frames.push(`${spinnerPrefix(spinnerIndex++)}${paintFrame(text, ranges, tiers)}`);
363
361
  }
364
362
  }
365
363
  }
366
364
  return { frames, intervalMs };
367
365
  }
368
366
 
367
+ /** @deprecated The working row no longer displays token stats. */
368
+ export function workingTokens(message: unknown): number {
369
+ const usage = (message as { usage?: { output?: unknown } } | undefined)?.usage;
370
+ const output = Number(usage?.output);
371
+ if (Number.isFinite(output) && output > 0) return Math.round(output);
372
+ const content = (message as { content?: Array<{ type?: string; text?: unknown; thinking?: unknown }> } | undefined)
373
+ ?.content;
374
+ if (!Array.isArray(content)) return 0;
375
+ let chars = 0;
376
+ for (const block of content) {
377
+ if (block?.type === "text" && typeof block.text === "string") chars += block.text.length;
378
+ else if (block?.type === "thinking" && typeof block.thinking === "string") chars += block.thinking.length;
379
+ }
380
+ return Math.round(chars / 4);
381
+ }
382
+
369
383
  // ─── Widget ──────────────────────────────────────────────────────────────────
370
384
 
371
385
  /** Slice of the pi-tui TUI instance the widget needs. */
@@ -375,8 +389,8 @@ export interface WidgetTuiLike {
375
389
 
376
390
  /**
377
391
  * Zero-padding working-row component. The host renders whatever `render()`
378
- * returns with no margins, so the row sits flush-left — the thing the frames
379
- * API could not do.
392
+ * returns with no margins, so the legacy widget row sits flush-left without
393
+ * relying on the host loader's padding.
380
394
  */
381
395
  export class WorkingWidget {
382
396
  #frames: string[] = [];
@@ -397,6 +411,16 @@ export class WorkingWidget {
397
411
  this.#tui = tui;
398
412
  }
399
413
 
414
+ /** Ask the host for a render — lets non-widget consumers (the thinking label
415
+ * ticker) reuse this widget's TUI handle instead of relying on natural
416
+ * streaming renders. */
417
+ requestRender(): void {
418
+ this.#tui?.requestRender();
419
+ }
420
+
421
+ /** @deprecated Token stats are owned by Pi and are no longer rendered here. */
422
+ setStats(_text: string | undefined): void {}
423
+
400
424
  start(): void {
401
425
  if (this.#disposed || this.#started) return;
402
426
  this.#started = true;
@@ -462,16 +486,161 @@ export interface ThinkingUiLike {
462
486
  setHiddenThinkingLabel(label?: string): void;
463
487
  }
464
488
 
489
+ // ─── Per-row hidden-thinking labels ──────────────────────────────────────────
490
+
491
+ /** Controller for the host-component label interception. */
492
+ export interface PerRowThinkingLabels {
493
+ /** Mark the message currently streaming a thinking block (its row animates). */
494
+ setActive(timestamp: number): void;
495
+ /** Freeze a message's completed thinking duration in ms (its row shows
496
+ * `Thought for X` derived from this). */
497
+ complete(timestamp: number, durationMs: number): void;
498
+ /** Drop the active mark (run ended); completed rows keep their durations. */
499
+ clearActive(): void;
500
+ /** Restore the original prototype setter. */
501
+ uninstall(): void;
502
+ }
503
+
504
+ const PER_ROW_PATCH = Symbol.for("pi-pretty.per-row-thinking-labels");
505
+
506
+ interface RowInternals {
507
+ lastMessage?: { timestamp?: unknown };
508
+ }
509
+
510
+ /**
511
+ * Intercept the host's per-row label fan-out method
512
+ * (`AssistantMessageComponent.prototype.setHiddenThinkingLabel`): each row
513
+ * substitutes the incoming global label with its own — the streaming row
514
+ * animates, completed rows stay frozen at their own `Thought for X`, unknown
515
+ * rows fall back to pi's default. Any per-call failure passes the incoming
516
+ * label through unchanged, so a host internals change degrades to pi-pretty's
517
+ * global-label behavior instead of breaking. Durations live per session.
518
+ */
519
+ export function installPerRowThinkingLabels(componentClass: unknown): PerRowThinkingLabels | undefined {
520
+ const proto = (componentClass as { prototype?: { setHiddenThinkingLabel?: unknown } } | undefined)?.prototype;
521
+ const original = proto?.setHiddenThinkingLabel;
522
+ if (typeof componentClass !== "function" || !proto || typeof original !== "function") return undefined;
523
+ const existing = (original as { [PER_ROW_PATCH]?: PerRowThinkingLabels })[PER_ROW_PATCH];
524
+ // Reuse the installed patch but start from clean state: a session_start
525
+ // without an intervening shutdown (defensive) must not resurrect stale rows.
526
+ if (existing) {
527
+ existing.clearActive();
528
+ return existing;
529
+ }
530
+
531
+ const state: { activeTs?: number; completed: Map<number, number> } = { completed: new Map() };
532
+ const patched = function (this: unknown, label: string | undefined): void {
533
+ const own = ownLabel(this, label);
534
+ (original as (this: unknown, label: string | undefined) => void).call(this, own);
535
+ };
536
+ const ownLabel = (component: unknown, incoming: string | undefined): string | undefined => {
537
+ try {
538
+ const ts = (component as RowInternals | undefined)?.lastMessage?.timestamp;
539
+ if (typeof ts !== "number") return incoming;
540
+ if (ts === state.activeTs) return incoming;
541
+ const ms = state.completed.get(ts);
542
+ return ms === undefined ? THINKING_LABEL : `${THOUGHT_LABEL} ${formatThinkingDuration(ms)}`;
543
+ } catch {
544
+ return incoming;
545
+ }
546
+ };
547
+ const controller: PerRowThinkingLabels = {
548
+ setActive(timestamp: number): void {
549
+ state.activeTs = timestamp;
550
+ },
551
+ complete(timestamp: number, durationMs: number): void {
552
+ state.completed.set(timestamp, durationMs);
553
+ },
554
+ clearActive(): void {
555
+ state.activeTs = undefined;
556
+ },
557
+ uninstall(): void {
558
+ if (proto.setHiddenThinkingLabel === patched) {
559
+ proto.setHiddenThinkingLabel = original as { setHiddenThinkingLabel?: unknown }["setHiddenThinkingLabel"];
560
+ }
561
+ state.completed.clear();
562
+ state.activeTs = undefined;
563
+ },
564
+ };
565
+ (patched as { [PER_ROW_PATCH]?: PerRowThinkingLabels })[PER_ROW_PATCH] = controller;
566
+ proto.setHiddenThinkingLabel = patched as NonNullable<{ setHiddenThinkingLabel?: unknown }["setHiddenThinkingLabel"]>;
567
+ return controller;
568
+ }
569
+
465
570
  export interface ThinkingLabelAnimator {
466
- /** Apply the next shimmer frame to the hidden-thinking label. */
467
- tick(): void;
571
+ /** Apply the next shimmer frame, rebuilding frames when the label changes. */
572
+ tick(label?: string): void;
573
+ /** Apply a non-animated label (the host supplies its normal thinking style). */
574
+ show(label: string): void;
468
575
  /** Restore pi's default static label. */
469
576
  restore(): void;
470
- /** Pre-rendered label frames (exposed for diagnostics). */
577
+ /** Current pre-rendered label frames (exposed for diagnostics). */
471
578
  readonly frames: readonly string[];
472
579
  }
473
580
 
474
- const THINKING_LABEL = "Thinking...";
581
+ export interface ThinkingTimer {
582
+ /** Render the active label with the current elapsed duration. */
583
+ tick(): void;
584
+ /** Freeze the elapsed duration; returns the total in milliseconds, or
585
+ * undefined when the timer was already terminal. */
586
+ complete(): number | undefined;
587
+ /** Restore pi's default label and make the timer terminal. */
588
+ restore(): void;
589
+ }
590
+
591
+ const THINKING_LABEL = " Thinking...";
592
+ const THOUGHT_LABEL = " Thought for";
593
+ const padThinkingLabel = (label: string): string => (label.startsWith(" ") ? label : ` ${label}`);
594
+
595
+ /** Format sub-second elapsed time as milliseconds, then use compact whole seconds. */
596
+ export function formatThinkingDuration(elapsedMs: number): string {
597
+ const wholeMs = Math.max(0, Math.floor(elapsedMs));
598
+ if (wholeMs < 1000) return `${wholeMs}ms`;
599
+ const totalSeconds = Math.floor(wholeMs / 1000);
600
+ const seconds = totalSeconds % 60;
601
+ const totalMinutes = Math.floor(totalSeconds / 60);
602
+ const minutes = totalMinutes % 60;
603
+ const hours = Math.floor(totalMinutes / 60);
604
+ if (hours > 0) return `${hours}h ${String(minutes).padStart(2, "0")}m ${String(seconds).padStart(2, "0")}s`;
605
+ if (totalMinutes > 0) return `${totalMinutes}m ${String(seconds).padStart(2, "0")}s`;
606
+ return `${seconds}s`;
607
+ }
608
+
609
+ /**
610
+ * Couple elapsed-time wording to the label animator through an injected clock.
611
+ * The timer starts on the first thinking delta, freezes once, and is terminal
612
+ * after completion or restore. `startElapsedMs` resumes a later run in the
613
+ * same message from its accumulated thinking time (per-message semantics:
614
+ * the host renders one label line per thinking run, all sharing the message
615
+ * label, so a fresh-zero restart would visibly rewind earlier lines).
616
+ */
617
+ export function createThinkingTimer(
618
+ animator: ThinkingLabelAnimator,
619
+ now: () => number = Date.now,
620
+ startElapsedMs = 0,
621
+ ): ThinkingTimer {
622
+ const startedAt = now() - startElapsedMs;
623
+ let terminal = false;
624
+ const elapsed = (): number => now() - startedAt;
625
+ const duration = (): string => formatThinkingDuration(elapsed());
626
+ return {
627
+ tick(): void {
628
+ if (terminal) return;
629
+ animator.tick(`${THINKING_LABEL} ${duration()}`);
630
+ },
631
+ complete(): number | undefined {
632
+ if (terminal) return undefined;
633
+ terminal = true;
634
+ const ms = elapsed();
635
+ animator.show(`${THOUGHT_LABEL} ${formatThinkingDuration(ms)}`);
636
+ return ms;
637
+ },
638
+ restore(): void {
639
+ terminal = true;
640
+ animator.restore();
641
+ },
642
+ };
643
+ }
475
644
 
476
645
  /**
477
646
  * True while a streaming assistant message is currently emitting a thinking
@@ -489,9 +658,9 @@ export function thinkingBlockActive(message: unknown): boolean {
489
658
  * Animate pi's hidden-thinking label ("Thinking...") with the same shimmer as
490
659
  * the working row. Pi renders the label as static italic `thinkingText` text;
491
660
  * 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.
661
+ * chat children, so index.ts drives it at 30fps only while the streaming
662
+ * message's last block is thinking. The elapsed label is rebuilt when its
663
+ * displayed duration changes; the shimmer frame still advances every tick.
495
664
  *
496
665
  * Tiers: low = theme `thinkingText` (pi's own label look), mid/high = session
497
666
  * accent when enabled; every frame is italic like pi's label; no spinner.
@@ -504,6 +673,7 @@ export function createThinkingLabelAnimator(
504
673
  ): ThinkingLabelAnimator {
505
674
  const noop: ThinkingLabelAnimator = {
506
675
  tick() {},
676
+ show() {},
507
677
  restore() {},
508
678
  frames: [],
509
679
  };
@@ -519,25 +689,42 @@ export function createThinkingLabelAnimator(
519
689
  const accent = hexToAnsiFg(sessionAccentHex(sessionName));
520
690
  if (accent) ansi = { ...ansi, mid: accent, high: accent };
521
691
  }
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;
692
+ const buildLabelFrames = (label: string): string[] => {
693
+ const padded = padThinkingLabel(label);
694
+ const text = padded.trimStart();
695
+ const leftPadding = padded.slice(0, padded.length - text.length);
696
+ return buildWorkingFrames([text], {
697
+ mode: workSettings.mode,
698
+ ansi,
699
+ bold: workSettings.bold,
700
+ spinner: false,
701
+ italic: true,
702
+ }).frames.map((frame) => `${leftPadding}${frame}`);
703
+ };
704
+
705
+ let currentLabel = THINKING_LABEL;
706
+ let frames = buildLabelFrames(currentLabel);
530
707
 
531
708
  let index = 0;
532
- let applied = false;
709
+ let appliedLabel: string | undefined;
533
710
  return {
534
- frames,
535
- tick(): void {
536
- // A single frame (static mode) never changes — skip redundant rebuilds.
537
- if (applied && frames.length === 1) return;
711
+ get frames(): readonly string[] {
712
+ return frames;
713
+ },
714
+ tick(label = THINKING_LABEL): void {
715
+ const paddedLabel = padThinkingLabel(label);
716
+ if (paddedLabel !== currentLabel) {
717
+ frames = buildLabelFrames(paddedLabel);
718
+ currentLabel = paddedLabel;
719
+ }
720
+ // A static frame changes only when its elapsed-time label changes.
721
+ if (appliedLabel === currentLabel && frames.length === 1) return;
538
722
  ui.setHiddenThinkingLabel(frames[index % frames.length]);
539
723
  index++;
540
- applied = true;
724
+ appliedLabel = currentLabel;
725
+ },
726
+ show(label: string): void {
727
+ ui.setHiddenThinkingLabel(padThinkingLabel(label));
541
728
  },
542
729
  restore(): void {
543
730
  // undefined → pi falls back to its default static label.
@@ -548,15 +735,24 @@ export function createThinkingLabelAnimator(
548
735
 
549
736
  // ─── Installation ────────────────────────────────────────────────────────────
550
737
 
551
- /** Structural slice of pi's Theme needed for the hint, palette, and thinking label. */
738
+ /** Structural slice of pi's Theme needed for the palette and thinking label. */
552
739
  export interface WorkingThemeLike {
553
- /** Narrowed to the only color the hint needs; pi's ThemeColor satisfies it. */
740
+ /** Retained for compatibility with older theme adapters. */
554
741
  fg?: (name: "dim", text: string) => string;
555
742
  getFgAnsi?: (name: "dim" | "muted" | "accent" | "thinkingText") => string;
556
743
  }
557
744
 
745
+ export interface WorkingStatusOptionsLike {
746
+ frames?: string[];
747
+ intervalMs?: number;
748
+ }
749
+
558
750
  export interface WorkingUiLike {
559
751
  theme?: WorkingThemeLike;
752
+ /** Hosts with Pi's embedded working-status API render this in the editor border. */
753
+ setWorkingIndicator?: (options?: WorkingStatusOptionsLike) => void;
754
+ /** Clear the host message appended after a custom native frame. */
755
+ setWorkingMessage?: (message?: string) => void;
560
756
  setWorkingVisible(visible: boolean): void;
561
757
  setWidget(
562
758
  key: string,
@@ -565,6 +761,7 @@ export interface WorkingUiLike {
565
761
  ): void;
566
762
  }
567
763
 
764
+ /** @deprecated Retained for compatibility with the former hint-resolution seam. */
568
765
  export interface KeybindingsLike {
569
766
  getKeys(name: string): string[];
570
767
  }
@@ -574,60 +771,26 @@ export interface WorkingIndicatorController {
574
771
  start(): void;
575
772
  /** Streaming ended — hide the row and pause the animation. */
576
773
  stop(): void;
577
- /** Remove the widget and stop the animation. */
774
+ /** Remove the indicator and stop the animation. */
578
775
  dispose(): void;
776
+ /** @deprecated Token stats are owned by Pi and are no longer rendered here. */
777
+ setStats(text: string | undefined): void;
778
+ /** Request a host render for consumers that drive another animated label. */
779
+ requestRender(): void;
579
780
  /** Pre-rendered frames (exposed for diagnostics). */
580
781
  readonly frames: readonly string[];
581
782
  }
582
783
 
583
784
  const WIDGET_KEY = "pi-pretty-working";
584
785
 
585
- async function loadHostKeybindings(): Promise<KeybindingsLike | undefined> {
586
- try {
587
- const tui = (await import("@earendil-works/pi-tui")) as typeof import("@earendil-works/pi-tui");
588
- return tui.getKeybindings?.();
589
- } catch {
590
- return undefined;
591
- }
592
- }
593
-
594
- function formatKeyText(keys: string[]): string {
595
- const darwin = process.platform === "darwin";
596
- return keys
597
- .join("/")
598
- .split("/")
599
- .map((key) =>
600
- key
601
- .split("+")
602
- .map((part) => (darwin && part.toLowerCase() === "alt" ? "option" : part))
603
- .join("+"),
604
- )
605
- .join("/");
606
- }
607
-
608
- async function resolveHint(
609
- ui: WorkingUiLike,
610
- deps?: { getKeybindings?: () => KeybindingsLike | undefined | Promise<KeybindingsLike | undefined> },
611
- ): Promise<string | undefined> {
612
- try {
613
- const loader = deps?.getKeybindings ?? loadHostKeybindings;
614
- const keybindings = await loader();
615
- const keys = keybindings?.getKeys?.("app.interrupt");
616
- if (!keys?.length) return undefined;
617
- const text = ` (${formatKeyText(keys)} to interrupt)`;
618
- return ui.theme?.fg ? ui.theme.fg("dim", text) : text;
619
- } catch {
620
- return undefined;
621
- }
622
- }
623
-
624
786
  /**
625
- * Take over pi's working row with our own flush-left shimmer widget.
787
+ * Install the custom working shimmer and return its lifecycle controller.
626
788
  *
627
- * Installs a zero-padding component above the editor and hides the host
628
- * loader; returns a controller the host lifecycle drives (`start` on
629
- * agent_start, `stop` on agent_end). No-op (host defaults untouched) when
630
- * disabled or the phrase list is empty.
789
+ * Current Pi hosts receive the pre-rendered frames through the embedded
790
+ * working-status API, which places them in the editor's top border. Older
791
+ * hosts use a zero-padding widget above the editor. The host lifecycle drives
792
+ * the controller (`start` on agent_start, `stop` on agent_end); disabled or
793
+ * empty phrase settings leave host defaults untouched.
631
794
  */
632
795
  export async function installWorkingIndicator(
633
796
  ui: WorkingUiLike,
@@ -639,11 +802,14 @@ export async function installWorkingIndicator(
639
802
  start() {},
640
803
  stop() {},
641
804
  dispose() {},
805
+ setStats() {},
806
+ requestRender() {},
642
807
  frames: [],
643
808
  };
644
809
  if (!settings.enabled) return noopController;
645
810
  const texts = settings.texts.map((t) => t.trim()).filter((t) => t.length > 0);
646
811
  if (texts.length === 0) return noopController;
812
+ void deps; // Retained for compatibility; interrupt hints are no longer resolved here.
647
813
 
648
814
  const ansiResolved = resolvePaletteAnsi(settings.palette, ui.theme);
649
815
  let ansi = ansiResolved;
@@ -656,20 +822,73 @@ export async function installWorkingIndicator(
656
822
  spinnerColor = hexToAnsiFg(dimAccentHex(accentHex));
657
823
  }
658
824
  }
659
- const hint = settings.hint ? await resolveHint(ui, deps) : undefined;
660
825
  const { frames, intervalMs } = buildWorkingFrames(texts, {
661
826
  mode: settings.mode,
662
827
  ansi,
663
828
  bold: settings.bold,
664
829
  spinnerColor,
665
- hint,
666
830
  });
667
831
  if (frames.length === 0) return noopController;
668
832
 
833
+ const setWorkingIndicator = ui.setWorkingIndicator;
834
+ const setWorkingMessage = ui.setWorkingMessage;
835
+ if (typeof setWorkingIndicator === "function" && typeof setWorkingMessage === "function") {
836
+ let started = false;
837
+ let disposed = false;
838
+ const updateNativeMessage = (): void => {
839
+ // Keep Pi's built-in message hidden because the custom frame already
840
+ // supplies the working label; no hint or token suffix is added here.
841
+ setWorkingMessage.call(ui, "");
842
+ };
843
+
844
+ try {
845
+ setWorkingIndicator.call(ui, { frames, intervalMs });
846
+ // Keep the native status row hidden until the extension lifecycle starts it.
847
+ // An empty message is intentional: all visible content lives in our frames.
848
+ updateNativeMessage();
849
+ ui.setWorkingVisible(false);
850
+ } catch (error: unknown) {
851
+ try {
852
+ setWorkingIndicator.call(ui);
853
+ setWorkingMessage.call(ui);
854
+ } catch {
855
+ // Best-effort rollback; the caller still restores host visibility.
856
+ }
857
+ throw error;
858
+ }
859
+ return {
860
+ start: () => {
861
+ if (disposed || started) return;
862
+ started = true;
863
+ updateNativeMessage();
864
+ ui.setWorkingVisible(true);
865
+ },
866
+ stop: () => {
867
+ if (!started) return;
868
+ started = false;
869
+ ui.setWorkingVisible(false);
870
+ },
871
+ dispose: () => {
872
+ if (disposed) return;
873
+ if (started) {
874
+ started = false;
875
+ ui.setWorkingVisible(false);
876
+ }
877
+ disposed = true;
878
+ setWorkingIndicator.call(ui);
879
+ setWorkingMessage.call(ui);
880
+ },
881
+ setStats: (_text: string | undefined) => {},
882
+ requestRender: () => updateNativeMessage(),
883
+ frames,
884
+ };
885
+ }
886
+
669
887
  const widget = new WorkingWidget();
670
888
  widget.setFrames(frames, intervalMs);
671
889
  // Widget first, visibility second: if setWidget throws (older host without
672
- // the API) the host loader was never hidden and pi's default remains.
890
+ // the native working-status API) the host loader was never hidden and pi's
891
+ // default remains.
673
892
  ui.setWidget(
674
893
  WIDGET_KEY,
675
894
  (tui) => {
@@ -686,6 +905,8 @@ export async function installWorkingIndicator(
686
905
  widget.dispose();
687
906
  ui.setWidget(WIDGET_KEY, undefined);
688
907
  },
908
+ setStats: (_text: string | undefined) => {},
909
+ requestRender: () => widget.requestRender(),
689
910
  frames,
690
911
  };
691
912
  }