pi-jar 0.1.8 → 0.2.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.
@@ -1,5 +1,5 @@
1
1
  import { getAgentDir, SessionManager, SettingsManager, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { basename } from "node:path";
2
+ import { basename, join } from "node:path";
3
3
  import { execFile } from "node:child_process";
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { Key, truncateToWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
@@ -16,13 +16,15 @@ import { openJarHistory } from "../src/history-ui.ts";
16
16
  import { GOAL_ENTRY, GoalStore } from "../src/goals.ts";
17
17
  import { GoalLoop } from "../src/goal-loop.ts";
18
18
  import { FOOTER_FIELDS } from "../src/footer-settings.ts";
19
- import { defaultVisualSettings, loadVisualSettings, migrateLegacySettings, saveVisualSettings, type JarVisualSettings } from "../src/settings.ts";
19
+ import { defaultVisualSettings, loadVisualSettings, saveVisualSettings, type JarVisualSettings } from "../src/settings.ts";
20
+ import { ProfileStore, PROFILE_FILE, PROFILE_ENTRY, conversationStarted, pinnedProfileId } from "../src/profiles.ts";
20
21
  import { openJarSettings, type PiPreferences } from "../src/settings-ui.ts";
21
22
  import { pickSession } from "../src/session-ui.ts";
22
23
  import { fetchQuota, QuotaCache, type QuotaProvider } from "../src/quota.ts";
23
24
  import { ModelRoleManager } from "../src/model-roles.ts";
24
25
  import { jarCommit } from "../src/commit.ts";
25
26
  import { registerAdvisor } from "../src/advisor.ts";
27
+ import { registerContextDiet } from "../src/context-diet.ts";
26
28
  import { registerInfoPanels } from "../src/info-panels.ts";
27
29
  import { SideUsage } from "../src/side-model.ts";
28
30
  import { PlanMode } from "../src/plan.ts";
@@ -38,12 +40,13 @@ import { WorkingState } from "../src/working.ts";
38
40
  import { ChangeTracker } from "../src/changes.ts";
39
41
  import { CHILD_ENV, DelegateRegistry, registerDelegate, SUBAGENT_MESSAGE, type DelegateRun } from "../src/delegate.ts";
40
42
  import { CHILD_WORKTREE_ENV, workspacePathAllowed } from "../src/delegate-worktree.ts";
41
- import { createDiscussionPaper, DISCUSSION_FILE_ENV, disposeDiscussionPaper, registerDiscussionTool, SUBAGENT_NAME_ENV } from "../src/discussion.ts";
43
+ import { DiscussionBroker, DISCUSSION_NOTICE, discussionClientFromEnv, registerDiscussionTool } from "../src/discussion.ts";
42
44
  import { openActivityView, type ActivityTarget } from "../src/activity-view.ts";
43
45
  import { ICON_SETS, setIconSet, withIcon } from "../src/icons.ts";
44
46
  import { collectPrompts, openPromptSearch } from "../src/prompt-search.ts";
45
47
  import { ago, recentSessions, sessionDetails, type RecentSession } from "../src/session-gallery.ts";
46
48
  import { clearSessionBranchCache, sessionBranch } from "../src/session-branch.ts";
49
+ import { ContextSampler, formatPerf, LARGE_SESSION_ENTRIES, processRss, RenderScheduler, type RenderKind } from "../src/perf.ts";
47
50
  import { registerChangeReview } from "../src/diff-view.ts";
48
51
  import { registerShells, ShellManager, type ShellEvent, type ShellJob } from "../src/shells.ts";
49
52
  import { registerShellNotifications } from "../src/shell-notifications.ts";
@@ -57,7 +60,6 @@ const VERSION = (() => {
57
60
  })();
58
61
  /** Ignore the click Pi may synthesize right after a press we already acted on. */
59
62
  const WELCOME_CLICK_DEDUPE_MS = 400;
60
- const LARGE_SESSION_ENTRIES = 800;
61
63
  /** Entries Pi writes when a fresh session starts; a branch holding only these has no transcript yet. */
62
64
  const SESSION_SETUP_ENTRIES = new Set(["thinking_level_change", "model_change", "session_info"]);
63
65
  export default function piJar(pi: ExtensionAPI): void {
@@ -68,7 +70,7 @@ export default function piJar(pi: ExtensionAPI): void {
68
70
  // and handlers are disabled. Remove only parent automation, not task/user context.
69
71
  const parentAutomation = new Set([
70
72
  "pi-jar.goal-context", "pi-jar.goal-continuation",
71
- "pi-jar.plan-context", "pi-jar.plan-reminder", "pi-jar.moderator-context", SUBAGENT_MESSAGE
73
+ "pi-jar.plan-context", "pi-jar.plan-reminder", "pi-jar.moderator-context", SUBAGENT_MESSAGE, DISCUSSION_NOTICE
72
74
  ]);
73
75
  pi.on("context", (event) => {
74
76
  const messages = event.messages.filter((message) => !parentAutomation.has((message as { customType?: string }).customType ?? ""));
@@ -97,22 +99,35 @@ export default function piJar(pi: ExtensionAPI): void {
97
99
  installCompactBuiltinTools(pi);
98
100
  let demo = false;
99
101
  let visualSettings = defaultVisualSettings();
102
+ let profileStore: ProfileStore | undefined;
100
103
  let animations = visualSettings.animations;
101
104
  let enabled = visualSettings.ui;
102
105
  let disposeFooter: (() => void) | undefined;
103
106
  let footerTui: { requestRender(): void } | undefined;
104
- /** Footer facts that walk the session to compute; refreshed on the events that change them, never per frame. */
105
- let footerContext = "ctx ?";
107
+ /**
108
+ * Per-session render budget and context-usage cache (src/perf.ts): created on session_start and
109
+ * disposed on shutdown, so no repaint or sampling timer outlives its session.
110
+ */
111
+ let renders: RenderScheduler | undefined;
112
+ let contextUsage: ContextSampler | undefined;
113
+ /** Only an installed footer in a live session is repainted; user actions and transitions stay prompt. */
114
+ const repaint = (source: string, kind: RenderKind = "foreground") => { if (footerTui) renders?.request(source, kind); };
106
115
  let footerSessionName: string | undefined;
116
+ /** Boundaries refresh the footer facts that walk the session; message ends only mark context dirty. */
107
117
  const sampleFooter = (ctx: ExtensionContext) => {
108
- try {
109
- const usage = ctx.getContextUsage();
110
- footerContext = usage?.percent == null || !Number.isFinite(usage.percent) ? "ctx ?" : `ctx ${Math.round(usage.percent)}%`;
111
- } catch { footerContext = "ctx ?"; }
118
+ contextUsage?.refresh(() => ctx.getContextUsage());
112
119
  try { footerSessionName = ctx.sessionManager?.getSessionName?.(); } catch { footerSessionName = undefined; }
113
- footerTui?.requestRender();
120
+ repaint("session", "transition");
114
121
  };
115
122
  let quotaCache: QuotaCache | undefined;
123
+ let enableQuota: NodeJS.Timeout | undefined;
124
+ /**
125
+ * Lookups start on session boundaries, never from a render, and only while an installed footer can
126
+ * show them (never in RPC children or with the UI off); the cache bounds them to one per TTL.
127
+ */
128
+ const refreshQuota = (ctx: ExtensionContext) => {
129
+ if (footerTui && footerSettings.quota) quotaCache?.refresh(ctx.model?.provider, welcomeStatuses(), Date.now());
130
+ };
116
131
  let cost = 0;
117
132
  let welcomeInterval: ReturnType<typeof setInterval> | undefined;
118
133
  let welcomeFrame = 0;
@@ -133,8 +148,9 @@ export default function piJar(pi: ExtensionAPI): void {
133
148
  let workingIndicatorKey = "";
134
149
  let openSettings: (ctx: ExtensionContext) => Promise<void> = async () => {};
135
150
  let settingsOpen = false;
136
- let discussionFile: string | undefined = process.env[DISCUSSION_FILE_ENV];
137
- registerDiscussionTool(pi, () => discussionFile, () => process.env[SUBAGENT_NAME_ENV] ?? "moderator");
151
+ const discussionClient = delegatedChild ? discussionClientFromEnv() : undefined;
152
+ let discussion: DiscussionBroker | undefined;
153
+ registerDiscussionTool(pi, () => delegatedChild ? discussionClient : discussion?.local());
138
154
 
139
155
  // A finished list stays visible (all struck through) until the user's next prompt, like Claude.
140
156
  let todosAcknowledged = false;
@@ -182,38 +198,47 @@ export default function piJar(pi: ExtensionAPI): void {
182
198
  let changes: ChangeTracker | undefined;
183
199
  let changeCount = 0;
184
200
  const refreshChanges = () => {
201
+ const before = changeCount;
185
202
  try { changeCount = changes?.count() ?? 0; } catch { changeCount = 0; }
186
- footerTui?.requestRender();
203
+ if (changeCount !== before) repaint("changes", "transition");
187
204
  };
188
205
  registerChangeReview(pi, () => changes, refreshChanges);
189
206
  let shells: ShellManager | undefined;
190
207
  registerShells(pi, () => shells);
191
208
  const shellNotifications = registerShellNotifications(pi, () => shells);
209
+ /** Shell rows show status and watch matches: changing those is a transition, anything else a background delta. */
210
+ let shellSignature = "";
211
+ const noteShells = () => {
212
+ if (!footerTui) return;
213
+ const signature = (shells?.summaries() ?? []).map((job) => `${job.id}:${job.status}:${job.matched ? 1 : 0}`).join(" ");
214
+ repaint("shells", signature === shellSignature ? "background" : "transition");
215
+ shellSignature = signature;
216
+ };
192
217
  const onShellEvent = (event: ShellEvent) => {
193
- footerTui?.requestRender();
218
+ noteShells();
194
219
  if (!event.job.notify) return;
195
220
  // Queue compact metadata until a model boundary; observed completions are acknowledged.
196
221
  shellNotifications.notify();
197
222
  };
198
223
  /** Footer indicators owned by pi-jar; live subagents and shells get their own rows. */
199
224
  const footerChips = () => changeCount ? [withIcon("changes", `${changeCount} file${changeCount === 1 ? "" : "s"} · /diff`)] : [];
225
+ const subagents = new DelegateRegistry();
200
226
  /**
201
- * Subagent streams and shell output change the footer many times a second; every requested frame
202
- * re-renders the whole transcript, so background sources repaint it at most this often.
227
+ * Subagent streams notify many times a second, but footer rows only change state rarely: a changed
228
+ * key/state set is a transition, anything else (tool counts, activity text) a background delta.
203
229
  */
204
- const BACKGROUND_REPAINT_MS = 250;
205
- let backgroundRepaint: NodeJS.Timeout | undefined;
206
- const repaintFooterSoon = () => {
207
- if (backgroundRepaint) return;
208
- backgroundRepaint = setTimeout(() => { backgroundRepaint = undefined; footerTui?.requestRender(); }, BACKGROUND_REPAINT_MS);
209
- backgroundRepaint.unref?.();
210
- };
211
- const subagents = new DelegateRegistry();
212
- subagents.subscribe(repaintFooterSoon);
230
+ let subagentSignature = "";
231
+ subagents.subscribe(() => {
232
+ if (!footerTui) return;
233
+ const signature = subagents.records().map(({ key, run }) => `${key}:${run.state}`).join(" ");
234
+ repaint("subagents", signature === subagentSignature ? "background" : "transition");
235
+ subagentSignature = signature;
236
+ });
213
237
  /** Finished work stays visible briefly so a quick run is not a flicker. */
214
238
  const ACTIVITY_LINGER_MS = 5000;
239
+ // A hibernated agent has no live process but resumes on demand: the footer shows it as idle.
215
240
  const subagentState = (run: DelegateRun): ActivityState => run.state === "working" ? "running" : run.state === "queued" ? "queued"
216
- : run.state === "idle" ? "idle" : run.state === "paused" ? "paused" : run.state === "stopped" ? "stopped"
241
+ : run.state === "idle" || run.state === "hibernated" ? "idle" : run.state === "paused" ? "paused" : run.state === "stopped" ? "stopped"
217
242
  : run.state === "done" ? "done" : "failed";
218
243
  const shellActivityState = (job: Omit<ShellJob, "lines">): ActivityState => job.status === "running" ? "running"
219
244
  : job.status === "killed" ? "stopped" : job.status === "exited" && job.exitCode === 0 ? "done" : "failed";
@@ -235,17 +260,23 @@ export default function piJar(pi: ExtensionAPI): void {
235
260
  return rows;
236
261
  };
237
262
  let overlayOpen = false;
238
- /** Subagents, shells and other extensions' roles in one live split view. */
263
+ /**
264
+ * Subagents, shells and other extensions' roles in one live split view. While it is open it
265
+ * repaints live updates itself (on the tier's focused cadence), so footer background deltas wait.
266
+ */
239
267
  const openActivity = async (ctx: ExtensionContext, initial?: ActivityTarget) => {
240
268
  if (!ctx.hasUI || ctx.mode !== "tui" || overlayOpen) return;
241
269
  overlayOpen = true;
270
+ const budget = renders;
271
+ budget?.setFocused(true);
242
272
  try {
243
273
  await openActivityView(ctx, {
244
274
  subagents, ...(shells ? { shells } : {}),
245
275
  roles: () => collectStatuses(welcomeStatuses(), Date.now()).roles.filter((role) => !role.id.startsWith("delegate-"))
246
- .map((role) => ({ id: role.id, name: role.name, state: role.state, ...(role.task ? { task: role.task } : {}) }))
276
+ .map((role) => ({ id: role.id, name: role.name, state: role.state, ...(role.task ? { task: role.task } : {}) })),
277
+ repaintMs: () => renders?.focusedMs() ?? 100
247
278
  }, initial);
248
- } finally { overlayOpen = false; }
279
+ } finally { overlayOpen = false; budget?.setFocused(false); }
249
280
  };
250
281
  /** Searchable project sessions with a details pane; Enter resumes the selected one. */
251
282
  const openSessions = async (ctx: ExtensionContext | ExtensionCommandContext, query = "") => {
@@ -275,12 +306,57 @@ export default function piJar(pi: ExtensionAPI): void {
275
306
  registerAskTool(pi);
276
307
  const modelRoles = new ModelRoleManager(pi);
277
308
  const sideUsage = new SideUsage();
278
- registerAdvisor(pi, modelRoles, { enabled: () => visualSettings.advisor, gates: () => visualSettings.advisorGates, usage: sideUsage });
309
+ registerContextDiet(pi, () => visualSettings.contextDiet === true);
310
+ const advisor = registerAdvisor(pi, modelRoles, { enabled: () => !delegatedChild && visualSettings.advisor, gates: () => !delegatedChild && visualSettings.advisorGates, usage: sideUsage });
279
311
  registerInfoPanels(pi, { side: sideUsage, quotaEnabled: () => quotaCache?.enabled ?? false,
280
312
  quota: (ctx) => quotaCache?.get(ctx.model?.provider, welcomeStatuses(), Date.now()) });
281
- modelRoles.register((ctx) => openRolesUi(ctx, modelRoles), { activateDefault: () => !delegatedChild });
313
+ let followingTheme: string | undefined;
314
+ const profileEntries = (ctx: ExtensionContext): readonly unknown[] => {
315
+ try { return ctx.sessionManager.getEntries?.() ?? sessionBranch(ctx); } catch { return sessionBranch(ctx); }
316
+ };
317
+ const applyProfileTheme = (name: string, ctx: ExtensionContext) => {
318
+ if (!ctx.hasUI || ctx.mode !== "tui" || typeof ctx.ui.setTheme !== "function") return;
319
+ const target = name === "follow" ? followingTheme ?? "system" : name;
320
+ if (!ctx.ui.getTheme?.(target)) throw new Error(`Theme ${target} is not loaded`);
321
+ const result = ctx.ui.setTheme(target);
322
+ if (result && !result.success) throw new Error(result.error ?? `Could not apply theme ${target}`);
323
+ };
324
+ const initializeProfiles = (ctx: ExtensionContext) => {
325
+ const previousTheme = ctx.ui.theme?.name;
326
+ followingTheme ??= previousTheme ?? "system";
327
+ try {
328
+ if (!existsSync(join(getAgentDir(), PROFILE_FILE))
329
+ && (!ctx.hasUI || ctx.mode !== "tui" || typeof ctx.ui.getAllThemes !== "function")) {
330
+ profileStore = undefined; visualSettings = loadVisualSettings(getAgentDir()); return undefined;
331
+ }
332
+ modelRoles.load(ctx.cwd);
333
+ const legacyAccent = loadVisualSettings(getAgentDir()).accent;
334
+ const legacyTheme = accentTheme(legacyAccent);
335
+ const initialTheme = legacyAccent !== "follow" && ctx.ui.getTheme?.(legacyTheme) ? legacyTheme : previousTheme ?? "system";
336
+ const store = new ProfileStore(getAgentDir(), initialTheme, modelRoles.profileRoleConfig());
337
+ const entries = profileEntries(ctx);
338
+ const id = pinnedProfileId(entries) ?? (conversationStarted(entries) ? "default" : store.active().id);
339
+ const profile = store.list().find((item) => item.id === id);
340
+ if (!profile) throw new Error("This session's profile is no longer available");
341
+ applyProfileTheme(profile.theme, ctx);
342
+ if (store.activeName !== profile.name) store.activate(profile.name);
343
+ profileStore = store;
344
+ visualSettings = profile.settings;
345
+ composer.setProfile(profile.name);
346
+ return { roles: profile.roles, profileId: profile.id };
347
+ } catch (error) {
348
+ if (previousTheme) { try { applyProfileTheme(previousTheme, ctx); } catch {} }
349
+ profileStore = undefined; visualSettings = loadVisualSettings(getAgentDir()); composer.setProfile("Default");
350
+ ctx.ui.notify(`Could not load profiles: ${(error as Error).message}`, "warning");
351
+ return undefined;
352
+ }
353
+ };
354
+ modelRoles.register((ctx) => openRolesUi(ctx, modelRoles), { activateDefault: () => !delegatedChild, beforeSessionStart: initializeProfiles });
282
355
  let delegateController: DelegateController | undefined;
283
- registerDelegate(pi, modelRoles, subagents, { changes: () => changes, changed: refreshChanges, discussionFile: () => discussionFile,
356
+ registerDelegate(pi, modelRoles, subagents, { changes: () => changes, changed: refreshChanges,
357
+ discussionEnv: (key, name) => discussion?.childEnv(key, name),
358
+ discussionRetire: (key) => discussion?.retire(key),
359
+ discussionUnread: (key) => discussion?.unreadFor(key) ?? 0,
284
360
  getMaxSubagents: () => visualSettings.maxSubagents, onController: (controller) => { delegateController = controller; } });
285
361
  if (!delegatedChild) registerDemocracy(pi, () => delegateController, () => visualSettings.maxSubagents);
286
362
  const planMode = new PlanMode(pi, () => todos, modelRoles, updateTaskWidget);
@@ -289,7 +365,7 @@ export default function piJar(pi: ExtensionAPI): void {
289
365
  goals: () => goals, todos: () => todos, roles: modelRoles, planActive: () => planMode.isEnabled(),
290
366
  maxRounds: () => visualSettings.goalRounds,
291
367
  subagentMode: (agent) => subagents.resolve(agent)?.run.mode,
292
- changed: (ctx) => { footerTui?.requestRender(); welcomeTui?.requestRender(); updateTaskWidget(ctx); },
368
+ changed: (ctx) => { repaint("goal", "transition"); welcomeTui?.requestRender(); updateTaskWidget(ctx); },
293
369
  completed: () => composer.flash("complete", 4000)
294
370
  });
295
371
  if (!delegatedChild) goalLoop.register();
@@ -372,9 +448,8 @@ export default function piJar(pi: ExtensionAPI): void {
372
448
  welcomeFrame = 0;
373
449
  welcomeDismiss = 0;
374
450
  try {
375
- const contextUsage = ctx.getContextUsage();
376
- const context = contextUsage?.percent != null && Number.isFinite(contextUsage.percent)
377
- ? `ctx ${Math.round(contextUsage.percent)}%` : "ctx ?";
451
+ // The cached sample: the welcome never forces its own walk of the session.
452
+ const context = contextUsage?.label ?? "ctx ?";
378
453
  const commands = pi.getCommands().filter((item) => item.source === "extension");
379
454
  const managers: ("tasks" | "subagents")[] = [];
380
455
  if (commands.some((item) => item.name === "tasks")) managers.push("tasks");
@@ -448,7 +523,7 @@ export default function piJar(pi: ExtensionAPI): void {
448
523
  const quota = footerSettings.quota ? quotaCache?.get(ctx.model?.provider, statuses, Date.now()) : undefined;
449
524
  const open = todos?.all().filter((item) => !item.done) ?? [];
450
525
  const lines = welcomeLines(width, welcomeFrame, (color, text) => (ctx.ui.theme ?? theme).fg(color, text), {
451
- ...info, settingsClickable: tui.mode !== "regular", roles: live.roles,
526
+ ...info, model: ctx.model?.id, profile: profileStore?.activeName ?? "Default", settingsClickable: tui.mode !== "regular", roles: live.roles,
452
527
  quota: quota?.week?.used ?? quota?.fiveHour?.used,
453
528
  effort: ctx.model?.reasoning === false ? "off" : pi.getThinkingLevel?.(),
454
529
  ...(modelRoles.activeRole() ? { activeRole: modelRoles.activeRole()! } : {}),
@@ -498,7 +573,7 @@ export default function piJar(pi: ExtensionAPI): void {
498
573
  let disposed = false;
499
574
  let hits: FooterHit[] = [];
500
575
  let pressed: { target: FooterTarget; at: number } | undefined;
501
- const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
576
+ const unsubscribe = footerData.onBranchChange(() => repaint("branch", "transition"));
502
577
  const dispose = () => {
503
578
  if (disposed) return;
504
579
  disposed = true;
@@ -527,6 +602,8 @@ export default function piJar(pi: ExtensionAPI): void {
527
602
  },
528
603
  render(width: number): string[] {
529
604
  try {
605
+ // Whatever background state was waiting for a repaint is drawn by this frame, ours or Pi's.
606
+ renders?.painted();
530
607
  const now = Date.now();
531
608
  const statuses = footerData.getExtensionStatuses();
532
609
  const live = collectStatuses(statuses, now);
@@ -539,7 +616,7 @@ export default function piJar(pi: ExtensionAPI): void {
539
616
  expiryTimer = undefined;
540
617
  expiryAt = nearest;
541
618
  if (nearest != null) {
542
- expiryTimer = setTimeout(() => { expiryTimer = undefined; expiryAt = undefined; tui.requestRender(); }, Math.max(1, nearest - now));
619
+ expiryTimer = setTimeout(() => { expiryTimer = undefined; expiryAt = undefined; repaint("roles", "transition"); }, Math.max(1, nearest - now));
543
620
  expiryTimer.unref?.();
544
621
  }
545
622
  }
@@ -557,7 +634,7 @@ export default function piJar(pi: ExtensionAPI): void {
557
634
  model: ctx.model?.id ?? "no-model", effort: ctx.model?.reasoning === false ? "off" : (pi.getThinkingLevel?.() ?? "off"),
558
635
  sessionName: footerSessionName,
559
636
  cwd: ctx.cwd, settings: footerSettings, branch: footerData.getGitBranch(),
560
- context: footerContext, goal: goalLoop.progress(), chips: footerChips(), activity: footerActivity(now),
637
+ context: contextUsage?.label ?? "ctx ?", goal: goalLoop.progress(), chips: footerChips(), activity: footerActivity(now),
561
638
  memory, cost: formatCost(cost), quota, roles, extras: live.extras,
562
639
  demo, animations, frame, motionBudget: ctx.isIdle() ? 2 : 1
563
640
  }, width, ctx.ui.theme ?? theme);
@@ -580,10 +657,26 @@ export default function piJar(pi: ExtensionAPI): void {
580
657
  }
581
658
  };
582
659
 
583
- const applyVisualSettings = (next: JarVisualSettings, ctx: ExtensionContext) => {
660
+ const persistVisualPreferences = (next: JarVisualSettings, ctx: ExtensionContext, theme?: string) => {
661
+ if (profileStore) {
662
+ try { profileStore.update(profileStore.activeName, { settings: next, ...(theme ? { theme } : {}) }); }
663
+ catch (error) { ctx.ui.notify("Profile preferences changed for this session but could not be saved: " + (error as Error).message, "warning"); return; }
664
+ }
665
+ if (!profileStore || profileStore.active().id === "default") {
666
+ try { saveVisualSettings(getAgentDir(), next); }
667
+ catch { ctx.ui.notify("Visual preferences changed for this session but could not be saved", "warning"); }
668
+ }
669
+ };
670
+ const accentTheme = (accent: JarVisualSettings["accent"]) => accent === "follow" ? "follow" : accent === "default" ? "pi-jar-dark" : `pi-jar-dark-${accent}`;
671
+ const applyVisualSettings = (next: JarVisualSettings, ctx: ExtensionContext, options: { persist?: boolean; accent?: boolean } = {}) => {
584
672
  const wasEnabled = enabled;
585
673
  const hadMotion = animations;
586
- if (next.accent !== visualSettings.accent && next.accent !== "follow" && !selectAccent(ctx, next.accent)) {
674
+ const accentChanged = next.accent !== visualSettings.accent;
675
+ const quotaShown = !!footerTui && footerSettings.quota;
676
+ if (options.accent !== false && accentChanged && next.accent === "follow") {
677
+ try { applyProfileTheme("follow", ctx); } catch (error) { ctx.ui.notify(String(error), "warning"); return; }
678
+ }
679
+ if (options.accent !== false && accentChanged && next.accent !== "follow" && !selectAccent(ctx, next.accent)) {
587
680
  ctx.ui.notify(`Accent ${next.accent} is not loaded`, "warning");
588
681
  return;
589
682
  }
@@ -609,23 +702,85 @@ export default function piJar(pi: ExtensionAPI): void {
609
702
  else if (!hadMotion && animations && welcomeTui && !welcomeInterval) {
610
703
  { welcomeInterval = setInterval(() => { welcomeFrame++; welcomeTui?.requestRender(); }, WELCOME_INTERVAL_MS); welcomeInterval.unref?.(); }
611
704
  }
612
- footerTui?.requestRender();
705
+ repaint("settings");
613
706
  welcomeTui?.requestRender();
614
- try { saveVisualSettings(getAgentDir(), next); }
615
- catch { ctx.ui.notify("Visual preferences changed for this session but could not be saved", "warning"); }
707
+ // Quota shown again (field or UI back on): hiding it stopped lookups and dropped the cache.
708
+ if (!quotaShown) refreshQuota(ctx);
709
+ if (options.persist !== false) persistVisualPreferences(next, ctx, accentChanged ? accentTheme(next.accent) : undefined);
616
710
  };
617
711
 
712
+ let switchingProfile = false;
713
+ const canSwitchProfile = (ctx: ExtensionContext) => {
714
+ if (switchingProfile || advisor.isBusy() || !ctx.isIdle?.() || working.phase !== "idle" || planMode.isEnabled() || goals?.isActive() || subagents.running() > 0) {
715
+ ctx.ui.notify("Finish the active agent/workflow before switching profiles", "warning"); return false;
716
+ }
717
+ if (conversationStarted(profileEntries(ctx)) || pinnedProfileId(profileEntries(ctx)) !== undefined) {
718
+ ctx.ui.notify("This session's profile is locked. Open a new session to switch profiles", "warning"); return false;
719
+ }
720
+ return true;
721
+ };
722
+ const switchProfile = async (name: string, ctx: ExtensionContext) => {
723
+ if (!profileStore || name === profileStore.activeName || !canSwitchProfile(ctx)) return;
724
+ const store = profileStore;
725
+ const previous = store.active();
726
+ const previousTheme = ctx.ui.theme?.name;
727
+ const previousModel = ctx.model;
728
+ const previousRole = modelRoles.activeRole();
729
+ const previousThinking = pi.getThinkingLevel();
730
+ const next = store.list().find((profile) => profile.name === name);
731
+ if (!next) { ctx.ui.notify("Unknown profile: " + name, "warning"); return; }
732
+ switchingProfile = true;
733
+ try {
734
+ applyProfileTheme(next.theme, ctx);
735
+ store.activate(name);
736
+ modelRoles.invalidateTemporaryRestores();
737
+ modelRoles.load(ctx.cwd, next.roles, next.id);
738
+ composer.setProfile(next.name);
739
+ applyVisualSettings(next.settings, ctx, { persist: false, accent: false });
740
+ if (!await modelRoles.activate("default", ctx, true)) ctx.ui.notify("Profile loaded, but its default model is unavailable; keeping the current model", "warning");
741
+ repaint("profile"); welcomeTui?.requestRender();
742
+ ctx.ui.notify(`Profile: ${next.name}`, "info");
743
+ } catch (error) {
744
+ try { if (store.activeName !== previous.name) store.activate(previous.name); } catch {}
745
+ if (previousTheme) { try { applyProfileTheme(previousTheme, ctx); } catch {} }
746
+ modelRoles.load(ctx.cwd, previous.roles, previous.id);
747
+ try {
748
+ if (previousModel && ctx.model !== previousModel && !await pi.setModel(previousModel)) throw new Error("Previous model is unavailable");
749
+ pi.setThinkingLevel(previousThinking);
750
+ modelRoles.restoreActiveRole(previousRole, ctx);
751
+ } catch { ctx.ui.notify("Could not restore the previous model after the failed profile switch", "warning"); }
752
+ composer.setProfile(previous.name);
753
+ applyVisualSettings(previous.settings, ctx, { persist: false, accent: false });
754
+ ctx.ui.notify(`Could not switch profile: ${(error as Error).message}`, "warning");
755
+ } finally { switchingProfile = false; }
756
+ };
618
757
  openSettings = async (ctx) => {
619
758
  if (settingsOpen) return;
620
759
  settingsOpen = true;
621
760
  try { await openJarSettings(ctx, () => visualSettings,
622
- (next) => applyVisualSettings(next, ctx), loadedAccents(ctx), piPreferences(ctx)); }
761
+ (next) => applyVisualSettings(next, ctx), loadedAccents(ctx), piPreferences(ctx), profileStore ? {
762
+ store: profileStore, themes: ctx.ui.getAllThemes?.().map((theme) => theme.name) ?? [],
763
+ roles: () => modelRoles.profileRoleConfig(), roleManager: modelRoles,
764
+ create: (name, theme, settings, roles) => { const profile = profileStore!.add(name, theme, { ...settings, accent: "follow" }, roles); ctx.ui.notify(`Created profile ${name}; select it in a fresh session`, "info"); return profile; },
765
+ select: (name) => switchProfile(name, ctx),
766
+ setTheme: (name) => {
767
+ const previousTheme = ctx.ui.theme?.name;
768
+ const next = { ...visualSettings, accent: "follow" as const };
769
+ try { applyProfileTheme(name, ctx); profileStore!.update(profileStore!.activeName, { theme: name, settings: next }); }
770
+ catch (error) { if (previousTheme) applyProfileTheme(previousTheme, ctx); throw error; }
771
+ applyVisualSettings(next, ctx, { persist: false, accent: false });
772
+ if (profileStore!.active().id === "default") {
773
+ try { saveVisualSettings(getAgentDir(), next); }
774
+ catch { ctx.ui.notify("Theme saved, but legacy Default preferences could not be updated", "warning"); }
775
+ }
776
+ }
777
+ } : undefined); }
623
778
  finally { settingsOpen = false; }
624
779
  };
625
780
 
781
+ /** Callers are lifecycle handlers that repaint once through sampleFooter. */
626
782
  const updateCost = (ctx: ExtensionContext) => {
627
783
  try { cost = sessionCost(ctx); } catch { cost = 0; }
628
- footerTui?.requestRender();
629
784
  };
630
785
  const addMessageCost = (message: { usage?: { cost?: { total?: number } } }) => {
631
786
  const value = message.usage?.cost?.total;
@@ -637,12 +792,11 @@ export default function piJar(pi: ExtensionAPI): void {
637
792
  };
638
793
  const restoreGoal = (ctx: ExtensionContext, branch?: readonly unknown[]) => {
639
794
  try { goals?.restore(branch ?? sessionBranch(ctx)); } catch { goals?.restore([]); }
640
- footerTui?.requestRender();
641
795
  };
642
796
  pi.on("session_start", (_event, ctx) => {
643
797
  sideUsage.clear();
644
- migrateLegacySettings(getAgentDir());
645
- visualSettings = loadVisualSettings(getAgentDir());
798
+ if (!profileStore) initializeProfiles(ctx);
799
+ composer.setProfile(profileStore?.activeName ?? "Default");
646
800
  setIconSet(visualSettings.icons);
647
801
  animations = visualSettings.animations;
648
802
  enabled = visualSettings.ui;
@@ -652,20 +806,37 @@ export default function piJar(pi: ExtensionAPI): void {
652
806
  workingIndicatorKey = "";
653
807
  working.end();
654
808
  quotaCache?.stop();
809
+ clearTimeout(enableQuota);
810
+ // Render and sampling counters are session-scoped; nothing from a previous session may fire.
811
+ renders?.dispose();
812
+ contextUsage?.dispose();
655
813
  todos = new TodoStore((entry) => pi.appendEntry(TASK_ENTRY, entry));
656
814
  changes = new ChangeTracker(() => ctx.cwd);
657
815
  changeCount = 0;
658
816
  shells?.dispose();
659
- subagents.clear();
817
+ const preserved = subagents.clear();
818
+ if (preserved.length) ctx.ui.notify(`Unresolved subagent worktrees preserved on disk:\n${preserved.join("\n")}`, "warning");
819
+ const previousDiscussion = discussion;
660
820
  if (!delegatedChild) {
661
- disposeDiscussionPaper(discussionFile);
662
- discussionFile = createDiscussionPaper();
821
+ discussion = new DiscussionBroker();
822
+ discussion.onChange = () => repaint("discussion", "background");
663
823
  }
824
+ renders = new RenderScheduler(() => {
825
+ const tui = footerTui;
826
+ if (!tui) return false;
827
+ tui.requestRender();
828
+ return true;
829
+ });
830
+ contextUsage = new ContextSampler(() => repaint("context", "background"));
831
+ subagentSignature = "";
832
+ shellSignature = "";
664
833
  shells = new ShellManager(onShellEvent);
665
- shells.onChange = repaintFooterSoon;
834
+ shells.onChange = noteShells;
666
835
  goals = new GoalStore((entry) => pi.appendEntry(GOAL_ENTRY, entry));
667
836
  let branch: readonly unknown[] = [];
668
837
  try { branch = sessionBranch(ctx); } catch { /* keep empty */ }
838
+ // Counted from here on per message_end: no per-event branch walk.
839
+ renders.setEntries(branch.length);
669
840
  if (delegatedChild) {
670
841
  todos.restore([]);
671
842
  goals.restore([]);
@@ -676,12 +847,15 @@ export default function piJar(pi: ExtensionAPI): void {
676
847
  // Quota is enabled per session; no credentials or consent are persisted.
677
848
  quotaCache = new QuotaCache(
678
849
  (provider: QuotaProvider, signal) => fetchQuota(provider, (id) => ctx.modelRegistry.getProviderAuth(id), signal),
679
- () => footerTui?.requestRender()
850
+ () => repaint("quota", "transition")
680
851
  );
681
852
  // Hold the network lookup until startup settles; the footer shows it once it arrives.
682
853
  quotaCache.enabled = false;
683
854
  const cache = quotaCache;
684
- const enableQuota = setTimeout(() => { if (quotaCache === cache && !quotaDisabledByUser) { cache.enabled = true; footerTui?.requestRender(); } }, STARTUP_GRACE_MS);
855
+ enableQuota = setTimeout(() => {
856
+ enableQuota = undefined;
857
+ if (quotaCache === cache && !quotaDisabledByUser) { cache.enabled = true; refreshQuota(ctx); }
858
+ }, STARTUP_GRACE_MS);
685
859
  enableQuota.unref?.();
686
860
  quotaDisabledByUser = false;
687
861
  updateCost(ctx);
@@ -690,7 +864,7 @@ export default function piJar(pi: ExtensionAPI): void {
690
864
  composer.setMascot(visualSettings.mascot);
691
865
  if (visualSettings.composer && enabled) composer.enable(ctx);
692
866
  suggest.sync();
693
- if (visualSettings.accent !== "follow") selectAccent(ctx, visualSettings.accent);
867
+ if (!profileStore && visualSettings.accent !== "follow") selectAccent(ctx, visualSettings.accent);
694
868
  if (branch.length >= LARGE_SESSION_ENTRIES) {
695
869
  try {
696
870
  const prefs = piPreferences(ctx)?.get();
@@ -705,6 +879,11 @@ export default function piJar(pi: ExtensionAPI): void {
705
879
  const resumed = branch.some((entry) => !(typeof entry === "object" && entry !== null && "type" in entry
706
880
  && typeof entry.type === "string" && SESSION_SETUP_ENTRIES.has(entry.type)));
707
881
  if (!delegatedChild && !resumed) showWelcome(ctx);
882
+ if (discussion) {
883
+ const current = discussion;
884
+ return (previousDiscussion ? previousDiscussion.close().then(() => current.start()) : current.start())
885
+ .catch((error) => { ctx.ui.notify("Discussion broker unavailable: " + String(error), "warning"); });
886
+ }
708
887
  });
709
888
  // Any real prompt dismisses the welcome. Slash commands bypass `input`, so `agent_start` covers them.
710
889
  const dismissWelcome = (ctx: ExtensionContext) => {
@@ -720,19 +899,31 @@ export default function piJar(pi: ExtensionAPI): void {
720
899
  welcomeTui.requestRender();
721
900
  };
722
901
  pi.on("input", (event, ctx) => {
902
+ if (profileStore && !conversationStarted(profileEntries(ctx))) {
903
+ const id = profileStore.active().id;
904
+ if (pinnedProfileId(profileEntries(ctx)) !== id) pi.appendEntry?.(PROFILE_ENTRY, { version: 1, id });
905
+ }
723
906
  if (event.source !== "interactive") return;
724
907
  dismissWelcome(ctx);
725
908
  if (!todosAcknowledged && todos?.all().every((item) => item.done)) { todosAcknowledged = true; updateTaskWidget(ctx); }
726
909
  });
727
- pi.on("agent_start", (_event, ctx) => { dismissWelcome(ctx); working.start(); applyWorking(ctx); });
910
+ pi.on("agent_start", (_event, ctx) => {
911
+ if (profileStore && pinnedProfileId(profileEntries(ctx)) !== profileStore.active().id) {
912
+ pi.appendEntry?.(PROFILE_ENTRY, { version: 1, id: profileStore.active().id });
913
+ }
914
+ dismissWelcome(ctx); working.start(); applyWorking(ctx); refreshQuota(ctx);
915
+ });
728
916
  pi.on("turn_start", (_event, ctx) => { working.start(); applyWorking(ctx); });
729
917
  pi.on("message_end", (event, ctx) => {
730
- // Every message (user, assistant, tool result) moves the context window.
731
- sampleFooter(ctx);
918
+ // Every message (user, assistant, tool result) moves the context window, and a tool-heavy turn
919
+ // ends many: count the entry and let the sampler recompute once per window, not per message.
920
+ renders?.addEntries(1);
921
+ contextUsage?.markDirty(() => ctx.getContextUsage());
732
922
  if (event.message.role === "assistant") {
733
923
  working.reportOutputTokens(event.message.usage?.output ?? 0);
734
924
  addMessageCost(event.message);
735
925
  applyWorking(ctx);
926
+ repaint("cost", "background");
736
927
  }
737
928
  });
738
929
  pi.on("tool_execution_start", (event, ctx) => { working.toolStart(event.toolCallId, event.toolName); applyWorking(ctx); });
@@ -740,13 +931,15 @@ export default function piJar(pi: ExtensionAPI): void {
740
931
  pi.on("ui_prompt_start", (_event, ctx) => { working.prompt(true); applyWorking(ctx); });
741
932
  pi.on("ui_prompt_end", (_event, ctx) => { working.prompt(false); applyWorking(ctx); });
742
933
  // A request may span several tool turns; keep its elapsed time and reported tokens until agent_end.
743
- pi.on("turn_end", (_event, ctx) => { applyWorking(ctx); });
744
- pi.on("agent_end", (_event, ctx) => { working.end(); applyWorking(ctx); });
934
+ pi.on("turn_end", (_event, ctx) => { applyWorking(ctx); refreshQuota(ctx); });
935
+ pi.on("agent_end", (_event, ctx) => { working.end(); applyWorking(ctx); sampleFooter(ctx); });
745
936
  pi.on("agent_before_settle", (event) => { if (event.outcome === "error") composer.flash("error"); });
746
- pi.on("agent_settled", (_event, ctx) => { working.end(); applyWorking(ctx); });
937
+ // Retries or compaction after agent_end may have added messages; otherwise the agent_end sample stands.
938
+ pi.on("agent_settled", (_event, ctx) => { working.end(); applyWorking(ctx); if (contextUsage?.dirty) sampleFooter(ctx); refreshQuota(ctx); });
747
939
  pi.on("session_tree", (_event, ctx) => {
748
940
  let branch: readonly unknown[] = [];
749
941
  try { branch = sessionBranch(ctx); } catch { /* keep empty */ }
942
+ renders?.setEntries(branch.length);
750
943
  if (!delegatedChild) {
751
944
  restoreTodos(ctx, branch);
752
945
  restoreGoal(ctx, branch);
@@ -756,12 +949,15 @@ export default function piJar(pi: ExtensionAPI): void {
756
949
  sampleFooter(ctx);
757
950
  });
758
951
  pi.on("session_compact", (_event, ctx) => {
759
- if (!delegatedChild) { restoreTodos(ctx); restoreGoal(ctx); }
952
+ let branch: readonly unknown[] = [];
953
+ try { branch = sessionBranch(ctx); } catch { /* keep empty */ }
954
+ renders?.setEntries(branch.length);
955
+ if (!delegatedChild) { restoreTodos(ctx, branch); restoreGoal(ctx, branch); }
760
956
  updateCost(ctx);
761
957
  sampleFooter(ctx);
762
958
  });
763
- pi.on("model_select", (_event, ctx) => sampleFooter(ctx));
764
- pi.on("thinking_level_select", (_event, _ctx) => footerTui?.requestRender());
959
+ pi.on("model_select", (_event, ctx) => { sampleFooter(ctx); refreshQuota(ctx); });
960
+ pi.on("thinking_level_select", (_event, _ctx) => repaint("effort"));
765
961
  pi.on("session_info_changed", (_event, ctx) => { composer.refreshSession(ctx); sampleFooter(ctx); });
766
962
  pi.on("session_shutdown", (_event, ctx) => {
767
963
  clearSessionBranchCache(ctx);
@@ -772,17 +968,27 @@ export default function piJar(pi: ExtensionAPI): void {
772
968
  try { if (ctx.hasUI && ctx.mode === "tui") { ctx.ui.setWorkingMessage?.(); ctx.ui.setWorkingIndicator(); ctx.ui.setWidget("pi-jar.todos", undefined); } } catch {}
773
969
  quotaCache?.stop();
774
970
  quotaCache = undefined;
775
- if (!delegatedChild) { disposeDiscussionPaper(discussionFile); discussionFile = undefined; }
971
+ clearTimeout(enableQuota);
972
+ enableQuota = undefined;
973
+ // Late notifications (draining children, shell disposal) find no scheduler and start no timer.
974
+ renders?.dispose();
975
+ renders = undefined;
976
+ contextUsage?.dispose();
977
+ contextUsage = undefined;
776
978
  todos = undefined;
777
979
  changes = undefined;
778
980
  changeCount = 0;
779
981
  shells?.dispose();
780
982
  shells = undefined;
781
- subagents.clear();
983
+ const preserved = subagents.clear();
984
+ if (preserved.length) ctx.ui.notify(`Unresolved subagent worktrees preserved on disk:\n${preserved.join("\n")}`, "warning");
985
+ const closingDiscussion = discussion;
986
+ discussion = undefined;
782
987
  goals = undefined;
783
988
  disposeFooter?.();
784
989
  disposeFooter = undefined;
785
990
  demo = false;
991
+ return closingDiscussion?.close();
786
992
  });
787
993
 
788
994
  pi.registerShortcut?.(Key.ctrlAlt("r"), {
@@ -806,9 +1012,18 @@ export default function piJar(pi: ExtensionAPI): void {
806
1012
  if (picked !== undefined) ctx.ui.setEditorText(picked);
807
1013
  }
808
1014
  });
1015
+ pi.registerShortcut?.(Key.ctrlShift("tab"), {
1016
+ description: "Switch pi-jar profile",
1017
+ handler: async (ctx) => {
1018
+ if (!ctx.hasUI || ctx.mode !== "tui" || !profileStore || settingsOpen) return;
1019
+ const profiles = profileStore.list();
1020
+ const index = profiles.findIndex((profile) => profile.name === profileStore!.activeName);
1021
+ if (profiles.length > 1) await switchProfile(profiles[(index + 1) % profiles.length]!.name, ctx);
1022
+ }
1023
+ });
809
1024
  pi.registerShortcut?.(Key.ctrlAlt("m"), {
810
1025
  description: "Cycle pi-jar model roles",
811
- handler: async (ctx) => { await modelRoles.cycle(ctx); footerTui?.requestRender(); }
1026
+ handler: async (ctx) => { await modelRoles.cycle(ctx); repaint("roles"); }
812
1027
  });
813
1028
  pi.registerShortcut?.(Key.ctrlAlt("a"), {
814
1029
  description: "Subagents and background shells (pi-jar)",
@@ -835,6 +1050,20 @@ export default function piJar(pi: ExtensionAPI): void {
835
1050
  ctx.ui.notify(`pi-jar: UI ${enabled ? "on" : "off"}; animations ${animations ? "on" : "off"}; quota ${quotaCache?.enabled ? "on" : "off"} (session-only); composer ${composer.enabled ? "on" : "off"}; mascot ${visualSettings.mascot ? "on" : "off"}; suggestions ${visualSettings.suggestions ? "on" : "off"}; ${todos?.all().length ?? 0} to-dos; demo ${demo ? "on" : "off"}`, "info");
836
1051
  return;
837
1052
  }
1053
+ if (command === "perf") {
1054
+ // On demand only: one `ps` call for live children; everything else is an in-memory counter.
1055
+ const subagentStats = subagents.stats();
1056
+ const childRssBytes = await processRss(subagentStats.pids);
1057
+ ctx.ui.notify(formatPerf({
1058
+ entries: renders?.entries ?? 0, context: contextUsage?.label ?? "ctx ?", rssBytes: process.memoryUsage.rss(),
1059
+ ...(renders ? { render: renders.stats() } : {}), ...(contextUsage ? { sampling: contextUsage.stats() } : {}),
1060
+ subagents: subagentStats, ...(childRssBytes !== undefined ? { childRssBytes } : {}),
1061
+ ...(shells ? { shells: shells.stats() } : {}), ...(discussion ? { discussion: discussion.stats() } : {}),
1062
+ ...(quotaCache ? { quota: quotaCache.stats() } : {}),
1063
+ sideCalls: sideUsage.all().length, now: Date.now()
1064
+ }), "info");
1065
+ return;
1066
+ }
838
1067
  // `/jar resume` without a number opens the same searchable picker.
839
1068
  if (command === "sessions" || command.startsWith("sessions ") || command === "resume") {
840
1069
  await openSessions(ctx, command === "resume" ? "" : args.trim().slice(8).trim());
@@ -879,7 +1108,7 @@ export default function piJar(pi: ExtensionAPI): void {
879
1108
  return;
880
1109
  }
881
1110
  footerSettings = visualSettings.footer;
882
- footerTui?.requestRender();
1111
+ repaint("settings");
883
1112
  const labels: Record<(typeof FOOTER_FIELDS)[number], string> = {
884
1113
  model: "Model", effort: "Model effort", sessionName: "Session name", cwd: "Working directory", context: "Context",
885
1114
  memory: "Process RAM (RSS)", cost: "Session cost", quota: "Quota", roles: "Roles", extras: "Extension statuses", branch: "Git branch"
@@ -907,8 +1136,7 @@ export default function piJar(pi: ExtensionAPI): void {
907
1136
  const applied = selectAccent(ctx, selected);
908
1137
  if (applied) {
909
1138
  visualSettings = { ...visualSettings, accent: selected as JarVisualSettings["accent"] };
910
- try { saveVisualSettings(getAgentDir(), visualSettings); }
911
- catch { ctx.ui.notify("Accent changed for this session but could not be saved", "warning"); }
1139
+ persistVisualPreferences(visualSettings, ctx, accentTheme(visualSettings.accent));
912
1140
  applyWorking(ctx);
913
1141
  }
914
1142
  const valid = selected === "default" || ACCENT_NAMES.some((name) => name === selected);
@@ -974,10 +1202,10 @@ export default function piJar(pi: ExtensionAPI): void {
974
1202
  else if (command === "animations off") applyVisualSettings({ ...visualSettings, animations: false }, ctx);
975
1203
  else if (command === "ui on") applyVisualSettings({ ...visualSettings, ui: true }, ctx);
976
1204
  else if (command === "ui off") applyVisualSettings({ ...visualSettings, ui: false }, ctx);
977
- else if (command === "quota on" && quotaCache) { quotaCache.enabled = true; quotaDisabledByUser = false; }
1205
+ else if (command === "quota on" && quotaCache) { quotaCache.enabled = true; quotaDisabledByUser = false; refreshQuota(ctx); }
978
1206
  else if (command === "quota off" && quotaCache) { quotaCache.enabled = false; quotaDisabledByUser = true; quotaCache.stop(); }
979
1207
  else {
980
- ctx.ui.notify("Usage: /jar [status|settings|activity|shells|sessions [search]|icons [unicode|nerd|ascii]|commit [note]|name <title>|history|footer|tasks|ask|composer on/off|accent [preset]|hub|welcome|demo|reset|animations on/off|ui on/off|quota on/off]", "error");
1208
+ ctx.ui.notify("Usage: /jar [status|perf|settings|activity|shells|sessions [search]|icons [unicode|nerd|ascii]|commit [note]|name <title>|history|footer|tasks|ask|composer on/off|accent [preset]|hub|welcome|demo|reset|animations on/off|ui on/off|quota on/off]", "error");
981
1209
  return;
982
1210
  }
983
1211
  if (!["animations on", "animations off", "ui on", "ui off"].includes(command)) installUi(ctx);