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.
- package/README.md +26 -13
- package/docs/DISCUSSION_BROKER_PLAN.md +333 -0
- package/docs/INTEGRATIONS.md +1 -1
- package/docs/LONG_SESSION_PERFORMANCE_PLAN.md +731 -0
- package/docs/SUBAGENT-TOOLS.md +2 -0
- package/extensions/index.ts +304 -76
- package/package.json +2 -2
- package/src/activity-view.ts +10 -6
- package/src/advisor.ts +226 -73
- package/src/composer.ts +21 -4
- package/src/context-diet.ts +94 -0
- package/src/delegate-worktree.ts +33 -13
- package/src/delegate.ts +736 -429
- package/src/democracy.ts +6 -4
- package/src/discussion-hub.ts +418 -0
- package/src/discussion.ts +419 -187
- package/src/footer-settings.ts +3 -29
- package/src/model-roles.ts +29 -9
- package/src/perf.ts +419 -0
- package/src/profile-wizard.ts +61 -0
- package/src/profiles.ts +148 -0
- package/src/quota.ts +65 -25
- package/src/settings-ui.ts +46 -14
- package/src/settings.ts +6 -13
- package/src/shell-notifications.ts +33 -6
- package/src/shells.ts +138 -29
- package/src/side-model.ts +3 -1
- package/src/welcome.ts +6 -1
package/extensions/index.ts
CHANGED
|
@@ -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,
|
|
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 {
|
|
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
|
-
/**
|
|
105
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
202
|
-
*
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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) => {
|
|
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
|
-
|
|
376
|
-
const context = contextUsage?.
|
|
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(() =>
|
|
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;
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
705
|
+
repaint("settings");
|
|
613
706
|
welcomeTui?.requestRender();
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
645
|
-
|
|
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
|
-
|
|
662
|
-
|
|
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 =
|
|
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
|
-
() =>
|
|
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
|
-
|
|
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) => {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
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);
|
|
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
|
-
|
|
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
|
-
|
|
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);
|