pi-jar 0.1.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/LICENSE +21 -0
- package/README.md +288 -0
- package/docs/DESIGN.md +85 -0
- package/docs/DEVELOPMENT.md +95 -0
- package/docs/FLAME.md +54 -0
- package/docs/INTEGRATIONS.md +82 -0
- package/docs/PLAN.md +68 -0
- package/docs/SHOWCASE.md +22 -0
- package/docs/WORKFLOWS.md +141 -0
- package/extensions/index.ts +773 -0
- package/package.json +62 -0
- package/src/accent.ts +36 -0
- package/src/advisor.ts +194 -0
- package/src/animations.ts +23 -0
- package/src/ask-tool.ts +185 -0
- package/src/attachments.ts +79 -0
- package/src/changes.ts +146 -0
- package/src/commit.ts +61 -0
- package/src/compact-tools.ts +129 -0
- package/src/composer.ts +404 -0
- package/src/context-view.ts +141 -0
- package/src/delegate.ts +208 -0
- package/src/dialogs.ts +127 -0
- package/src/diff-view.ts +160 -0
- package/src/flame.ts +245 -0
- package/src/footer-settings.ts +32 -0
- package/src/footer.ts +147 -0
- package/src/goal-loop.ts +313 -0
- package/src/goals.ts +114 -0
- package/src/history-ui.ts +136 -0
- package/src/history.ts +168 -0
- package/src/info-panels.ts +75 -0
- package/src/mascot.ts +103 -0
- package/src/model-roles.ts +332 -0
- package/src/panel.ts +90 -0
- package/src/plan-utils.ts +191 -0
- package/src/plan-view.ts +149 -0
- package/src/plan.ts +460 -0
- package/src/prompt-search.ts +83 -0
- package/src/quota.ts +134 -0
- package/src/roles-ui.ts +190 -0
- package/src/roles.ts +25 -0
- package/src/session-gallery.ts +52 -0
- package/src/session-ui.ts +76 -0
- package/src/settings-ui.ts +154 -0
- package/src/settings.ts +96 -0
- package/src/shells.ts +319 -0
- package/src/side-model.ts +48 -0
- package/src/split-view.ts +74 -0
- package/src/status.ts +71 -0
- package/src/suggest.ts +89 -0
- package/src/task-tool.ts +135 -0
- package/src/tasks-ui.ts +73 -0
- package/src/tasks.ts +147 -0
- package/src/usage-view.ts +98 -0
- package/src/usage.ts +16 -0
- package/src/welcome.ts +260 -0
- package/src/working.ts +60 -0
- package/themes/pi-jar-dark-amber.json +84 -0
- package/themes/pi-jar-dark-azure.json +84 -0
- package/themes/pi-jar-dark-gray.json +84 -0
- package/themes/pi-jar-dark-pink.json +84 -0
- package/themes/pi-jar-dark-teal.json +84 -0
- package/themes/pi-jar-dark-violet.json +84 -0
- package/themes/pi-jar-dark.json +84 -0
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
import { getAgentDir, SessionManager, SettingsManager, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { execFile } from "node:child_process";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { Key, truncateToWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
6
|
+
import { ACCENT_NAMES, loadedAccents, selectAccent } from "../src/accent.ts";
|
|
7
|
+
import { registerAskTool } from "../src/ask-tool.ts";
|
|
8
|
+
import { ComposerStyle } from "../src/composer.ts";
|
|
9
|
+
import { installCompactBuiltinTools } from "../src/compact-tools.ts";
|
|
10
|
+
import { WELCOME_INTERVAL_MS } from "../src/animations.ts";
|
|
11
|
+
import { promptText } from "../src/dialogs.ts";
|
|
12
|
+
import { renderFooter } from "../src/footer.ts";
|
|
13
|
+
import { openJarHistory } from "../src/history-ui.ts";
|
|
14
|
+
import { GOAL_ENTRY, GoalStore } from "../src/goals.ts";
|
|
15
|
+
import { GoalLoop } from "../src/goal-loop.ts";
|
|
16
|
+
import { FOOTER_FIELDS } from "../src/footer-settings.ts";
|
|
17
|
+
import { defaultVisualSettings, loadVisualSettings, migrateLegacySettings, saveVisualSettings, type JarVisualSettings } from "../src/settings.ts";
|
|
18
|
+
import { openJarSettings, type PiPreferences } from "../src/settings-ui.ts";
|
|
19
|
+
import { pickSession } from "../src/session-ui.ts";
|
|
20
|
+
import { fetchQuota, QuotaCache, type QuotaProvider } from "../src/quota.ts";
|
|
21
|
+
import { ModelRoleManager } from "../src/model-roles.ts";
|
|
22
|
+
import { jarCommit } from "../src/commit.ts";
|
|
23
|
+
import { registerAdvisor } from "../src/advisor.ts";
|
|
24
|
+
import { registerInfoPanels } from "../src/info-panels.ts";
|
|
25
|
+
import { SideUsage } from "../src/side-model.ts";
|
|
26
|
+
import { PlanMode } from "../src/plan.ts";
|
|
27
|
+
import { openRolesUi } from "../src/roles-ui.ts";
|
|
28
|
+
import { registerSuggestions, SuggestionState } from "../src/suggest.ts";
|
|
29
|
+
import { createDemoRoles } from "../src/roles.ts";
|
|
30
|
+
import { ACTIVE_STATES, collectStatuses, type JarRole } from "../src/status.ts";
|
|
31
|
+
import { manageTasks } from "../src/tasks-ui.ts";
|
|
32
|
+
import { registerTaskTool, todoRow } from "../src/task-tool.ts";
|
|
33
|
+
import { TASK_ENTRY, TodoStore } from "../src/tasks.ts";
|
|
34
|
+
import { formatCost, sessionCost } from "../src/usage.ts";
|
|
35
|
+
import { WorkingState } from "../src/working.ts";
|
|
36
|
+
import { ChangeTracker } from "../src/changes.ts";
|
|
37
|
+
import { registerDelegate } from "../src/delegate.ts";
|
|
38
|
+
import { collectPrompts, openPromptSearch } from "../src/prompt-search.ts";
|
|
39
|
+
import { ago, recentSessions, type RecentSession } from "../src/session-gallery.ts";
|
|
40
|
+
import { registerChangeReview } from "../src/diff-view.ts";
|
|
41
|
+
import { openShellsView, registerShells, SHELL_MESSAGE, shellEventMessage, ShellManager, type ShellEvent } from "../src/shells.ts";
|
|
42
|
+
import { hopefulWelcomeMessage, welcomeHit, welcomeLines, type WelcomeAction } from "../src/welcome.ts";
|
|
43
|
+
|
|
44
|
+
const WELCOME_KEY = "pi-jar.welcome";
|
|
45
|
+
const VERSION = (() => {
|
|
46
|
+
try { return "v" + (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: string }).version; }
|
|
47
|
+
catch { return undefined; }
|
|
48
|
+
})();
|
|
49
|
+
/** Ignore the click Pi may synthesize right after a press we already acted on. */
|
|
50
|
+
const WELCOME_CLICK_DEDUPE_MS = 400;
|
|
51
|
+
export default function piJar(pi: ExtensionAPI): void {
|
|
52
|
+
installCompactBuiltinTools(pi);
|
|
53
|
+
let demo = false;
|
|
54
|
+
let visualSettings = defaultVisualSettings();
|
|
55
|
+
let animations = visualSettings.animations;
|
|
56
|
+
let enabled = visualSettings.ui;
|
|
57
|
+
let disposeFooter: (() => void) | undefined;
|
|
58
|
+
let footerTui: { requestRender(): void } | undefined;
|
|
59
|
+
let quotaCache: QuotaCache | undefined;
|
|
60
|
+
let cost = 0;
|
|
61
|
+
let welcomeInterval: ReturnType<typeof setInterval> | undefined;
|
|
62
|
+
let welcomeFrame = 0;
|
|
63
|
+
let welcomeDismiss = 0;
|
|
64
|
+
let welcomeTui: TUI | undefined;
|
|
65
|
+
/** Background work (quota, session files, git) waits this long after startup. */
|
|
66
|
+
const STARTUP_GRACE_MS = 1500;
|
|
67
|
+
let quotaDisabledByUser = false;
|
|
68
|
+
let welcomeGit: AbortController | undefined;
|
|
69
|
+
let welcomeBranch = (): string | null => null;
|
|
70
|
+
let refreshWelcome: (() => void) | undefined;
|
|
71
|
+
let todos: TodoStore | undefined;
|
|
72
|
+
let goals: GoalStore | undefined;
|
|
73
|
+
const composer = new ComposerStyle();
|
|
74
|
+
let footerSettings = visualSettings.footer;
|
|
75
|
+
let welcomeStatuses = (): ReadonlyMap<string, string> => new Map();
|
|
76
|
+
const working = new WorkingState();
|
|
77
|
+
let workingClock: ReturnType<typeof setInterval> | undefined;
|
|
78
|
+
let workingIndicatorKey = "";
|
|
79
|
+
const stopWorkingClock = () => { if (workingClock) clearInterval(workingClock); workingClock = undefined; };
|
|
80
|
+
let openSettings: (ctx: ExtensionContext) => Promise<void> = async () => {};
|
|
81
|
+
let settingsOpen = false;
|
|
82
|
+
|
|
83
|
+
// A finished list stays visible (all struck through) until the user's next prompt, like Claude.
|
|
84
|
+
let todosAcknowledged = false;
|
|
85
|
+
const updateTaskWidget = (ctx: ExtensionContext) => {
|
|
86
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
87
|
+
const items = todos?.all() ?? [];
|
|
88
|
+
const open = items.filter((item) => !item.done);
|
|
89
|
+
if (open.length) todosAcknowledged = false;
|
|
90
|
+
const visible = enabled && items.length > 0 && (open.length > 0 || !todosAcknowledged);
|
|
91
|
+
try {
|
|
92
|
+
ctx.ui.setWidget("pi-jar.todos", !visible ? undefined : (_tui, theme) => ({
|
|
93
|
+
invalidate() {},
|
|
94
|
+
render(width: number) {
|
|
95
|
+
const colors = ctx.ui.theme ?? theme;
|
|
96
|
+
const all = todos?.all() ?? [];
|
|
97
|
+
const done = all.filter((item) => item.done).length;
|
|
98
|
+
// Show a window of up to 8 rows that keeps the running (or next open) task in view.
|
|
99
|
+
const focus = Math.max(0, all.findIndex((item) => item.status === "in_progress") >= 0
|
|
100
|
+
? all.findIndex((item) => item.status === "in_progress") : all.findIndex((item) => !item.done));
|
|
101
|
+
const start = Math.max(0, Math.min(focus - 2, all.length - 8));
|
|
102
|
+
const shown = all.slice(start, start + 8);
|
|
103
|
+
const rows = [
|
|
104
|
+
colors.fg("accent", "Tasks") + colors.fg("dim", ` · ${done}/${all.length} done` + (done === all.length ? " · all complete" : "") + " · /jar tasks"),
|
|
105
|
+
...(start > 0 ? [colors.fg("dim", ` … ${start} earlier`)] : []),
|
|
106
|
+
...shown.map((item) => todoRow(item, (color, text) => colors.fg(color, text), (text) => colors.bold(text))),
|
|
107
|
+
...(start + shown.length < all.length ? [colors.fg("dim", ` … +${all.length - start - shown.length} more`)] : [])
|
|
108
|
+
];
|
|
109
|
+
// Pi inserts a spacer before widgets, but not between widgets and the composer.
|
|
110
|
+
return [...rows.map((line) => truncateToWidth(line, Math.max(0, width))), truncateToWidth(" ", Math.max(0, width))];
|
|
111
|
+
}
|
|
112
|
+
}));
|
|
113
|
+
} catch { /* Optional widget; task data remains available via /jar tasks and jar_todo. */ }
|
|
114
|
+
};
|
|
115
|
+
registerTaskTool(pi, () => todos, (ctx) => updateTaskWidget(ctx));
|
|
116
|
+
let changes: ChangeTracker | undefined;
|
|
117
|
+
let changeCount = 0;
|
|
118
|
+
const refreshChanges = () => {
|
|
119
|
+
try { changeCount = changes?.count() ?? 0; } catch { changeCount = 0; }
|
|
120
|
+
footerTui?.requestRender();
|
|
121
|
+
};
|
|
122
|
+
registerChangeReview(pi, () => changes, refreshChanges);
|
|
123
|
+
let shells: ShellManager | undefined;
|
|
124
|
+
registerShells(pi, () => shells);
|
|
125
|
+
const onShellEvent = (event: ShellEvent) => {
|
|
126
|
+
footerTui?.requestRender();
|
|
127
|
+
if (!event.job.notify) return;
|
|
128
|
+
// Wake the agent (or queue for its current run) so it never has to poll.
|
|
129
|
+
try {
|
|
130
|
+
pi.sendMessage({ customType: SHELL_MESSAGE, content: shellEventMessage(event), display: true, details: { id: event.job.id, kind: event.kind } },
|
|
131
|
+
{ triggerTurn: true, deliverAs: "followUp" });
|
|
132
|
+
} catch (error) { console.error("pi-jar: could not deliver shell event", error); }
|
|
133
|
+
};
|
|
134
|
+
/** Footer indicators owned by pi-jar. */
|
|
135
|
+
const footerChips = () => {
|
|
136
|
+
const running = shells?.running() ?? 0;
|
|
137
|
+
return [...(changeCount ? [`± ${changeCount} file${changeCount === 1 ? "" : "s"} · /diff`] : []),
|
|
138
|
+
...(running ? [`⚙ ${running} shell${running === 1 ? "" : "s"}`] : [])];
|
|
139
|
+
};
|
|
140
|
+
registerAskTool(pi);
|
|
141
|
+
const modelRoles = new ModelRoleManager(pi);
|
|
142
|
+
const sideUsage = new SideUsage();
|
|
143
|
+
registerAdvisor(pi, modelRoles, { enabled: () => visualSettings.advisor, gates: () => visualSettings.advisorGates, usage: sideUsage });
|
|
144
|
+
registerInfoPanels(pi, { side: sideUsage, quotaEnabled: () => quotaCache?.enabled ?? false,
|
|
145
|
+
quota: (ctx) => quotaCache?.get(ctx.model?.provider, welcomeStatuses(), Date.now()) });
|
|
146
|
+
modelRoles.register((ctx) => openRolesUi(ctx, modelRoles));
|
|
147
|
+
registerDelegate(pi, modelRoles);
|
|
148
|
+
const planMode = new PlanMode(pi, () => todos, modelRoles, updateTaskWidget);
|
|
149
|
+
planMode.register();
|
|
150
|
+
const goalLoop = new GoalLoop(pi, {
|
|
151
|
+
goals: () => goals, todos: () => todos, roles: modelRoles, planActive: () => planMode.isEnabled(),
|
|
152
|
+
maxRounds: () => visualSettings.goalRounds,
|
|
153
|
+
changed: (ctx) => { footerTui?.requestRender(); welcomeTui?.requestRender(); updateTaskWidget(ctx); },
|
|
154
|
+
completed: () => composer.flash("complete", 4000)
|
|
155
|
+
});
|
|
156
|
+
goalLoop.register();
|
|
157
|
+
planMode.setOnEnter((ctx) => goalLoop.pause(ctx, "plan mode is on"));
|
|
158
|
+
const suggestions = new SuggestionState();
|
|
159
|
+
composer.attachSuggestions(suggestions);
|
|
160
|
+
const suggest = registerSuggestions(pi, suggestions, {
|
|
161
|
+
enabled: () => enabled && visualSettings.composer && visualSettings.suggestions && composer.enabled,
|
|
162
|
+
skip: () => planMode.isEnabled() || !!goals?.isActive()
|
|
163
|
+
});
|
|
164
|
+
let liveTui: { setCopyOnSelect?: (enabled: boolean) => void } | undefined;
|
|
165
|
+
/** Pi's own settings (TUI mode, copy-on-select); written through Pi's SettingsManager. */
|
|
166
|
+
const piPreferences = (ctx: ExtensionContext): PiPreferences | undefined => {
|
|
167
|
+
let manager: SettingsManager;
|
|
168
|
+
try { manager = SettingsManager.create(ctx.cwd, getAgentDir()); } catch { return undefined; }
|
|
169
|
+
const save = () => { void manager.flush().catch((error: unknown) => ctx.ui.notify("Could not save Pi settings: " + String(error), "error")); };
|
|
170
|
+
return {
|
|
171
|
+
get: () => ({ fullscreen: manager.getTuiMode() === "fullscreen", copyOnSelect: manager.getFullscreenCopyOnSelect() }),
|
|
172
|
+
setFullscreen: (on) => { manager.setTuiMode(on ? "fullscreen" : "regular"); save(); },
|
|
173
|
+
setCopyOnSelect: (on) => { manager.setFullscreenCopyOnSelect(on); save(); liveTui?.setCopyOnSelect?.(on); }
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const applyWorking = (ctx: ExtensionContext) => {
|
|
178
|
+
const active = enabled && working.phase !== "idle";
|
|
179
|
+
if (!active || !ctx.hasUI || ctx.mode !== "tui") stopWorkingClock();
|
|
180
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
181
|
+
composer.setActivity(enabled ? working.phase : "idle", animations);
|
|
182
|
+
try {
|
|
183
|
+
if (!enabled) { workingIndicatorKey = ""; ctx.ui.setWorkingMessage?.(); ctx.ui.setWorkingIndicator(); return; }
|
|
184
|
+
const task = todos?.current();
|
|
185
|
+
const view = working.view(animations, (color, text) => ctx.ui.theme?.fg(color, text) ?? text,
|
|
186
|
+
Date.now(), ctx.thinkingLevel, task ? task.activeForm ?? task.title : undefined);
|
|
187
|
+
ctx.ui.setWorkingMessage?.(view.message);
|
|
188
|
+
const indicatorKey = `${working.phase}:${animations}`;
|
|
189
|
+
if (indicatorKey !== workingIndicatorKey) {
|
|
190
|
+
ctx.ui.setWorkingIndicator({ frames: view.frames, intervalMs: 240 });
|
|
191
|
+
workingIndicatorKey = indicatorKey;
|
|
192
|
+
}
|
|
193
|
+
if (active && !workingClock) {
|
|
194
|
+
workingClock = setInterval(() => applyWorking(ctx), 1000);
|
|
195
|
+
workingClock.unref?.();
|
|
196
|
+
}
|
|
197
|
+
} catch { stopWorkingClock(); /* Working decoration must never interrupt a Pi turn. */ }
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const stopWelcome = (ctx?: ExtensionContext) => {
|
|
201
|
+
welcomeGit?.abort();
|
|
202
|
+
welcomeGit = undefined;
|
|
203
|
+
if (welcomeInterval) clearInterval(welcomeInterval);
|
|
204
|
+
welcomeInterval = undefined;
|
|
205
|
+
welcomeTui = undefined;
|
|
206
|
+
refreshWelcome = undefined;
|
|
207
|
+
if (ctx?.hasUI && ctx.mode === "tui") {
|
|
208
|
+
try { ctx.ui.setWidget(WELCOME_KEY, undefined); } catch { /* optional widget API */ }
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const openWelcomeSettings = (ctx: ExtensionContext) => {
|
|
213
|
+
if (settingsOpen) return;
|
|
214
|
+
stopWelcome(ctx);
|
|
215
|
+
void openSettings(ctx);
|
|
216
|
+
};
|
|
217
|
+
const runWelcomeAction = (action: WelcomeAction, ctx: ExtensionContext) => {
|
|
218
|
+
if (action === "settings") { openWelcomeSettings(ctx); return; }
|
|
219
|
+
if (action === "refresh") { refreshWelcome?.(); return; }
|
|
220
|
+
const report = (error: unknown) => ctx.ui.notify("pi-jar: " + String(error), "error");
|
|
221
|
+
if (action === "roles") void openRolesUi(ctx, modelRoles).catch(report);
|
|
222
|
+
else if (action === "plan") {
|
|
223
|
+
if (planMode.hasPlan()) void planMode.review(ctx).catch(report);
|
|
224
|
+
else ctx.ui.pasteToEditor("/plan ");
|
|
225
|
+
} else if (action === "goal") void goalLoop.prompt(ctx).catch(report);
|
|
226
|
+
else if (action.startsWith("resume:")) {
|
|
227
|
+
// Switching sessions needs a command context, so stage the command for the user.
|
|
228
|
+
ctx.ui.setEditorText(`/jar resume ${action.slice(7)}`);
|
|
229
|
+
ctx.ui.notify("Press Enter to resume that session", "info");
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
/** Recent sessions as the welcome lists them (numbered from 1). */
|
|
233
|
+
let welcomeRecent: RecentSession[] = [];
|
|
234
|
+
const loadRecent = async (ctx: ExtensionContext) => recentSessions(await SessionManager.list(ctx.cwd), ctx.sessionManager.getSessionFile());
|
|
235
|
+
|
|
236
|
+
const showWelcome = (ctx: ExtensionContext) => {
|
|
237
|
+
if (!ctx.hasUI || ctx.mode !== "tui" || !enabled) return;
|
|
238
|
+
stopWelcome(ctx);
|
|
239
|
+
welcomeFrame = 0;
|
|
240
|
+
welcomeDismiss = 0;
|
|
241
|
+
try {
|
|
242
|
+
const contextUsage = ctx.getContextUsage();
|
|
243
|
+
const context = contextUsage?.percent != null && Number.isFinite(contextUsage.percent)
|
|
244
|
+
? `ctx ${Math.round(contextUsage.percent)}%` : "ctx ?";
|
|
245
|
+
const commands = pi.getCommands().filter((item) => item.source === "extension");
|
|
246
|
+
const managers: ("tasks" | "subagents")[] = [];
|
|
247
|
+
if (commands.some((item) => item.name === "tasks")) managers.push("tasks");
|
|
248
|
+
if (commands.some((item) => item.name === "subagents-fleet")) managers.push("subagents");
|
|
249
|
+
// Footer metadata is cached. The optional dirty check is async and never blocks first paint.
|
|
250
|
+
const branch = welcomeBranch() || undefined;
|
|
251
|
+
const info = {
|
|
252
|
+
model: ctx.model?.id,
|
|
253
|
+
project: ctx.cwd ? basename(ctx.cwd) : undefined,
|
|
254
|
+
context,
|
|
255
|
+
cost: formatCost(cost),
|
|
256
|
+
managers,
|
|
257
|
+
quotaEnabled: quotaCache?.enabled ?? false,
|
|
258
|
+
branch, dirty: false,
|
|
259
|
+
message: hopefulWelcomeMessage(),
|
|
260
|
+
flameSeed: 1 + Math.floor(Math.random() * 1000)
|
|
261
|
+
};
|
|
262
|
+
const git = new AbortController();
|
|
263
|
+
welcomeGit = git;
|
|
264
|
+
const sample = (args: string[], received: (output: string) => void) => {
|
|
265
|
+
execFile("git", args, { cwd: ctx.cwd, timeout: 800, encoding: "utf8", signal: git.signal }, (error, stdout) => {
|
|
266
|
+
if (error || git.signal.aborted || welcomeGit !== git || welcomeDismiss) return;
|
|
267
|
+
received(stdout);
|
|
268
|
+
welcomeTui?.requestRender();
|
|
269
|
+
});
|
|
270
|
+
};
|
|
271
|
+
welcomeRecent = [];
|
|
272
|
+
// Background reads (session files, git) wait until startup settles so they never compete
|
|
273
|
+
// with Pi loading other extensions; the card fills in when they finish.
|
|
274
|
+
const background = setTimeout(() => {
|
|
275
|
+
if (git.signal.aborted || welcomeGit !== git) return;
|
|
276
|
+
void loadRecent(ctx).then((recent) => {
|
|
277
|
+
if (git.signal.aborted || welcomeGit !== git || welcomeDismiss) return;
|
|
278
|
+
welcomeRecent = recent;
|
|
279
|
+
(info as { recent?: unknown }).recent = recent.map((session) => ({ title: session.title, age: ago(session.modified),
|
|
280
|
+
...(session.goal ? { goal: session.goal } : {}), ...(session.plan ? { plan: session.plan } : {}) }));
|
|
281
|
+
welcomeTui?.requestRender();
|
|
282
|
+
}).catch((error) => console.error("pi-jar: recent sessions unavailable", error));
|
|
283
|
+
if (ctx.cwd && existsSync(ctx.cwd)) {
|
|
284
|
+
if (!branch) sample(["branch", "--show-current"], (output) => { info.branch = output.trim() || undefined; });
|
|
285
|
+
sample(["status", "--porcelain"], (output) => { info.dirty = !!output.trim(); });
|
|
286
|
+
}
|
|
287
|
+
}, STARTUP_GRACE_MS);
|
|
288
|
+
background.unref?.();
|
|
289
|
+
git.signal.addEventListener("abort", () => clearTimeout(background), { once: true });
|
|
290
|
+
refreshWelcome = () => {
|
|
291
|
+
info.message = hopefulWelcomeMessage(Math.random, info.message);
|
|
292
|
+
info.flameSeed = 1 + Math.floor(Math.random() * 1000);
|
|
293
|
+
welcomeFrame = 0;
|
|
294
|
+
if (ctx.cwd && existsSync(ctx.cwd) && !welcomeGit?.signal.aborted) sample(["status", "--porcelain"], (output) => { info.dirty = !!output.trim(); });
|
|
295
|
+
welcomeTui?.requestRender();
|
|
296
|
+
};
|
|
297
|
+
ctx.ui.setWidget(WELCOME_KEY, (tui, theme) => {
|
|
298
|
+
welcomeTui = tui;
|
|
299
|
+
let visibleLines: string[] = [];
|
|
300
|
+
let pressed: { action: WelcomeAction; at: number } | undefined;
|
|
301
|
+
return { invalidate() {}, handleMouse(event: TuiMouseEvent) {
|
|
302
|
+
if (event.button !== "left" || welcomeDismiss) return;
|
|
303
|
+
const action = welcomeHit(visibleLines, event.x, event.y);
|
|
304
|
+
if (!action) return;
|
|
305
|
+
if (event.type !== "press" && event.type !== "click") return { handled: true };
|
|
306
|
+
// Act on press (click synthesis is not guaranteed through multiplexers); drop the echo click.
|
|
307
|
+
const now = Date.now();
|
|
308
|
+
if (event.type === "click" && pressed?.action === action && now - pressed.at < WELCOME_CLICK_DEDUPE_MS) return { handled: true };
|
|
309
|
+
pressed = { action, at: now };
|
|
310
|
+
queueMicrotask(() => runWelcomeAction(action, ctx));
|
|
311
|
+
return { handled: true, capture: event.type === "press" };
|
|
312
|
+
}, render(width: number) {
|
|
313
|
+
const statuses = welcomeStatuses();
|
|
314
|
+
const live = collectStatuses(statuses, Date.now());
|
|
315
|
+
const quota = footerSettings.quota ? quotaCache?.get(ctx.model?.provider, statuses, Date.now()) : undefined;
|
|
316
|
+
const open = todos?.all().filter((item) => !item.done) ?? [];
|
|
317
|
+
const lines = welcomeLines(width, welcomeFrame, (color, text) => (ctx.ui.theme ?? theme).fg(color, text), {
|
|
318
|
+
...info, settingsClickable: tui.mode !== "regular", roles: live.roles,
|
|
319
|
+
quota: quota?.week?.used ?? quota?.fiveHour?.used,
|
|
320
|
+
effort: ctx.model?.reasoning === false ? "off" : pi.getThinkingLevel?.(),
|
|
321
|
+
...(modelRoles.activeRole() ? { activeRole: modelRoles.activeRole()! } : {}),
|
|
322
|
+
tasks: open.length, ...(open[0] ? { nextTask: open[0].title } : {}),
|
|
323
|
+
plan: planMode.summary(), ...(goalLoop.progress() ? { goal: goalLoop.progress()! } : {}),
|
|
324
|
+
rolesSummary: modelRoles.summary(), ...(VERSION ? { version: VERSION } : {})
|
|
325
|
+
});
|
|
326
|
+
if (!welcomeDismiss) { visibleLines = lines; return lines; }
|
|
327
|
+
// A short upward dissolve: dim the remaining art as rows disappear.
|
|
328
|
+
const remaining = Math.max(0, Math.ceil(lines.length * (1 - welcomeDismiss / 5)));
|
|
329
|
+
visibleLines = lines.slice(0, remaining).map((line) => (ctx.ui.theme ?? theme).fg("dim", line));
|
|
330
|
+
return visibleLines;
|
|
331
|
+
} };
|
|
332
|
+
});
|
|
333
|
+
if (animations) { welcomeInterval = setInterval(() => { welcomeFrame++; welcomeTui?.requestRender(); }, WELCOME_INTERVAL_MS); welcomeInterval.unref?.(); }
|
|
334
|
+
} catch { stopWelcome(ctx); }
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const installUi = (ctx: ExtensionContext) => {
|
|
338
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
339
|
+
if (!enabled) {
|
|
340
|
+
stopWelcome(ctx);
|
|
341
|
+
quotaCache?.stop();
|
|
342
|
+
composer.disable(ctx);
|
|
343
|
+
updateTaskWidget(ctx);
|
|
344
|
+
disposeFooter?.();
|
|
345
|
+
disposeFooter = undefined;
|
|
346
|
+
ctx.ui.setFooter(undefined);
|
|
347
|
+
applyWorking(ctx);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
applyWorking(ctx);
|
|
351
|
+
updateTaskWidget(ctx);
|
|
352
|
+
disposeFooter?.();
|
|
353
|
+
disposeFooter = undefined;
|
|
354
|
+
try {
|
|
355
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
356
|
+
footerTui = tui;
|
|
357
|
+
liveTui = tui as { setCopyOnSelect?: (enabled: boolean) => void };
|
|
358
|
+
welcomeStatuses = () => footerData.getExtensionStatuses();
|
|
359
|
+
welcomeBranch = () => footerData.getGitBranch();
|
|
360
|
+
let frame = 0;
|
|
361
|
+
let memory = `ram ${Math.round(process.memoryUsage.rss() / 1048576)} MiB`;
|
|
362
|
+
let memoryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
363
|
+
const sampleMemory = () => {
|
|
364
|
+
if (disposed) return;
|
|
365
|
+
memory = `ram ${Math.round(process.memoryUsage.rss() / 1048576)} MiB`;
|
|
366
|
+
tui.requestRender();
|
|
367
|
+
memoryTimer = setTimeout(sampleMemory, 3000);
|
|
368
|
+
memoryTimer.unref?.();
|
|
369
|
+
};
|
|
370
|
+
if (footerSettings.memory) {
|
|
371
|
+
memoryTimer = setTimeout(sampleMemory, 3000);
|
|
372
|
+
memoryTimer.unref?.();
|
|
373
|
+
}
|
|
374
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
375
|
+
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
376
|
+
let disposed = false;
|
|
377
|
+
const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
|
|
378
|
+
const dispose = () => {
|
|
379
|
+
if (disposed) return;
|
|
380
|
+
disposed = true;
|
|
381
|
+
if (memoryTimer) clearTimeout(memoryTimer);
|
|
382
|
+
if (timer) clearInterval(timer);
|
|
383
|
+
if (expiryTimer) clearTimeout(expiryTimer);
|
|
384
|
+
timer = undefined;
|
|
385
|
+
expiryTimer = undefined;
|
|
386
|
+
if (footerTui === tui) { footerTui = undefined; welcomeStatuses = () => new Map<string, string>(); welcomeBranch = () => null; }
|
|
387
|
+
unsubscribe();
|
|
388
|
+
};
|
|
389
|
+
disposeFooter = dispose;
|
|
390
|
+
return {
|
|
391
|
+
dispose,
|
|
392
|
+
invalidate() {},
|
|
393
|
+
render(width: number): string[] {
|
|
394
|
+
try {
|
|
395
|
+
const now = Date.now();
|
|
396
|
+
const statuses = footerData.getExtensionStatuses();
|
|
397
|
+
const live = collectStatuses(statuses, now);
|
|
398
|
+
const roles: JarRole[] = demo ? createDemoRoles() : live.roles;
|
|
399
|
+
if (expiryTimer) clearTimeout(expiryTimer);
|
|
400
|
+
expiryTimer = undefined;
|
|
401
|
+
const nearest = demo ? undefined : roles.reduce<number | undefined>((min, role) =>
|
|
402
|
+
role.expiresAt != null ? Math.min(min ?? Infinity, role.expiresAt) : min, undefined);
|
|
403
|
+
if (nearest != null) expiryTimer = setTimeout(() => tui.requestRender(), Math.max(1, nearest - now));
|
|
404
|
+
const active = animations && footerSettings.roles && roles.some((role) => ACTIVE_STATES.has(role.state));
|
|
405
|
+
if (active && !timer) timer = setInterval(() => { frame += 1; tui.requestRender(); }, 240);
|
|
406
|
+
else if (!active && timer) { clearInterval(timer); timer = undefined; }
|
|
407
|
+
const usage = ctx.getContextUsage();
|
|
408
|
+
const context = usage?.percent == null || !Number.isFinite(usage.percent)
|
|
409
|
+
? "ctx ?" : `ctx ${Math.round(usage.percent)}%`;
|
|
410
|
+
const quota = footerSettings.quota ? quotaCache?.get(ctx.model?.provider, statuses, now) : undefined;
|
|
411
|
+
return renderFooter({
|
|
412
|
+
model: ctx.model?.id ?? "no-model", effort: ctx.model?.reasoning === false ? "off" : (pi.getThinkingLevel?.() ?? "off"),
|
|
413
|
+
sessionName: ctx.sessionManager?.getSessionName?.(),
|
|
414
|
+
cwd: ctx.cwd, settings: footerSettings, branch: footerData.getGitBranch(),
|
|
415
|
+
context, goal: goalLoop.progress(), chips: footerChips(), memory, cost: formatCost(cost), quota, roles, extras: live.extras,
|
|
416
|
+
demo, animations, frame, motionBudget: ctx.isIdle() ? 2 : 1
|
|
417
|
+
}, width, ctx.ui.theme ?? theme);
|
|
418
|
+
} catch {
|
|
419
|
+
if (timer) clearInterval(timer);
|
|
420
|
+
if (expiryTimer) clearTimeout(expiryTimer);
|
|
421
|
+
timer = undefined;
|
|
422
|
+
expiryTimer = undefined;
|
|
423
|
+
return width > 0 ? ["pi-jar: UI unavailable (run /jar ui off)".slice(0, width)] : [];
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
});
|
|
428
|
+
} catch {
|
|
429
|
+
disposeFooter = undefined;
|
|
430
|
+
ctx.ui.setFooter(undefined);
|
|
431
|
+
ctx.ui.notify("pi-jar: custom footer unavailable; using Pi footer", "warning");
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const applyVisualSettings = (next: JarVisualSettings, ctx: ExtensionContext) => {
|
|
436
|
+
const wasEnabled = enabled;
|
|
437
|
+
const hadMotion = animations;
|
|
438
|
+
if (next.accent !== visualSettings.accent && next.accent !== "follow" && !selectAccent(ctx, next.accent)) {
|
|
439
|
+
ctx.ui.notify(`Accent ${next.accent} is not loaded`, "warning");
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
visualSettings = next;
|
|
443
|
+
enabled = next.ui;
|
|
444
|
+
animations = next.animations;
|
|
445
|
+
if (footerSettings.quota && !next.footer.quota) quotaCache?.stop();
|
|
446
|
+
footerSettings = next.footer;
|
|
447
|
+
if (!animations && welcomeInterval) {
|
|
448
|
+
clearInterval(welcomeInterval);
|
|
449
|
+
welcomeInterval = undefined;
|
|
450
|
+
welcomeFrame = 0;
|
|
451
|
+
}
|
|
452
|
+
installUi(ctx);
|
|
453
|
+
if (enabled && next.composer) composer.enable(ctx);
|
|
454
|
+
else composer.disable(ctx);
|
|
455
|
+
composer.setMascot(next.mascot);
|
|
456
|
+
composer.setActivity(enabled ? working.phase : "idle", animations);
|
|
457
|
+
suggest.sync();
|
|
458
|
+
applyWorking(ctx);
|
|
459
|
+
if (!wasEnabled && enabled) showWelcome(ctx);
|
|
460
|
+
else if (!hadMotion && animations && welcomeTui && !welcomeInterval) {
|
|
461
|
+
{ welcomeInterval = setInterval(() => { welcomeFrame++; welcomeTui?.requestRender(); }, WELCOME_INTERVAL_MS); welcomeInterval.unref?.(); }
|
|
462
|
+
}
|
|
463
|
+
footerTui?.requestRender();
|
|
464
|
+
welcomeTui?.requestRender();
|
|
465
|
+
try { saveVisualSettings(getAgentDir(), next); }
|
|
466
|
+
catch { ctx.ui.notify("Visual preferences changed for this session but could not be saved", "warning"); }
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
openSettings = async (ctx) => {
|
|
470
|
+
if (settingsOpen) return;
|
|
471
|
+
settingsOpen = true;
|
|
472
|
+
try { await openJarSettings(ctx, () => visualSettings,
|
|
473
|
+
(next) => applyVisualSettings(next, ctx), loadedAccents(ctx), piPreferences(ctx)); }
|
|
474
|
+
finally { settingsOpen = false; }
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const updateCost = (ctx: ExtensionContext) => {
|
|
478
|
+
try { cost = sessionCost(ctx); } catch { cost = 0; }
|
|
479
|
+
footerTui?.requestRender();
|
|
480
|
+
};
|
|
481
|
+
const restoreTodos = (ctx: ExtensionContext) => {
|
|
482
|
+
try { todos?.restore(ctx.sessionManager.getBranch()); } catch { todos?.restore([]); }
|
|
483
|
+
updateTaskWidget(ctx);
|
|
484
|
+
};
|
|
485
|
+
const restoreGoal = (ctx: ExtensionContext) => {
|
|
486
|
+
try { goals?.restore(ctx.sessionManager.getBranch()); } catch { goals?.restore([]); }
|
|
487
|
+
footerTui?.requestRender();
|
|
488
|
+
};
|
|
489
|
+
pi.on("session_start", (_event, ctx) => {
|
|
490
|
+
sideUsage.clear();
|
|
491
|
+
migrateLegacySettings(getAgentDir());
|
|
492
|
+
visualSettings = loadVisualSettings(getAgentDir());
|
|
493
|
+
animations = visualSettings.animations;
|
|
494
|
+
enabled = visualSettings.ui;
|
|
495
|
+
footerSettings = visualSettings.footer;
|
|
496
|
+
demo = false;
|
|
497
|
+
composer.disable(ctx);
|
|
498
|
+
stopWorkingClock();
|
|
499
|
+
workingIndicatorKey = "";
|
|
500
|
+
working.end();
|
|
501
|
+
quotaCache?.stop();
|
|
502
|
+
todos = new TodoStore((entry) => pi.appendEntry(TASK_ENTRY, entry));
|
|
503
|
+
changes = new ChangeTracker(() => ctx.cwd);
|
|
504
|
+
changeCount = 0;
|
|
505
|
+
shells?.dispose();
|
|
506
|
+
shells = new ShellManager(onShellEvent);
|
|
507
|
+
shells.onChange = () => footerTui?.requestRender();
|
|
508
|
+
goals = new GoalStore((entry) => pi.appendEntry(GOAL_ENTRY, entry));
|
|
509
|
+
restoreTodos(ctx);
|
|
510
|
+
restoreGoal(ctx);
|
|
511
|
+
// Quota is enabled per session; no credentials or consent are persisted.
|
|
512
|
+
quotaCache = new QuotaCache(
|
|
513
|
+
(provider: QuotaProvider, signal) => fetchQuota(provider, (id) => ctx.modelRegistry.getProviderAuth(id), signal),
|
|
514
|
+
() => footerTui?.requestRender()
|
|
515
|
+
);
|
|
516
|
+
// Hold the network lookup until startup settles; the footer shows it once it arrives.
|
|
517
|
+
quotaCache.enabled = false;
|
|
518
|
+
const cache = quotaCache;
|
|
519
|
+
const enableQuota = setTimeout(() => { if (quotaCache === cache && !quotaDisabledByUser) { cache.enabled = true; footerTui?.requestRender(); } }, STARTUP_GRACE_MS);
|
|
520
|
+
enableQuota.unref?.();
|
|
521
|
+
quotaDisabledByUser = false;
|
|
522
|
+
updateCost(ctx);
|
|
523
|
+
installUi(ctx);
|
|
524
|
+
composer.setMascot(visualSettings.mascot);
|
|
525
|
+
if (visualSettings.composer && enabled) composer.enable(ctx);
|
|
526
|
+
suggest.sync();
|
|
527
|
+
if (visualSettings.accent !== "follow") selectAccent(ctx, visualSettings.accent);
|
|
528
|
+
showWelcome(ctx);
|
|
529
|
+
});
|
|
530
|
+
// Any real prompt dismisses the welcome. Slash commands bypass `input`, so `agent_start` covers them.
|
|
531
|
+
const dismissWelcome = (ctx: ExtensionContext) => {
|
|
532
|
+
if (!welcomeTui || welcomeDismiss) return;
|
|
533
|
+
if (!animations) { stopWelcome(ctx); return; }
|
|
534
|
+
welcomeDismiss = 1;
|
|
535
|
+
if (welcomeInterval) clearInterval(welcomeInterval);
|
|
536
|
+
welcomeInterval = setInterval(() => {
|
|
537
|
+
welcomeDismiss++;
|
|
538
|
+
if (welcomeDismiss >= 5) stopWelcome(ctx);
|
|
539
|
+
else welcomeTui?.requestRender();
|
|
540
|
+
}, 90);
|
|
541
|
+
welcomeTui.requestRender();
|
|
542
|
+
};
|
|
543
|
+
pi.on("input", (event, ctx) => {
|
|
544
|
+
if (event.source !== "interactive") return;
|
|
545
|
+
dismissWelcome(ctx);
|
|
546
|
+
if (!todosAcknowledged && todos?.all().every((item) => item.done)) { todosAcknowledged = true; updateTaskWidget(ctx); }
|
|
547
|
+
});
|
|
548
|
+
pi.on("agent_start", (_event, ctx) => { dismissWelcome(ctx); working.start(); applyWorking(ctx); });
|
|
549
|
+
pi.on("turn_start", (_event, ctx) => { working.start(); applyWorking(ctx); });
|
|
550
|
+
pi.on("message_end", (event, ctx) => {
|
|
551
|
+
if (event.message.role === "assistant") {
|
|
552
|
+
working.reportOutputTokens(event.message.usage?.output ?? 0);
|
|
553
|
+
applyWorking(ctx);
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
pi.on("tool_execution_start", (event, ctx) => { working.toolStart(event.toolCallId, event.toolName); applyWorking(ctx); });
|
|
557
|
+
pi.on("tool_execution_end", (event, ctx) => { working.toolEnd(event.toolCallId); applyWorking(ctx); });
|
|
558
|
+
pi.on("ui_prompt_start", (_event, ctx) => { working.prompt(true); applyWorking(ctx); });
|
|
559
|
+
pi.on("ui_prompt_end", (_event, ctx) => { working.prompt(false); applyWorking(ctx); });
|
|
560
|
+
// A request may span several tool turns; keep its elapsed time and reported tokens until agent_end.
|
|
561
|
+
pi.on("turn_end", (_event, ctx) => { applyWorking(ctx); updateCost(ctx); });
|
|
562
|
+
pi.on("agent_end", (_event, ctx) => { working.end(); applyWorking(ctx); });
|
|
563
|
+
pi.on("agent_before_settle", (event) => { if (event.outcome === "error") composer.flash("error"); });
|
|
564
|
+
pi.on("agent_settled", (_event, ctx) => { working.end(); applyWorking(ctx); });
|
|
565
|
+
pi.on("session_tree", (_event, ctx) => { restoreTodos(ctx); restoreGoal(ctx); updateCost(ctx); composer.refreshSession(ctx); });
|
|
566
|
+
pi.on("session_compact", (_event, ctx) => { restoreTodos(ctx); restoreGoal(ctx); updateCost(ctx); });
|
|
567
|
+
pi.on("model_select", (_event, _ctx) => footerTui?.requestRender());
|
|
568
|
+
pi.on("thinking_level_select", (_event, _ctx) => footerTui?.requestRender());
|
|
569
|
+
pi.on("session_info_changed", (_event, ctx) => { composer.refreshSession(ctx); footerTui?.requestRender(); });
|
|
570
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
571
|
+
stopWelcome(ctx);
|
|
572
|
+
composer.disable(ctx);
|
|
573
|
+
stopWorkingClock();
|
|
574
|
+
workingIndicatorKey = "";
|
|
575
|
+
working.end();
|
|
576
|
+
try { if (ctx.hasUI && ctx.mode === "tui") { ctx.ui.setWorkingMessage?.(); ctx.ui.setWorkingIndicator(); ctx.ui.setWidget("pi-jar.todos", undefined); } } catch {}
|
|
577
|
+
quotaCache?.stop();
|
|
578
|
+
quotaCache = undefined;
|
|
579
|
+
todos = undefined;
|
|
580
|
+
changes = undefined;
|
|
581
|
+
changeCount = 0;
|
|
582
|
+
shells?.dispose();
|
|
583
|
+
shells = undefined;
|
|
584
|
+
goals = undefined;
|
|
585
|
+
disposeFooter?.();
|
|
586
|
+
disposeFooter = undefined;
|
|
587
|
+
demo = false;
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
pi.registerShortcut?.(Key.ctrlAlt("r"), {
|
|
591
|
+
description: "Refresh the pi-jar welcome screen",
|
|
592
|
+
handler: async (ctx) => {
|
|
593
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
594
|
+
if (welcomeTui && !welcomeDismiss) refreshWelcome?.(); else showWelcome(ctx);
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
pi.registerShortcut?.(Key.ctrlAlt("h"), {
|
|
598
|
+
description: "Search earlier prompts (pi-jar)",
|
|
599
|
+
handler: async (ctx) => {
|
|
600
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
601
|
+
let earlier: Awaited<ReturnType<typeof SessionManager.list>> = [];
|
|
602
|
+
try {
|
|
603
|
+
const current = ctx.sessionManager.getSessionFile();
|
|
604
|
+
earlier = (await SessionManager.list(ctx.cwd)).filter((session) => session.path !== current)
|
|
605
|
+
.sort((a, b) => b.modified.getTime() - a.modified.getTime()).slice(0, 50);
|
|
606
|
+
} catch (error) { ctx.ui.notify("pi-jar: earlier sessions unavailable: " + String(error), "warning"); }
|
|
607
|
+
const picked = await openPromptSearch(ctx, collectPrompts(ctx.sessionManager.getEntries(), earlier));
|
|
608
|
+
if (picked !== undefined) ctx.ui.setEditorText(picked);
|
|
609
|
+
}
|
|
610
|
+
});
|
|
611
|
+
pi.registerShortcut?.(Key.ctrlAlt("m"), {
|
|
612
|
+
description: "Cycle pi-jar model roles",
|
|
613
|
+
handler: async (ctx) => { await modelRoles.cycle(ctx); footerTui?.requestRender(); }
|
|
614
|
+
});
|
|
615
|
+
pi.registerShortcut?.(Key.ctrlAlt("s"), {
|
|
616
|
+
description: "Open pi-jar settings",
|
|
617
|
+
handler: async (ctx) => {
|
|
618
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
619
|
+
await openSettings(ctx);
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
pi.registerCommand("jar", {
|
|
624
|
+
description: "Pi-jar settings, read-only history, to-dos, prompts, quota and animations",
|
|
625
|
+
handler: async (args, ctx) => {
|
|
626
|
+
const command = args.trim().toLowerCase();
|
|
627
|
+
if (!command && ctx.hasUI && ctx.mode === "tui" || command === "settings") {
|
|
628
|
+
if (!ctx.hasUI || ctx.mode !== "tui") { ctx.ui.notify("Settings require the interactive TUI", "warning"); return; }
|
|
629
|
+
await openSettings(ctx);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (!command || command === "status") {
|
|
633
|
+
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");
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
// `/jar resume` without a number opens the same searchable picker.
|
|
637
|
+
if (command === "sessions" || command.startsWith("sessions ") || command === "resume") {
|
|
638
|
+
if (!ctx.hasUI || ctx.mode !== "tui") { ctx.ui.notify("Session search requires the interactive TUI", "warning"); return; }
|
|
639
|
+
let sessions;
|
|
640
|
+
try { sessions = (await SessionManager.list(ctx.cwd)).sort((a, b) => b.modified.getTime() - a.modified.getTime()); }
|
|
641
|
+
catch (error) { ctx.ui.notify("Could not search sessions: " + String(error), "error"); return; }
|
|
642
|
+
if (!sessions.length) { ctx.ui.notify("No sessions found for this project", "info"); return; }
|
|
643
|
+
const selected = await pickSession(ctx, sessions, command === "resume" ? "" : args.trim().slice(8).trim());
|
|
644
|
+
if (selected && selected !== ctx.sessionManager.getSessionFile()) {
|
|
645
|
+
// Never access the old command context after a successful switch.
|
|
646
|
+
const result = await ctx.switchSession(selected);
|
|
647
|
+
if (result.cancelled) ctx.ui.notify("Session switch cancelled", "info");
|
|
648
|
+
}
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
if (command.startsWith("resume ")) {
|
|
652
|
+
const index = Number(command.slice(7).trim());
|
|
653
|
+
let recent = welcomeRecent;
|
|
654
|
+
try { if (!recent.length) recent = await loadRecent(ctx); } catch (error) { ctx.ui.notify("Could not list sessions: " + String(error), "error"); return; }
|
|
655
|
+
const target = Number.isInteger(index) && index >= 1 ? recent[index - 1] : undefined;
|
|
656
|
+
if (!target) { ctx.ui.notify(`No recent session ${command.slice(7).trim()}; try /jar sessions`, "warning"); return; }
|
|
657
|
+
const result = await ctx.switchSession(target.path);
|
|
658
|
+
if (result.cancelled) ctx.ui.notify("Session switch cancelled", "info");
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (command.startsWith("name ")) {
|
|
662
|
+
const name = args.trim().slice(5).trim();
|
|
663
|
+
if (name) { pi.setSessionName(name); ctx.ui.notify("Session named: " + name, "info"); }
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (command === "history") {
|
|
667
|
+
if (!ctx.hasUI || ctx.mode !== "tui") { ctx.ui.notify("History requires the interactive TUI", "warning"); return; }
|
|
668
|
+
await openJarHistory(ctx);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
if (command === "tasks" || command.startsWith("tasks ")) {
|
|
672
|
+
if (todos) await manageTasks(args.trim().slice(5).trim(), ctx, todos, () => updateTaskWidget(ctx));
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (command === "footer") {
|
|
676
|
+
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
677
|
+
ctx.ui.notify("Footer settings require the interactive TUI", "warning");
|
|
678
|
+
return;
|
|
679
|
+
}
|
|
680
|
+
footerSettings = visualSettings.footer;
|
|
681
|
+
footerTui?.requestRender();
|
|
682
|
+
const labels: Record<(typeof FOOTER_FIELDS)[number], string> = {
|
|
683
|
+
model: "Model", effort: "Model effort", sessionName: "Session name", cwd: "Working directory", context: "Context",
|
|
684
|
+
memory: "Process RAM (RSS)", cost: "Session cost", quota: "Quota", roles: "Roles", extras: "Extension statuses", branch: "Git branch"
|
|
685
|
+
};
|
|
686
|
+
while (true) {
|
|
687
|
+
const options = FOOTER_FIELDS.map((field) => `${footerSettings[field] ? "[x]" : "[ ]"} ${labels[field]}`);
|
|
688
|
+
const selected = await ctx.ui.select("pi-jar · footer visibility", [...options, "Done"]);
|
|
689
|
+
const index = options.indexOf(selected ?? "");
|
|
690
|
+
if (index < 0) break;
|
|
691
|
+
const field = FOOTER_FIELDS[index]!;
|
|
692
|
+
const next = { ...footerSettings, [field]: !footerSettings[field] };
|
|
693
|
+
applyVisualSettings({ ...visualSettings, footer: next }, ctx);
|
|
694
|
+
}
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (command === "accent") {
|
|
698
|
+
const loaded = loadedAccents(ctx);
|
|
699
|
+
ctx.ui.notify(loaded.length
|
|
700
|
+
? `Pi-jar loaded accents: ${loaded.join(", ")}`
|
|
701
|
+
: "Pi-jar loaded accents: none. Install the package or launch with --theme <pi-jar>/themes", "info");
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (command.startsWith("accent ")) {
|
|
705
|
+
const selected = command.slice(7).trim();
|
|
706
|
+
const applied = selectAccent(ctx, selected);
|
|
707
|
+
if (applied) {
|
|
708
|
+
visualSettings = { ...visualSettings, accent: selected as JarVisualSettings["accent"] };
|
|
709
|
+
try { saveVisualSettings(getAgentDir(), visualSettings); }
|
|
710
|
+
catch { ctx.ui.notify("Accent changed for this session but could not be saved", "warning"); }
|
|
711
|
+
applyWorking(ctx);
|
|
712
|
+
}
|
|
713
|
+
const valid = selected === "default" || ACCENT_NAMES.some((name) => name === selected);
|
|
714
|
+
const failure = applied ? "" : !valid ? `Unknown pi-jar accent: ${selected || "(empty)"}; use /jar accent`
|
|
715
|
+
: !loadedAccents(ctx).includes(selected) ? `Pi-jar accent ${selected} is not loaded. Install with pi install npm:pi-jar and restart Pi, or launch with --theme <pi-jar>/themes`
|
|
716
|
+
: `Could not apply pi-jar accent: ${selected}`;
|
|
717
|
+
ctx.ui.notify(applied ? `pi-jar accent: ${selected}` : failure, applied ? "info" : "warning");
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (command === "composer on") {
|
|
721
|
+
const ready = composer.enable(ctx);
|
|
722
|
+
if (ready) applyVisualSettings({ ...visualSettings, composer: true }, ctx);
|
|
723
|
+
ctx.ui.notify(ready ? "pi-jar composer on; /jar composer off restores Pi's editor" : "Composer unavailable in this UI", ready ? "info" : "warning");
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
if (command === "composer off") { applyVisualSettings({ ...visualSettings, composer: false }, ctx); ctx.ui.notify("pi-jar composer off", "info"); return; }
|
|
727
|
+
if (command === "ask" || command.startsWith("ask ")) {
|
|
728
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
729
|
+
const question = args.trim().slice(3).trim() || await promptText(ctx, "Ask with pi-jar", "What would you like to ask?");
|
|
730
|
+
if (!question) return;
|
|
731
|
+
const answer = await promptText(ctx, "Your answer", question);
|
|
732
|
+
if (answer) ctx.ui.pasteToEditor(answer);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (command === "hub") {
|
|
736
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
737
|
+
const native = [
|
|
738
|
+
{ name: "tasks", label: "Tasks (/tasks)" },
|
|
739
|
+
{ name: "subagents-fleet", label: "Subagents · Fleet (/subagents-fleet)" }
|
|
740
|
+
].filter((entry) => pi.getCommands().some((item) => item.name === entry.name && item.source === "extension"));
|
|
741
|
+
if (!native.length) { ctx.ui.notify("No task or subagent manager is installed", "info"); return; }
|
|
742
|
+
const selected = await ctx.ui.select("pi-jar · open existing manager", native.map((item) => item.label));
|
|
743
|
+
const target = native.find((item) => item.label === selected);
|
|
744
|
+
if (target && pi.getCommands().some((item) => item.name === target.name && item.source === "extension")) {
|
|
745
|
+
pi.sendUserMessage(`/${target.name}`, { expandPromptTemplates: true });
|
|
746
|
+
}
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (command === "welcome") { showWelcome(ctx); return; }
|
|
750
|
+
if (command === "commit" || command.startsWith("commit ")) { await jarCommit(pi, ctx, modelRoles, sideUsage, args.trim().slice(6).trim()); return; }
|
|
751
|
+
if (command === "shells") {
|
|
752
|
+
if (!shells) return;
|
|
753
|
+
if (!ctx.hasUI || ctx.mode !== "tui") { ctx.ui.notify(shells.list().map((job) => `${job.id} ${job.name} ${job.status}`).join("\n") || "No background shells", "info"); return; }
|
|
754
|
+
await openShellsView(ctx, shells);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
if (command === "demo") demo = true;
|
|
758
|
+
else if (command === "reset" || command === "demo off") demo = false;
|
|
759
|
+
else if (command === "animations on") applyVisualSettings({ ...visualSettings, animations: true }, ctx);
|
|
760
|
+
else if (command === "animations off") applyVisualSettings({ ...visualSettings, animations: false }, ctx);
|
|
761
|
+
else if (command === "ui on") applyVisualSettings({ ...visualSettings, ui: true }, ctx);
|
|
762
|
+
else if (command === "ui off") applyVisualSettings({ ...visualSettings, ui: false }, ctx);
|
|
763
|
+
else if (command === "quota on" && quotaCache) { quotaCache.enabled = true; quotaDisabledByUser = false; }
|
|
764
|
+
else if (command === "quota off" && quotaCache) { quotaCache.enabled = false; quotaDisabledByUser = true; quotaCache.stop(); }
|
|
765
|
+
else {
|
|
766
|
+
ctx.ui.notify("Usage: /jar [status|settings|commit [note]|sessions [search]|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");
|
|
767
|
+
return;
|
|
768
|
+
}
|
|
769
|
+
if (!["animations on", "animations off", "ui on", "ui off"].includes(command)) installUi(ctx);
|
|
770
|
+
ctx.ui.notify(`pi-jar: ${command}`, "info");
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
}
|