gentle-pi 3.3.0 → 3.4.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 (69) hide show
  1. package/README.md +63 -59
  2. package/assets/orchestrator-delegation.md +1 -1
  3. package/docs/assets/brand/gentle-shell-banner.gif +0 -0
  4. package/docs/assets/diagrams/odd-workflow.svg +74 -0
  5. package/docs/assets/features/agents-view.png +0 -0
  6. package/docs/assets/features/changes-view.png +0 -0
  7. package/docs/assets/features/command-palette.png +0 -0
  8. package/docs/assets/features/profiles-routing.png +0 -0
  9. package/docs/gentle-shell.md +26 -2
  10. package/docs/readme-reference.md +32 -6
  11. package/extensions/ask-user-question.ts +210 -0
  12. package/extensions/gentle-agents.ts +8 -1
  13. package/extensions/gentle-ai.ts +59 -18
  14. package/extensions/gentle-shell.ts +99 -10
  15. package/extensions/quiet-tools.ts +28 -5
  16. package/extensions/startup-banner.ts +25 -10
  17. package/lib/animation-policy.ts +52 -0
  18. package/lib/background-cache-warming.ts +38 -0
  19. package/lib/command-palette-catalog.ts +1 -0
  20. package/lib/inprocess-reviewer.ts +38 -1
  21. package/lib/native-review-cli.ts +36 -10
  22. package/lib/questionnaire/questionnaire-view.ts +603 -0
  23. package/lib/questionnaire/schema.ts +82 -0
  24. package/lib/questionnaire/validate.ts +141 -0
  25. package/lib/review-candidate-view-owner.ts +20 -5
  26. package/lib/review-candidate-view.ts +9 -2
  27. package/lib/review-host-relay.ts +10 -0
  28. package/lib/review-integration-v2.ts +4 -1
  29. package/lib/shell-bar.ts +13 -0
  30. package/lib/shell-sidebar-layout.ts +10 -4
  31. package/lib/shell-usage-view.ts +5 -2
  32. package/lib/shell-usage.ts +120 -6
  33. package/package.json +1 -1
  34. package/runtime/native-review-cli.mjs +35 -9
  35. package/runtime/review-integration-v2.mjs +4 -1
  36. package/scripts/gentle-ai-installer.mjs +10 -10
  37. package/scripts/verify-package-files.mjs +2 -3
  38. package/tests/animation-policy.test.ts +42 -0
  39. package/tests/ask-user-question.test.ts +435 -0
  40. package/tests/background-cache-warming.test.ts +60 -0
  41. package/tests/background-subagents.test.ts +68 -0
  42. package/tests/command-palette.test.ts +9 -0
  43. package/tests/gentle-agents.test.ts +71 -2
  44. package/tests/gentle-ai-binary.test.ts +1 -1
  45. package/tests/gentle-ai-installer.test.ts +47 -47
  46. package/tests/gentle-ai.test.ts +56 -4
  47. package/tests/gentle-shell.test.ts +355 -2
  48. package/tests/inprocess-reviewer.test.ts +92 -0
  49. package/tests/native-review-capability-contract.test.ts +16 -1
  50. package/tests/native-review-parity.test.ts +19 -0
  51. package/tests/package-manifest.test.ts +6 -17
  52. package/tests/questionnaire-schema.test.ts +274 -0
  53. package/tests/questionnaire-view.test.ts +446 -0
  54. package/tests/rdd-status-line.test.ts +21 -4
  55. package/tests/review-candidate-owner-retry.test.ts +63 -0
  56. package/tests/review-candidate-view.test.ts +15 -0
  57. package/tests/review-controller-native-routing.test.ts +86 -0
  58. package/tests/review-host-relay.test.ts +21 -0
  59. package/tests/review-integration-v2.test.ts +30 -0
  60. package/tests/review-ledger-contract.test.ts +1 -2
  61. package/tests/review-relay-transport-agent.test.ts +107 -2
  62. package/tests/review-risk-assessment.test.ts +104 -0
  63. package/tests/shell-bar.test.ts +8 -0
  64. package/tests/shell-sidebar-layout.test.ts +60 -5
  65. package/tests/shell-usage.test.ts +129 -0
  66. package/tests/skill-collision-prefixes.test.ts +1 -1
  67. package/tests/startup-banner.test.ts +93 -2
  68. package/docs/assets/brand/gentle-pi-banner.png +0 -0
  69. 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
  },
