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.
- package/README.md +88 -59
- package/assets/orchestrator-delegation.md +1 -1
- package/bin/gentle-shell.mjs +198 -0
- package/docs/assets/brand/gentle-shell-banner.gif +0 -0
- package/docs/assets/diagrams/odd-workflow.svg +74 -0
- package/docs/assets/features/agents-view.png +0 -0
- package/docs/assets/features/changes-view.png +0 -0
- package/docs/assets/features/command-palette.png +0 -0
- package/docs/assets/features/profiles-routing.png +0 -0
- package/docs/gentle-agents-activity.md +95 -0
- package/docs/gentle-shell.md +26 -2
- package/docs/readme-reference.md +99 -6
- package/extensions/ask-user-choice.ts +70 -22
- package/extensions/ask-user-question.ts +338 -0
- package/extensions/gentle-agents.ts +41 -1
- package/extensions/gentle-ai.ts +59 -18
- package/extensions/gentle-shell.ts +99 -10
- package/extensions/quiet-tools.ts +28 -5
- package/extensions/startup-banner.ts +25 -10
- package/lib/agents-rpc-publisher.ts +342 -0
- package/lib/agents-runner.ts +7 -2
- package/lib/animation-policy.ts +52 -0
- package/lib/background-cache-warming.ts +38 -0
- package/lib/command-palette-catalog.ts +1 -0
- package/lib/gentle-shell-launcher.ts +482 -0
- package/lib/inprocess-reviewer.ts +38 -1
- package/lib/native-review-cli.ts +36 -10
- package/lib/questionnaire/questionnaire-view.ts +603 -0
- package/lib/questionnaire/schema.ts +82 -0
- package/lib/questionnaire/validate.ts +141 -0
- package/lib/review-candidate-view-owner.ts +20 -5
- package/lib/review-candidate-view.ts +9 -2
- package/lib/review-host-relay.ts +10 -0
- package/lib/review-integration-v2.ts +4 -1
- package/lib/rpc-host.ts +36 -0
- package/lib/shell-bar.ts +13 -0
- package/lib/shell-sidebar-layout.ts +10 -4
- package/lib/shell-usage-view.ts +5 -2
- package/lib/shell-usage.ts +120 -6
- package/package.json +5 -1
- package/runtime/gentle-shell-launcher.mjs +483 -0
- package/runtime/native-review-cli.mjs +35 -9
- package/runtime/review-integration-v2.mjs +4 -1
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/install-gentle-ai.mjs +14 -7
- package/scripts/install-tui-mode-setting.mjs +78 -1
- package/scripts/verify-package-files.mjs +6 -3
- package/tests/agents-rpc-publisher.test.ts +407 -0
- package/tests/agents-runner.test.ts +10 -0
- package/tests/animation-policy.test.ts +42 -0
- package/tests/ask-user-choice.test.ts +129 -0
- package/tests/ask-user-question.test.ts +661 -0
- package/tests/background-cache-warming.test.ts +60 -0
- package/tests/background-subagents.test.ts +68 -0
- package/tests/command-palette.test.ts +9 -0
- package/tests/gentle-agents.test.ts +161 -2
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +56 -4
- package/tests/gentle-shell-bin.test.ts +188 -0
- package/tests/gentle-shell-launcher.test.ts +718 -0
- package/tests/gentle-shell.test.ts +355 -2
- package/tests/inprocess-reviewer.test.ts +92 -0
- package/tests/install-tui-mode-guard.test.ts +99 -0
- package/tests/install-tui-mode-setting.test.ts +39 -1
- package/tests/native-review-capability-contract.test.ts +16 -1
- package/tests/native-review-parity.test.ts +19 -0
- package/tests/package-manifest.test.ts +6 -17
- package/tests/questionnaire-schema.test.ts +274 -0
- package/tests/questionnaire-view.test.ts +446 -0
- package/tests/rdd-status-line.test.ts +21 -4
- package/tests/review-candidate-owner-retry.test.ts +63 -0
- package/tests/review-candidate-view.test.ts +15 -0
- package/tests/review-controller-native-routing.test.ts +86 -0
- package/tests/review-host-relay.test.ts +21 -0
- package/tests/review-integration-v2.test.ts +30 -0
- package/tests/review-ledger-contract.test.ts +1 -2
- package/tests/review-relay-transport-agent.test.ts +107 -2
- package/tests/review-risk-assessment.test.ts +104 -0
- package/tests/rpc-host.test.ts +77 -0
- package/tests/shell-bar.test.ts +8 -0
- package/tests/shell-sidebar-layout.test.ts +60 -5
- package/tests/shell-usage.test.ts +129 -0
- package/tests/skill-collision-prefixes.test.ts +1 -1
- package/tests/startup-banner.test.ts +93 -2
- package/docs/assets/brand/gentle-pi-banner.png +0 -0
- 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.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
725
|
-
tick
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
},
|