gentle-pi 3.3.0 → 3.5.0

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 (88) hide show
  1. package/README.md +88 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/bin/gentle-shell.mjs +198 -0
  4. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  5. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  6. package/docs/assets/features/agents-view.png +0 -0
  7. package/docs/assets/features/changes-view.png +0 -0
  8. package/docs/assets/features/command-palette.png +0 -0
  9. package/docs/assets/features/profiles-routing.png +0 -0
  10. package/docs/gentle-agents-activity.md +95 -0
  11. package/docs/gentle-shell.md +26 -2
  12. package/docs/readme-reference.md +99 -6
  13. package/extensions/ask-user-choice.ts +70 -22
  14. package/extensions/ask-user-question.ts +338 -0
  15. package/extensions/gentle-agents.ts +41 -1
  16. package/extensions/gentle-ai.ts +59 -18
  17. package/extensions/gentle-shell.ts +99 -10
  18. package/extensions/quiet-tools.ts +28 -5
  19. package/extensions/startup-banner.ts +25 -10
  20. package/lib/agents-rpc-publisher.ts +342 -0
  21. package/lib/agents-runner.ts +7 -2
  22. package/lib/animation-policy.ts +52 -0
  23. package/lib/background-cache-warming.ts +38 -0
  24. package/lib/command-palette-catalog.ts +1 -0
  25. package/lib/gentle-shell-launcher.ts +482 -0
  26. package/lib/inprocess-reviewer.ts +38 -1
  27. package/lib/native-review-cli.ts +36 -10
  28. package/lib/questionnaire/questionnaire-view.ts +603 -0
  29. package/lib/questionnaire/schema.ts +82 -0
  30. package/lib/questionnaire/validate.ts +141 -0
  31. package/lib/review-candidate-view-owner.ts +20 -5
  32. package/lib/review-candidate-view.ts +9 -2
  33. package/lib/review-host-relay.ts +10 -0
  34. package/lib/review-integration-v2.ts +4 -1
  35. package/lib/rpc-host.ts +36 -0
  36. package/lib/shell-bar.ts +13 -0
  37. package/lib/shell-sidebar-layout.ts +10 -4
  38. package/lib/shell-usage-view.ts +5 -2
  39. package/lib/shell-usage.ts +120 -6
  40. package/package.json +5 -1
  41. package/runtime/gentle-shell-launcher.mjs +483 -0
  42. package/runtime/native-review-cli.mjs +35 -9
  43. package/runtime/review-integration-v2.mjs +4 -1
  44. package/scripts/build-runtime-modules.mjs +1 -0
  45. package/scripts/gentle-ai-installer.mjs +10 -10
  46. package/scripts/install-gentle-ai.mjs +14 -7
  47. package/scripts/install-tui-mode-setting.mjs +78 -1
  48. package/scripts/verify-package-files.mjs +6 -3
  49. package/tests/agents-rpc-publisher.test.ts +407 -0
  50. package/tests/agents-runner.test.ts +10 -0
  51. package/tests/animation-policy.test.ts +42 -0
  52. package/tests/ask-user-choice.test.ts +129 -0
  53. package/tests/ask-user-question.test.ts +661 -0
  54. package/tests/background-cache-warming.test.ts +60 -0
  55. package/tests/background-subagents.test.ts +68 -0
  56. package/tests/command-palette.test.ts +9 -0
  57. package/tests/gentle-agents.test.ts +161 -2
  58. package/tests/gentle-ai-binary.test.ts +1 -1
  59. package/tests/gentle-ai-installer.test.ts +47 -47
  60. package/tests/gentle-ai.test.ts +56 -4
  61. package/tests/gentle-shell-bin.test.ts +188 -0
  62. package/tests/gentle-shell-launcher.test.ts +718 -0
  63. package/tests/gentle-shell.test.ts +355 -2
  64. package/tests/inprocess-reviewer.test.ts +92 -0
  65. package/tests/install-tui-mode-guard.test.ts +99 -0
  66. package/tests/install-tui-mode-setting.test.ts +39 -1
  67. package/tests/native-review-capability-contract.test.ts +16 -1
  68. package/tests/native-review-parity.test.ts +19 -0
  69. package/tests/package-manifest.test.ts +6 -17
  70. package/tests/questionnaire-schema.test.ts +274 -0
  71. package/tests/questionnaire-view.test.ts +446 -0
  72. package/tests/rdd-status-line.test.ts +21 -4
  73. package/tests/review-candidate-owner-retry.test.ts +63 -0
  74. package/tests/review-candidate-view.test.ts +15 -0
  75. package/tests/review-controller-native-routing.test.ts +86 -0
  76. package/tests/review-host-relay.test.ts +21 -0
  77. package/tests/review-integration-v2.test.ts +30 -0
  78. package/tests/review-ledger-contract.test.ts +1 -2
  79. package/tests/review-relay-transport-agent.test.ts +107 -2
  80. package/tests/review-risk-assessment.test.ts +104 -0
  81. package/tests/rpc-host.test.ts +77 -0
  82. package/tests/shell-bar.test.ts +8 -0
  83. package/tests/shell-sidebar-layout.test.ts +60 -5
  84. package/tests/shell-usage.test.ts +129 -0
  85. package/tests/skill-collision-prefixes.test.ts +1 -1
  86. package/tests/startup-banner.test.ts +93 -2
  87. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  88. package/skills/release/SKILL.md +0 -137