@@ -0,0 +1,52 @@
1
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ import { join } from "node:path";
4
+ import { gentlePiConfigHome } from "./agent-home.ts";
5
+
6
+ export const ANIMATION_POLICY = { QUALITY: "quality", PERFORMANCE: "performance", POTATO: "potato" } as const;
7
+ export type AnimationPolicy = (typeof ANIMATION_POLICY)[keyof typeof ANIMATION_POLICY];
8
+ const SOURCE = { GLOBAL: "global_file", DEFAULT: "default" } as const;
9
+ export const ANIMATION_SCHEMA = "gentle-pi.animations/v1";
10
+
11
+ interface AnimationOptions { gentlePiConfigHome?: string }
12
+ export interface AnimationResolution {
13
+ policy: AnimationPolicy;
14
+ source: (typeof SOURCE)[keyof typeof SOURCE];
15
+ malformed: boolean;
16
+ globalFile: string;
17
+ }
18
+
19
+ export function parseAnimationPolicyFile(raw: string): AnimationPolicy | undefined {
20
+ try {
21
+ const value: unknown = JSON.parse(raw);
22
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
23
+ if (!("schema" in value) || value.schema !== ANIMATION_SCHEMA || !("policy" in value) || Object.keys(value).length !== 2) return undefined;
24
+ return value.policy === "quality" || value.policy === "performance" || value.policy === "potato" ? value.policy : undefined;
25
+ } catch { return undefined; }
26
+ }
27
+
28
+ export function resolveAnimationPolicy(options: AnimationOptions = {}): AnimationResolution {
29
+ const globalFile = join(options.gentlePiConfigHome ?? gentlePiConfigHome(), "animations.json");
30
+ try {
31
+ const policy = parseAnimationPolicyFile(readFileSync(globalFile, "utf8"));
32
+ return { policy: policy ?? "quality", source: SOURCE.GLOBAL, malformed: policy === undefined, globalFile };
33
+ } catch (error) {
34
+ const missing = typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
35
+ return { policy: "quality", source: missing ? SOURCE.DEFAULT : SOURCE.GLOBAL, malformed: !missing, globalFile };
36
+ }
37
+ }
38
+
39
+ /** Same-directory rename prevents readers from seeing a partially written policy. */
40
+ export function writeAnimationPolicy(policy: AnimationPolicy, options: AnimationOptions = {}): string {
41
+ const home = options.gentlePiConfigHome ?? gentlePiConfigHome();
42
+ const path = join(home, "animations.json");
43
+ const temporary = `${path}.${randomUUID()}.tmp`;
44
+ mkdirSync(home, { recursive: true });
45
+ try {
46
+ writeFileSync(temporary, `${JSON.stringify({ schema: ANIMATION_SCHEMA, policy })}\n`, { flag: "wx", mode: 0o600 });
47
+ renameSync(temporary, path);
48
+ } finally {
49
+ try { unlinkSync(temporary); } catch { /* Rename already consumed the temporary file. */ }
50
+ }
51
+ return path;
52
+ }
@@ -0,0 +1,38 @@
1
+ import type { TaskRecord } from "./agents-protocol.ts";
2
+
3
+ const ACTION = { WARM: "warm", STOP: "stop" } as const;
4
+ type WarmingAction = (typeof ACTION)[keyof typeof ACTION];
5
+ interface WarmingDecision {
6
+ warmCost: number;
7
+ missCost: number;
8
+ continuationProbability: number;
9
+ action: WarmingAction;
10
+ }
11
+ interface WarmingOverride { action: WarmingAction }
12
+ interface WarmingEvents {
13
+ on(event: "cache_warming_decision", handler: (event: WarmingDecision) => WarmingOverride | undefined): unknown;
14
+ }
15
+ export interface WarmingState {
16
+ sessionId: string | undefined;
17
+ ownedTaskIds: ReadonlySet<string>;
18
+ tasks: Array<Pick<TaskRecord, "id" | "parentSessionId" | "mode" | "status">>;
19
+ }
20
+
21
+ // Pi 0.86.1's native minimum expected savings, in dollars. Ownership raises
22
+ // continuation probability to 1, not permission to spend without a benefit.
23
+ const MINIMUM_EXPECTED_SAVINGS = 0.05;
24
+
25
+ export function installBackgroundCacheWarming(pi: WarmingEvents, state: () => WarmingState): void {
26
+ pi.on("cache_warming_decision", (event) => {
27
+ const { sessionId, ownedTaskIds, tasks } = state();
28
+ if (!sessionId || !tasks.some(task => task.parentSessionId === sessionId &&
29
+ ownedTaskIds.has(task.id) && task.mode === "background" &&
30
+ (task.status === "queued" || task.status === "running"))) return;
31
+ // Only native candidates arrive here: Pi owns opt-out, replay safety,
32
+ // provider TTLs, fixed horizons, scheduling and usage outside context.
33
+ const economic = Number.isFinite(event.warmCost) && event.warmCost >= 0 &&
34
+ Number.isFinite(event.missCost) && event.missCost > 0 &&
35
+ event.missCost - event.warmCost >= MINIMUM_EXPECTED_SAVINGS;
36
+ return { action: economic ? ACTION.WARM : ACTION.STOP };
37
+ });
38
+ }
@@ -26,6 +26,7 @@ export const COMMAND_PALETTE_CATALOG: readonly CommandPaletteCatalogGroup[] = [
26
26
  { command: "gentle:review-mode", label: "Review mode (receipt-driven development)" },
27
27
  { command: "gentle:background-subagents", label: "Background subagents" },
28
28
  { command: "gentle:double-esc-cancel", label: "Require double Esc to cancel" },
29
+ { command: "gentle:animations", label: "Animation mode" },
29
30
  { command: "gentle:telemetry", label: "Telemetry" },
30
31
  { command: "gentle:banner", label: "Startup banner" },
31
32
  { command: "gentle:banner-color", label: "Banner color" },
@@ -59,6 +59,13 @@ export interface InProcessReviewerRequest {
59
59
  readonly prompt: Buffer;
60
60
  readonly timeoutMs: number;
61
61
  readonly signal?: AbortSignal;
62
+ /**
63
+ * The live pi session id, threaded from the extension context. Pi's main
64
+ * agent loop adds OpenCode attribution headers itself; this side-call must
65
+ * carry them itself instead. Absent (or empty) means no attribution header,
66
+ * never an invented one and never an error.
67
+ */
68
+ readonly sessionId?: string;
62
69
  /** e.g. "review-risk" — only used to name the routing config key in refusal messages. */
63
70
  readonly routingKey: string;
64
71
  }
@@ -126,6 +133,30 @@ function isTextContent(part: { type?: unknown }): part is TextContent {
126
133
  return part.type === "text";
127
134
  }
128
135
 
136
+ /**
137
+ * Mirrors pi's main-loop OpenCode attribution condition exactly
138
+ * (core/provider-attribution.js#getSessionHeaders): the model's provider is
139
+ * `opencode` or `opencode-go`, or its baseUrl host is `opencode.ai`. Returns
140
+ * the `{ x-opencode-session, x-opencode-client }` attribution pair, or
141
+ * undefined when the model is not OpenCode-routed or there is no live session
142
+ * id — a missing session id is never an error and never invents a header.
143
+ * The URL parse is guarded: an unparseable baseUrl follows the provider
144
+ * condition alone.
145
+ */
146
+ export function openCodeSessionAttributionHeaders(model: Model<Api>, sessionId: string | undefined): ProviderHeaders | undefined {
147
+ const isOpenCode = model.provider === "opencode"
148
+ || model.provider === "opencode-go"
149
+ || (() => {
150
+ try {
151
+ return new URL(String(model.baseUrl ?? "")).hostname === "opencode.ai";
152
+ } catch {
153
+ return false;
154
+ }
155
+ })();
156
+ if (!isOpenCode || typeof sessionId !== "string" || sessionId.length === 0) return undefined;
157
+ return { "x-opencode-session": sessionId, "x-opencode-client": "pi" };
158
+ }
159
+
129
160
  /**
130
161
  * Runs one reviewer completion in-process: resolve the model, authenticate,
131
162
  * map the routing thinking label, and complete exactly one frozen prompt as
@@ -168,6 +199,12 @@ export async function runInProcessReviewer(request: InProcessReviewerRequest, de
168
199
  );
169
200
  }
170
201
 
202
+ // Extension side-calls bypass pi's main agent loop, which is where OpenCode
203
+ // attribution headers are otherwise added, so this completion carries them
204
+ // itself — as a default beneath the registry's own auth headers, the same
205
+ // merge order pi's core uses for explicit header sources.
206
+ const attributionHeaders = openCodeSessionAttributionHeaders(model, request.sessionId);
207
+
171
208
  // The caller's own signal (if any) and a floor timeout race together:
172
209
  // whichever fires first aborts the completion. The catch branch below
173
210
  // tells them apart by which underlying signal actually fired, never by
@@ -188,7 +225,7 @@ export async function runInProcessReviewer(request: InProcessReviewerRequest, de
188
225
  signal: combinedSignal,
189
226
  timeoutMs: request.timeoutMs,
190
227
  ...(auth.apiKey === undefined ? {} : { apiKey: auth.apiKey }),
191
- ...(auth.headers === undefined ? {} : { headers: auth.headers }),
228
+ ...(auth.headers === undefined && attributionHeaders === undefined ? {} : { headers: attributionHeaders === undefined ? auth.headers : { ...attributionHeaders, ...auth.headers } }),
192
229
  ...(reasoning.reasoning === undefined ? {} : { reasoning: reasoning.reasoning }),
193
230
  };
194
231
 
@@ -201,7 +201,7 @@ export interface NativeReviewModeRequest {
201
201
  // explicit `committedOnly` acknowledgement, exactly like Native START's
202
202
  // baseRef/committedOnly pairing, because both select a committed range
203
203
  // instead of the ambient working tree.
204
- export interface NativeReviewAssessRequest {
204
+ export interface NativeReviewAssessRequest extends NativeUntrackedSelection {
205
205
  cwd: string;
206
206
  baseRef?: string;
207
207
  committedOnly?: boolean;
@@ -705,7 +705,11 @@ function isNativeUntrackedPath(value: unknown): value is string {
705
705
  && value.split("/").every((segment) => segment.length > 0 && segment !== "." && segment !== "..");
706
706
  }
707
707
 
708
- function nativeUntrackedSelection(request: NativeUntrackedSelectionRequest): NativeUntrackedSelection {
708
+ export function nativeUntrackedSelection(request: {
709
+ untrackedScope?: unknown;
710
+ expectedUntrackedInventory?: unknown;
711
+ intendedUntracked?: unknown;
712
+ }): NativeUntrackedSelection {
709
713
  const { untrackedScope, expectedUntrackedInventory, intendedUntracked } = request;
710
714
  const declared = untrackedScope !== undefined || expectedUntrackedInventory !== undefined || intendedUntracked !== undefined;
711
715
  if (!declared) return {};
@@ -716,16 +720,19 @@ function nativeUntrackedSelection(request: NativeUntrackedSelectionRequest): Nat
716
720
  ) {
717
721
  throw new TypeError("Native untracked selection must declare one scope, one inventory digest, and unique repository-relative paths");
718
722
  }
719
- if (untrackedScope === NATIVE_UNTRACKED_SCOPE.EXCLUDE && (intendedUntracked?.length ?? 0) > 0) {
723
+ // The guard above establishes the array and element types for both typed
724
+ // native requests and untyped facade input.
725
+ const paths = intendedUntracked as readonly string[] | undefined;
726
+ if (untrackedScope === NATIVE_UNTRACKED_SCOPE.EXCLUDE && (paths?.length ?? 0) > 0) {
720
727
  throw new TypeError("Native exclude untracked selection cannot include paths");
721
728
  }
722
- if (untrackedScope === NATIVE_UNTRACKED_SCOPE.SELECT && (intendedUntracked?.length ?? 0) === 0) {
729
+ if (untrackedScope === NATIVE_UNTRACKED_SCOPE.SELECT && (paths?.length ?? 0) === 0) {
723
730
  throw new TypeError("Native select untracked selection requires at least one path");
724
731
  }
725
732
  return {
726
733
  untrackedScope,
727
734
  expectedUntrackedInventory,
728
- intendedUntracked: intendedUntracked === undefined ? undefined : [...intendedUntracked],
735
+ intendedUntracked: paths === undefined ? undefined : [...paths],
729
736
  };
730
737
  }
731
738
 
@@ -1018,6 +1025,13 @@ export const NATIVE_CLI_CONTRACTS = Object.freeze({
1018
1025
  // and hint remain dark because neither is proven to reach the negotiated
1019
1026
  // START path Pi consumes.
1020
1027
  "3.4.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
1028
+ // v3.5.0 repeats 3.4.0: the published provider contract bundle is
1029
+ // byte-identical at 1.2.0, both binaries advertise capabilities/v2.6
1030
+ // with only build-identity differences, and no review-integration schema
1031
+ // changed. The v2 preflight failure identity fix does not change the
1032
+ // closed START/STATUS fields this row negotiates. riskEvidence and hint
1033
+ // remain dark; neither is proven to reach Pi's negotiated START path.
1034
+ "3.5.0": Object.freeze({ start: true, finalize: true, validate: true, bindSdd: true, status: true, inventory: true, reclaim: true, recover: true, abandon: true, quarantineLegacy: true, reconcileAuthority: true, repairLegacyAlias: true, mode: true, riskEvidence: false, hint: false, delivery: true }),
1021
1035
  });
1022
1036
 
1023
1037
  export interface NativeReviewProcessDiagnostics {
@@ -1102,8 +1116,19 @@ function decodeSelectedLenses(value: unknown, riskLevel: string, lensesRequired:
1102
1116
  function enumString(value: unknown, allowed: readonly string[]): string { const parsed = stringValue(value); if (!allowed.includes(parsed)) throw new Error("unsupported enum"); return parsed; }
1103
1117
  const NATIVE_DIAGNOSTIC_TEXT_LIMIT = 4_096;
1104
1118
 
1105
- function sanitizeNativeDiagnosticText(value: string, limit = NATIVE_DIAGNOSTIC_TEXT_LIMIT): string {
1106
- const normalized = value
1119
+ function sanitizeNativeDiagnosticText(value: string, limit = NATIVE_DIAGNOSTIC_TEXT_LIMIT, operation?: NativeReviewOperation): string {
1120
+ // ASSESS diagnostics are projected into a public verification plan. Retain
1121
+ // native guidance, not local paths or environment assignment values.
1122
+ const input = operation === NATIVE_REVIEW_OPERATION.ASSESS
1123
+ ? value
1124
+ .replace(/(?<![\w-])[a-z_][a-z0-9_]*=(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s]+)/gi, "[REDACTED ENV]")
1125
+ .replace(/--(?:password|token|secret|authorization|cookie|private[_-]key|access[_-]token|[a-z0-9_-]+[_-]token|api[_-]?key)[ \t]+(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s]+)/gi, "[REDACTED CREDENTIAL]")
1126
+ // Quoted paths have a clear boundary. For an unquoted path, the
1127
+ // remaining line is ambiguous (spaces may belong to the filename).
1128
+ // Redact that suffix rather than leak trailing path components.
1129
+ .replace(/"(?:[A-Za-z]:[\\/]|\/)[^"\r\n]*"|'(?:[A-Za-z]:[\\/]|\/)[^'\r\n]*'|(?:[A-Za-z]:[\\/]|\/)[^\r\n]*/g, "[REDACTED PATH]")
1130
+ : value;
1131
+ const normalized = input
1107
1132
  .replace(/\x1b](?:[^\x07\x1b]|\x1b(?!\\))*?(?:\x07|\x1b\\)/g, "[REDACTED CONTROL]")
1108
1133
  .replace(/\x1b[PX^_][\s\S]*?\x1b\\/g, "[REDACTED CONTROL]")
1109
1134
  .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "[REDACTED CONTROL]")
@@ -1137,7 +1162,7 @@ export function sanitizeForeignNativeReviewDiagnostics(value: unknown): NativeRe
1137
1162
  timed_out: booleanValue(raw.timed_out),
1138
1163
  output_limit_exceeded: booleanValue(raw.output_limit_exceeded),
1139
1164
  ...(maxBufferBytes === undefined ? {} : { max_buffer_bytes: maxBufferBytes, configuration_hint: configurationHint! }),
1140
- ...(raw.stderr === undefined ? {} : { stderr: sanitizeNativeDiagnosticText(stringValue(raw.stderr)) }),
1165
+ ...(raw.stderr === undefined ? {} : { stderr: sanitizeNativeDiagnosticText(stringValue(raw.stderr), NATIVE_DIAGNOSTIC_TEXT_LIMIT, operation) }),
1141
1166
  };
1142
1167
  } catch { return undefined; }
1143
1168
  }
@@ -1154,7 +1179,7 @@ function nativeProcessDiagnostics(operation: NativeReviewOperation, code: Native
1154
1179
  ...(code === NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT && maxBufferBytes !== undefined
1155
1180
  ? { max_buffer_bytes: maxBufferBytes, configuration_hint: NATIVE_REVIEW_MAX_BUFFER_CONFIGURATION_HINT }
1156
1181
  : {}),
1157
- ...(result?.stderr.trim() ? { stderr: sanitizeNativeDiagnosticText(result.stderr) } : {}),
1182
+ ...(result?.stderr.trim() ? { stderr: sanitizeNativeDiagnosticText(result.stderr, NATIVE_DIAGNOSTIC_TEXT_LIMIT, operation) } : {}),
1158
1183
  };