@@ -5,7 +5,7 @@ import { statSync } from "node:fs";
5
5
  import { profilesFilePath, readProfilesFileResult } from "../lib/agent-profiles.ts";
6
6
  import * as os from "node:os";
7
7
  import { join } from "node:path";
8
- import { buildShellHeaderModel, renderShellBar, renderShellHeaderBar, renderShellSidebarBar, shellEnabled, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
8
+ import { buildShellHeaderModel, renderShellBar, renderShellHeaderBar, renderShellHeaderRule, renderShellSidebarBar, shellEnabled, type ShellBarModel, type ShellBarTheme } from "../lib/shell-bar.ts";
9
9
  import { CHANGE_STATUS, RootBranchLabels, renderChangesWidget, type ChangedFile, type ChangesModel, type GitRunner, type WorktreeChanges } from "../lib/shell-changes.ts";
10
10
  import { WorktreeChangesView } from "../lib/shell-changes-view.ts";
11
11
  import { SessionWorktreeRegistry, resolveSessionWorktree, worktreeGitEnvironment, type WorktreeResolver } from "../lib/session-worktree-registry.ts";
@@ -16,6 +16,7 @@ import { agentsViewKey } from "../lib/agents-keys.ts";
16
16
  import { GentleAiDevBinaryOverrideError, resolveGentleAiDevBinaryOverride } from "../lib/gentle-ai-binary.ts";
17
17
  import { DOUBLE_ESC_CANCEL_HINT, framePromptLines, IDLE_ESC_CLEAR_HINT, PROMPT_HINT, PROMPT_STATE, SHELL_PULSE_MS, withPromptHint, type PromptState } from "../lib/shell-prompt.ts";
18
18
  import { gentlePiConfigHome } from "../lib/agent-home.ts";
19
+ import { resolveAnimationPolicy, writeAnimationPolicy, type AnimationPolicy } from "../lib/animation-policy.ts";
19
20
  import {
20
21
  DOUBLE_ESC_CANCEL_WINDOW_MS,
21
22
  resolveDoubleEscCancelPolicy,
@@ -23,7 +24,7 @@ import {
23
24
  type DoubleEscCancelPolicy,
24
25
  type DoubleEscCancelResolution,
25
26
  } from "../lib/double-esc-cancel-policy.ts";
26
- import { accountIdFromToken, CODEX_PROVIDER, CODEX_USAGE_URL, NAN_PROVIDER, NAN_QUOTA_URL, parseCodexUsage, parseNanQuota, parseUsageHeaders, UsageStore, type ProviderUsage } from "../lib/shell-usage.ts";
27
+ import { accountIdFromToken, CODEX_PROVIDER, CODEX_USAGE_URL, NAN_PROVIDER, NAN_QUOTA_URL, parseCodexUsage, parseNanQuota, parseProviderUsage, parseUsageHeaders, parseUsageSource, UsageSourceRegistry, UsageStore, USAGE_SOURCE_EVENT, type ProviderUsage, type UsageSource } from "../lib/shell-usage.ts";
27
28
  import { UsageView } from "../lib/shell-usage-view.ts";
28
29
  import { sidebarHeader, sidebarPart } from "../lib/shell-sidebar.ts";
29
30
  import { installSidebar, invalidateSidebar } from "../lib/shell-sidebar-layout.ts";
@@ -229,6 +230,7 @@ export function extractQueuedText(combined: string, draft: string): string | und
229
230
  export class GentlePromptEditor extends CustomEditor {
230
231
  private promptState: PromptState = PROMPT_STATE.IDLE;
231
232
  private tick = 0;
233
+ private animationPolicy: AnimationPolicy = "quality";
232
234
  private pulse: NodeJS.Timeout | undefined;
233
235
  private readonly deps: PromptEditorDeps;
234
236
  // CustomEditor keeps its own `keybindings` private, so this class holds
@@ -248,6 +250,23 @@ export class GentlePromptEditor extends CustomEditor {
248
250
  this.keybindingsManager = keybindings;
249
251
  }
250
252
 
253
+ setAnimationPolicy(policy: AnimationPolicy): void {
254
+ if (this.animationPolicy === policy) return;
255
+ this.animationPolicy = policy;
256
+ this.stopPulse();
257
+ if (this.promptState === PROMPT_STATE.WORKING) this.startPulse();
258
+ this.deps.requestRender();
259
+ }
260
+
261
+ private startPulse(): void {
262
+ if (this.animationPolicy === "potato") return;
263
+ this.pulse = setInterval(() => {
264
+ this.tick += 1;
265
+ this.deps.requestRender();
266
+ }, this.animationPolicy === "performance" ? 1000 : SHELL_PULSE_MS);
267
+ this.pulse.unref();
268
+ }
269
+
251
270
  setWorking(working: boolean): void {
252
271
  this.promptState = working ? PROMPT_STATE.WORKING : PROMPT_STATE.IDLE;
253
272
  this.stopPulse();
@@ -256,11 +275,7 @@ export class GentlePromptEditor extends CustomEditor {
256
275
  } else {
257
276
  this.pendingIdleClearDeadline = undefined;
258
277
  this.pendingIdleClearText = undefined;
259
- this.pulse = setInterval(() => {
260
- this.tick += 1;
261
- this.deps.requestRender();
262
- }, SHELL_PULSE_MS);
263
- this.pulse.unref();
278
+ this.startPulse();
264
279
  }
265
280
  this.deps.requestRender();
266
281
  }
@@ -743,28 +758,67 @@ export async function fetchNanUsage(apiKey: string | undefined, fetchFn: typeof
743
758
  }
744
759
  }
745
760
 
761
+ // A registered source is foreign code running inside a fire-and-forget
762
+ // refresh: it must degrade exactly like the built-in fetchers above, never
763
+ // throw past this call, and never leave an unhandled rejection behind.
764
+ async function fetchFromSource(source: UsageSource, apiKey: string | undefined, fetchFn: typeof fetch, now: number): Promise<ProviderUsage | undefined> {
765
+ try {
766
+ const result = await source.fetch(apiKey, fetchFn, now);
767
+ return result === undefined ? undefined : parseProviderUsage(result, source.provider);
768
+ } catch {
769
+ return undefined;
770
+ }
771
+ }
772
+
746
773
  export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = process.env, overrides: Partial<ShellDeps> = {}): void {
747
774
  installSessionChangeCapture(pi, env, overrides.resolveWorktree ?? resolveSessionWorktree);
748
775
  if (!shellEnabled(env)) return;
749
776
  const deps: ShellDeps = { ...defaultShellDeps, activeProfile: createActiveProfileReader(env), ...overrides };
750
777
  const usage = new UsageStore();
778
+ // Providers gentle-shell has never heard of get a usage source too, when
779
+ // the extension that owns them registers one on pi.events; see the
780
+ // USAGE_SOURCE_EVENT subscription below.
781
+ const usageSources = new UsageSourceRegistry();
751
782
  let renderHost: ShellRenderHost | undefined;
752
783
  // The 5-minute rule is per provider: one provider's fetch cannot leave the
753
784
  // next one waiting for an interval it never used.
754
785
  const usageFetchedAt = new Map<string, number>();
755
786
  const refreshUsage = async (ctx: ExtensionContext, force: boolean) => {
756
787
  const provider = ctx.model?.provider;
757
- if (provider !== CODEX_PROVIDER && provider !== NAN_PROVIDER) return;
788
+ if (!provider) return;
789
+ const source = usageSources.get(provider);
790
+ if (!source && provider !== CODEX_PROVIDER && provider !== NAN_PROVIDER) return;
758
791
  const now = deps.now();
759
792
  if (!force && now - (usageFetchedAt.get(provider) ?? 0) < USAGE_REFRESH_MS) return;
760
793
  usageFetchedAt.set(provider, now);
761
794
  const apiKey = await ctx.modelRegistry.getApiKeyForProvider(provider).catch(() => undefined);
762
- const fetched = provider === NAN_PROVIDER ? await fetchNanUsage(apiKey, deps.fetch, deps.now()) : await fetchCodexUsage(apiKey, deps.fetch, deps.now());
795
+ const fetched = source
796
+ ? await fetchFromSource(source, apiKey, deps.fetch, deps.now())
797
+ : provider === NAN_PROVIDER
798
+ ? await fetchNanUsage(apiKey, deps.fetch, deps.now())
799
+ : await fetchCodexUsage(apiKey, deps.fetch, deps.now());
763
800
  if (!fetched) return;
801
+ // A registered source can be replaced while its own fetch is still in
802
+ // flight; the identity captured above is this call's source, so a stale
803
+ // answer that outlives its replacement is discarded instead of
804
+ // overwriting whatever the replacement already recorded.
805
+ if (source && usageSources.get(provider) !== source) return;
764
806
  usage.record(fetched);
765
807
  renderHost?.invalidateSidebar?.();
766
808
  renderHost?.requestRender();
767
809
  };
810
+ // Subscribed once, for the life of the extension: a registration can
811
+ // arrive before the first session_start (the owning extension's factory
812
+ // runs first) or after it (its own session_start fires later, or it
813
+ // registers lazily). Either order is fine: a registration for the
814
+ // currently active provider forces exactly one refresh, so the panel
815
+ // never waits for the 5-minute window or the next turn to notice it.
816
+ pi.events.on(USAGE_SOURCE_EVENT, (payload) => {
817
+ const source = parseUsageSource(payload);
818
+ if (!source) return;
819
+ usageSources.register(source);
820
+ if (currentContext?.model?.provider === source.provider) void refreshUsage(currentContext, true);
821
+ });
768
822
  pi.on("after_provider_response", (event) => {
769
823
  const parsed = parseUsageHeaders(event.headers, deps.now());
770
824
  if (!parsed) return;
@@ -785,6 +839,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
785
839
  theme,
786
840
  now: () => deps.now(),
787
841
  active: () => (ctx.model ? { provider: ctx.model.provider } : undefined),
842
+ registry: () => usageSources,
788
843
  onRefresh: () => refreshUsage(ctx, true),
789
844
  onClose: () => done(null),
790
845
  requestRender: () => tui.requestRender(),
@@ -818,6 +873,8 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
818
873
  // direction, and the Esc gate always describe the same effective policy
819
874
  // even when another session or a hand edit changed the file mid-session.
820
875
  const doubleEscCancelConfigHome = gentlePiConfigHome(env);
876
+ const animationOptions = { gentlePiConfigHome: doubleEscCancelConfigHome };
877
+ let animationPolicy = resolveAnimationPolicy(animationOptions).policy;
821
878
  let doubleEscCancelPolicy: DoubleEscCancelPolicy = resolveDoubleEscCancelPolicy({
822
879
  env,
823
880
  gentlePiConfigHome: doubleEscCancelConfigHome,
@@ -889,10 +946,11 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
889
946
  const headerBar = (width: number) => renderShellHeaderBar(buildShellHeaderModel(footerModel()), theme, width, usageShortcutKey);
890
947
  const disposeHeader = sidebarHeader(tui, {
891
948
  digest: () => JSON.stringify(buildShellHeaderModel(footerModel())),
892
- render: (width) => [headerBar(width).text],
949
+ render: (width) => [headerBar(width).text, renderShellHeaderRule(theme, width)],
893
950
  invalidate() {},
894
951
  handleMouse(event) {
895
952
  if (event.type !== "click" || event.button !== "left") return undefined;
953
+ if (event.y !== 0) return undefined; // the rule row under the status line is decorative, never clickable
896
954
  const { usageSpan } = headerBar(event.width);
897
955
  if (!usageSpan || event.x < usageSpan.start || event.x >= usageSpan.end) return undefined;
898
956
  void openUsage(ctx);
@@ -908,6 +966,7 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
908
966
  (created) => {
909
967
  prompt?.dispose();
910
968
  prompt = created;
969
+ prompt.setAnimationPolicy(animationPolicy);
911
970
  },
912
971
  { now: () => deps.now(), doubleEscCancelEnabled: () => doubleEscCancelPolicy === "on", dispatchQueuedText: (text) => { pendingQueuedText = pendingQueuedText === undefined ? text : `${pendingQueuedText}\n\n${text}`; } },
913
972
  );
@@ -971,6 +1030,36 @@ export default function gentleShell(pi: ExtensionAPI, env: NodeJS.ProcessEnv = p
971
1030
  handler: async (ctx) => showCommandPalette(pi, ctx, env),
972
1031
  });
973
1032
  }
1033
+ pi.registerCommand("gentle:animations", {
1034
+ description: "Show or set global animations; no argument opens a selectable menu (quality|performance|potato, plus status).",
1035
+ // No argument opens a selectable menu when an interactive UI is present;
1036
+ // headless callers and fakes without ui.select keep the status fallback.
1037
+ handler: async (args, ctx) => {
1038
+ let action = args.trim() || "status";
1039
+ if (args.trim().length === 0 && ctx.hasUI && typeof ctx.ui.select === "function") {
1040
+ const selected = await ctx.ui.select(
1041
+ `Gentle animations (current: ${animationPolicy})`,
1042
+ ["quality", "performance", "potato", "status"],
1043
+ );
1044
+ if (selected === undefined) return;
1045
+ action = selected;
1046
+ }
1047
+ if (action !== "status" && action !== "quality" && action !== "performance" && action !== "potato") {
1048
+ ctx.ui.notify("Use /gentle:animations status|quality|performance|potato.", "warning");
1049
+ return;
1050
+ }
1051
+ try {
1052
+ if (action !== "status") writeAnimationPolicy(action, animationOptions);
1053
+ const result = resolveAnimationPolicy(animationOptions);
1054
+ animationPolicy = result.policy;
1055
+ prompt?.setAnimationPolicy(animationPolicy);
1056
+ const source = result.source === "default" ? "built-in default" : `global file ${result.globalFile}`;
1057
+ ctx.ui.notify(`animations: ${result.policy} (decided by ${source})${result.malformed ? "; malformed or unreadable file, falling back to quality" : ""}. Prompt applies now; startup banner applies at next creation.`, result.malformed ? "warning" : "info");
1058
+ } catch (error) {
1059
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
1060
+ }
1061
+ },
1062
+ });
974
1063
  // User-owned, like gentle:background-subagents and gentle:review-mode: the
975
1064
  // only writer is this handler, reached only by explicit invocation. Unlike
976
1065
  // those two, no argument toggles the effective policy instead of merely
@@ -535,18 +535,39 @@ interface BoundedRowSection {
535
535
  tail?: boolean;
536
536
  }
537
537
 
538
+ // Cache the rendered preview slice per stable tool-result object: pi re-renders
539
+ // every visible card each frame, so re-tokenizing the full output text per pass is
540
+ // pure waste. Only the returned preview slice is retained, never the full wrapped
541
+ // text, so an arbitrarily large output cannot pin every wrapped line for the
542
+ // result object's lifetime.
543
+ const boundedRowsLineCache = new WeakMap<object, Array<{ text: string; width: number; rows: number; tail: boolean; lines: string[] } | undefined>>();
544
+
538
545
  class BoundedRows implements Component {
539
546
  private readonly sections: readonly BoundedRowSection[];
547
+ private readonly cacheKey: object | undefined;
540
548
 
541
- constructor(sections: readonly BoundedRowSection[]) {
549
+ constructor(sections: readonly BoundedRowSection[], cacheKey?: object) {
542
550
  this.sections = sections;
551
+ this.cacheKey = cacheKey;
543
552
  }
544
553
 
554
+ /** Renders each section through the wrapped-line cache, sliced to the section row budget; cache hits skip re-tokenizing and re-wrapping the section text. */
545
555
  render(width: number): string[] {
546
- return this.sections.flatMap(({ text, rows, tail = false }) => {
556
+ const cache = this.cacheKey ? boundedRowsLineCache.get(this.cacheKey) : undefined;
557
+ return this.sections.flatMap(({ text, rows, tail = false }, index) => {
547
558
  if (rows <= 0) return [];
559
+ const hit = cache?.[index];
560
+ if (hit && hit.text === text && hit.width === width && hit.rows === rows && hit.tail === tail) {
561
+ return [...hit.lines];
562
+ }
548
563
  const rendered = new Text(text, 0, 0).render(width);
549
- return tail ? rendered.slice(-rows) : rendered.slice(0, rows);
564
+ const sliced = tail ? rendered.slice(-rows) : rendered.slice(0, rows);
565
+ if (this.cacheKey) {
566
+ const slot = boundedRowsLineCache.get(this.cacheKey) ?? [];
567
+ slot[index] = { text, width, rows, tail, lines: sliced };
568
+ boundedRowsLineCache.set(this.cacheKey, slot);
569
+ }
570
+ return [...sliced];
550
571
  });
551
572
  }
552
573
 
@@ -641,8 +662,10 @@ function registerQuietTool(pi: ExtensionAPI, toolName: QuietToolName, commandArg
641
662
  }
642
663
  return new Text(formatToolCall(toolName, callArgs, theme), 0, 0);
643
664
  },
665
+ /** Builds the card component for this render pass; collapsed cards delegate to the wrapped-line cache keyed by the tool result object. */
644
666
  renderResult(result, options, theme, context) {
645
667
  const renderContext = context as ToolRenderContextLike | undefined;
668
+ const cacheKey = typeof result === "object" && result !== null ? result : undefined;
646
669
  const safeResult = sanitizedResult(result);
647
670
  const text = safeText(extractTextContent(safeResult));
648
671
  const isError = renderContext?.isError ?? options.isError ?? false;
@@ -661,7 +684,7 @@ function registerQuietTool(pi: ExtensionAPI, toolName: QuietToolName, commandArg
661
684
  return new BoundedRows([
662
685
  { text: theme.fg("warning", partialLabel(toolName, text)), rows: 1 },
663
686
  ...(visible ? [{ text: theme.fg("muted", visible), rows: PREVIEW_LINE_LIMIT, tail: true }] : []),
664
- ]);
687
+ ], cacheKey);
665
688
  }
666
689
  if (options.expanded && toolName === "read" && hasImageContent(safeResult) && officialRenderResult) {
667
690
  return officialRenderResult(
@@ -686,7 +709,7 @@ function registerQuietTool(pi: ExtensionAPI, toolName: QuietToolName, commandArg
686
709
  return new BoundedRows([
687
710
  { text: theme.fg(color, output.replace(/^\n/, "")), rows: PREVIEW_LINE_LIMIT, tail },
688
711
  ...(hint ? [{ text: theme.fg(color, hint.slice(1)), rows: 1 }] : []),
689
- ]);
712
+ ], cacheKey);
690
713
  }
691
714
  return new Text(hint ? theme.fg(color, hint.slice(1)) : "", 0, 0);
692
715
  },
@@ -5,6 +5,7 @@ import * as os from "node:os";
5
5
  import { execFile } from "node:child_process";
6
6
  import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
7
7
  import { join } from "node:path";
8
+ import { resolveAnimationPolicy } from "../lib/animation-policy.ts";
8
9
 
9
10
  const PI_AGENT_DIR = join(os.homedir(), ".pi", "agent");
10
11
  const PI_NPM_DIR = join(PI_AGENT_DIR, "npm", "node_modules");
@@ -691,6 +692,7 @@ export default function (pi: ExtensionAPI) {
691
692
 
692
693
  let tick = 0;
693
694
  let refreshStats = () => {};
695
+ let headerCache: { key: string; out: string[] } | null = null;
694
696
  const state = {
695
697
  timer: null as NodeJS.Timeout | null,
696
698
  mode: currentIntroMode() as IntroMode,
@@ -718,18 +720,27 @@ export default function (pi: ExtensionAPI) {
718
720
  setTimeout(() => {
719
721
  ctx.ui.setHeader((tui, theme) => {
720
722
  if (state.timer) clearInterval(state.timer);
723
+ headerCache = null;
721
724
 
722
725
  refreshStats = () => tui.requestRender();
726
+ // Capture once: a command changes the live prompt, not this intro.
727
+ const animationPolicy = resolveAnimationPolicy().policy;
723
728
  const animStart = Date.now();
724
- state.timer = setInterval(() => {
725
- tick++;
726
- const finished = allStrokesReady() && tick > WRITING_END_TICK + 22;
727
- if (finished || Date.now() - animStart > 5000) {
728
- clearInterval(state.timer!);
729
- state.timer = null;
730
- }
731
- try { tui.requestRender(); } catch { cleanup(); }
732
- }, 25);
729
+ if (animationPolicy === "potato") {
730
+ tick = Number.MAX_SAFE_INTEGER;
731
+ state.timer = null;
732
+ } else {
733
+ const performance = animationPolicy === "performance";
734
+ state.timer = setInterval(() => {
735
+ tick += performance ? 10 : 1;
736
+ const finished = allStrokesReady() && tick > WRITING_END_TICK + 22;
737
+ if (finished || Date.now() - animStart > 5000) {
738
+ clearInterval(state.timer!);
739
+ state.timer = null;
740
+ }
741
+ try { tui.requestRender(); } catch { cleanup(); }
742
+ }, performance ? 250 : 25);
743
+ }
733
744
 
734
745
  // Grace period: pi-tui emite resizes transitorios mientras compone su layout inicial.
735
746
  const bootStart = Date.now();
@@ -753,8 +764,11 @@ export default function (pi: ExtensionAPI) {
753
764
  process.stdout.on("resize", resizeHandler);
754
765
 
755
766
  return {
767
+ /** Renders the persistent header grid; memoized per width, tick, mode and stats so static passes reuse the built lines. */
756
768
  render(width: number): string[] {
757
769
  if (state.mode === "skip") return [];
770
+ const headerKey = `${width}|${tick}|${state.mode}|${gitBranch}|${mcpServersCount}|${extensionsCount}|${packagesCount}|${sddAgentsCount}|${ctx.cwd}|${skills.length}|${customTools.length}`;
771
+ if (headerCache?.key === headerKey) return headerCache.out;
758
772
 
759
773
  const flashStartTick = 10;
760
774
  const roseOpacity = Math.min(1, tick / 10);
@@ -1050,9 +1064,10 @@ export default function (pi: ExtensionAPI) {
1050
1064
  out.push(truncateToWidth(line, Math.max(1, width), ""));
1051
1065
  }
1052
1066
 
1067
+ headerCache = { key: headerKey, out };
1053
1068
  return out;
1054
1069
  },
1055
- invalidate() {},
1070
+ invalidate() { headerCache = null; },
1056
1071
  dispose() {
1057
1072
  cleanup();
1058
1073
  },