1159
1184
  }
1160
1185
 
@@ -2535,6 +2560,7 @@ export class NativeReviewCliV216 implements NativeReviewCli {
2535
2560
  // rejects -- callers (the `gentle_review` tool's `assess` operation) fail
2536
2561
  // closed to `high`.
2537
2562
  async assess(request: NativeReviewAssessRequest): Promise<ReviewAssessmentV1> {
2563
+ const selection = nativeUntrackedSelection(request);
2538
2564
  if (request.baseRef !== undefined && !isCanonicalProcessString(request.baseRef)) throw new TypeError("Native ASSESS baseRef must be a non-empty, trimmed, NUL-free string");
2539
2565
  if (request.baseRef !== undefined && request.committedOnly !== true) throw new TypeError("Native ASSESS baseRef requires explicit committedOnly acknowledgement");
2540
2566
  if (request.baseRef === undefined && request.committedOnly !== undefined) throw new TypeError("Native ASSESS committedOnly requires an explicit baseRef");
@@ -2542,7 +2568,7 @@ export class NativeReviewCliV216 implements NativeReviewCli {
2542
2568
  const execution = await this.invoke(
2543
2569
  NATIVE_REVIEW_OPERATION.ASSESS,
2544
2570
  cwd,
2545
- ["review", "assess", "--cwd", cwd, ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]), "--json"],
2571
+ ["review", "assess", "--cwd", cwd, ...(request.baseRef === undefined ? [] : ["--base-ref", request.baseRef, "--committed-only"]), ...nativeUntrackedSelectionArguments(selection), "--json"],
2546
2572
  false,
2547
2573
  request.signal,
2548
2574
  this.executablePath(NATIVE_REVIEW_OPERATION.ASSESS, false),