pi-jar 0.1.9 → 0.2.1
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 +16 -5
- package/docs/INTEGRATIONS.md +1 -1
- package/docs/WORKFLOWS.md +1 -1
- package/extensions/index.ts +167 -14
- package/package.json +2 -2
- 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 +70 -40
- package/src/model-roles.ts +29 -9
- package/src/profile-ui.ts +58 -0
- package/src/profiles.ts +148 -0
- package/src/settings-ui.ts +36 -14
- package/src/settings.ts +4 -1
- package/src/shell-notifications.ts +33 -6
- package/src/shells.ts +4 -1
- package/src/side-model.ts +3 -1
- package/src/welcome.ts +6 -1
package/src/delegate.ts
CHANGED
|
@@ -171,15 +171,18 @@ export function delegateArgs(model: string | undefined, thinking: string | undef
|
|
|
171
171
|
: session.source ? ["--fork", session.source, "--session-dir", session.sessionDir]
|
|
172
172
|
: ["--session-dir", session.sessionDir];
|
|
173
173
|
const extensions = extensionFlags(argv);
|
|
174
|
+
const tools = session?.tools ?? toolOverride ?? (!write ? READ_ONLY_TOOLS : undefined);
|
|
175
|
+
const ownExtension = fileURLToPath(new URL("../extensions/index.ts", import.meta.url));
|
|
176
|
+
// Default read-only children need only core file tools and pi-jar. Avoid importing every
|
|
177
|
+
// personal extension (and opening unused MCP/provider connections) for each scout.
|
|
178
|
+
// Explicit extension flags and optional inherited tools retain the parent's full setup.
|
|
179
|
+
const lean = !write && extensions.length === 0 && tools?.every(tool => (READ_ONLY_TOOLS as readonly string[]).includes(tool));
|
|
180
|
+
if (lean) extensions.push("--no-extensions", "--extension", ownExtension);
|
|
174
181
|
// Worktree cwd/session forks (resumed ones too) do not inherit project extension discovery from the parent.
|
|
175
|
-
if (session?.source)
|
|
176
|
-
const ownExtension = fileURLToPath(new URL("../extensions/index.ts", import.meta.url));
|
|
177
|
-
if (!extensions.includes(ownExtension)) extensions.push("--extension", ownExtension);
|
|
178
|
-
}
|
|
182
|
+
else if (session?.source && !extensions.includes(ownExtension)) extensions.push("--extension", ownExtension);
|
|
179
183
|
const args = ["--mode", "rpc", ...sessionArgs, ...extensions];
|
|
180
184
|
if (model) args.push("--model", model);
|
|
181
185
|
if (thinking) args.push("--thinking", thinking);
|
|
182
|
-
const tools = session?.tools ?? toolOverride ?? (!write ? READ_ONLY_TOOLS : undefined);
|
|
183
186
|
if (tools) args.push("--tools", tools.join(","));
|
|
184
187
|
return args;
|
|
185
188
|
}
|
|
@@ -846,12 +849,11 @@ export function startDelegate(run: DelegateRun, args: string[], prompt: string,
|
|
|
846
849
|
// Hibernated: there is no process to stop, so the agent simply ends here.
|
|
847
850
|
if (!proc) { finish("done"); return; }
|
|
848
851
|
if (run.state === "working" && !proc.hibernating) {
|
|
849
|
-
const waiting = waitSettled();
|
|
850
852
|
send({ type: "clear_queue" });
|
|
851
|
-
if (send({ type: "abort" }))
|
|
852
|
-
if (!await settlesWithin(waiting, 3000) && !finished) hardStop();
|
|
853
|
-
} else hardStop();
|
|
853
|
+
if (!send({ type: "abort" })) hardStop();
|
|
854
854
|
}
|
|
855
|
+
// EOF follows the queued abort in the same pipe. Pi can now dispose immediately;
|
|
856
|
+
// do not spend an abort grace and then another full process-exit grace serially.
|
|
855
857
|
const stdin = proc.child.stdin;
|
|
856
858
|
if (stdin && !stdin.writableEnded) stdin.end();
|
|
857
859
|
const killer = setTimeout(() => { if (proc.child.exitCode === null) hardStop(); }, 3000);
|
|
@@ -1256,6 +1258,9 @@ const peekRecord = (record: SubagentRecord): string => {
|
|
|
1256
1258
|
const settledRecord = (record: SubagentRecord): string =>
|
|
1257
1259
|
peekRecord(record) + (record.run.output ? `\nlast: ${cleanBlock(record.run.output, 1200)}` : "");
|
|
1258
1260
|
|
|
1261
|
+
/** One queued moderator notification; several coalesce into a single pi-jar.subagent message. */
|
|
1262
|
+
interface SubagentEvent { record: SubagentRecord; kind: "turn" | "control"; label: string; content: string; detail: Record<string, unknown> }
|
|
1263
|
+
|
|
1259
1264
|
/**
|
|
1260
1265
|
* Every active worktree agent may still end up needing recovery, so admission reserves a recovery slot for
|
|
1261
1266
|
* it: unresolved recovery workspaces never exceed RECOVERY_LIMIT, and none is ever deleted to make room.
|
|
@@ -1334,29 +1339,58 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1334
1339
|
(raw as { customType?: string }).customType !== "pi-jar.moderator-context" || (active && index === latest)) };
|
|
1335
1340
|
});
|
|
1336
1341
|
|
|
1337
|
-
/** Native RPC drives registry state
|
|
1342
|
+
/** Native RPC drives registry state. Completion events wait in our own queue, never Pi's irrevocable
|
|
1343
|
+
* follow-up queue: while the moderator runs they join the next turn boundary so its final answer already
|
|
1344
|
+
* reflects them, instead of re-waking it after that answer. Only events arriving while idle wake a turn. */
|
|
1338
1345
|
const eventBusAvailable = typeof pi.sendMessage === "function";
|
|
1339
1346
|
const watched = new Map<string, SubagentRecord>();
|
|
1340
|
-
const
|
|
1347
|
+
const pendingEvents = new Map<string, SubagentEvent>();
|
|
1341
1348
|
const deleteSettled = (key: string) => {
|
|
1342
|
-
for (const [
|
|
1349
|
+
for (const [id, event] of pendingEvents) if (event.kind === "turn" && event.record.key === key) pendingEvents.delete(id);
|
|
1343
1350
|
};
|
|
1344
|
-
let
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1351
|
+
let moderatorActive = false;
|
|
1352
|
+
let moderatorInterrupted = false;
|
|
1353
|
+
let wakeTimer: NodeJS.Timeout | undefined;
|
|
1354
|
+
const takeEvents = () => {
|
|
1355
|
+
clearTimeout(wakeTimer);
|
|
1356
|
+
wakeTimer = undefined;
|
|
1357
|
+
const events = [...pendingEvents.values()].filter(({ record }) => registry.get(record.key) === record);
|
|
1358
|
+
pendingEvents.clear();
|
|
1359
|
+
if (!events.length) return;
|
|
1360
|
+
const summary = cleanText(`Subagents · ${events.map(event => event.label).join(" · ")}`, 160);
|
|
1361
|
+
return {
|
|
1362
|
+
customType: SUBAGENT_MESSAGE, display: true, details: { summary, events: events.map(event => event.detail) },
|
|
1363
|
+
content: [`[PI-JAR SUBAGENT] ${summary}`, ...events.map(event => event.content), "Review, then resume, ask or stop retained agents as needed."].join("\n\n")
|
|
1364
|
+
};
|
|
1365
|
+
};
|
|
1366
|
+
const scheduleWake = () => {
|
|
1367
|
+
if (moderatorActive || moderatorInterrupted || wakeTimer || !pendingEvents.size) return;
|
|
1368
|
+
wakeTimer = setTimeout(() => {
|
|
1369
|
+
wakeTimer = undefined;
|
|
1370
|
+
if (moderatorActive || moderatorInterrupted) return;
|
|
1371
|
+
const message = takeEvents();
|
|
1372
|
+
if (!message) return;
|
|
1373
|
+
try { pi.sendMessage?.(message, { triggerTurn: true }); }
|
|
1374
|
+
catch (error) { console.error("pi-jar: could not deliver a subagent event", error); }
|
|
1375
|
+
}, NOTIFY_COALESCE_MS);
|
|
1376
|
+
wakeTimer.unref?.();
|
|
1377
|
+
};
|
|
1378
|
+
const queueEvent = (id: string, event: SubagentEvent) => { pendingEvents.set(id, event); scheduleWake(); };
|
|
1379
|
+
const boundary = (event: { outcome?: string; context?: { canContinue?: boolean } }) => {
|
|
1380
|
+
if (event.outcome !== "completed" || event.context?.canContinue === false) return;
|
|
1381
|
+
const message = takeEvents();
|
|
1382
|
+
if (message) return { entries: [{ type: "custom_message" as const, ...message }], continue: true };
|
|
1359
1383
|
};
|
|
1384
|
+
pi.registerMessageRenderer?.<{ summary?: string }>(SUBAGENT_MESSAGE, (message, { expanded }, theme) => {
|
|
1385
|
+
const text = typeof message.content === "string" ? message.content : "Subagents";
|
|
1386
|
+
const summary = message.details?.summary ?? text.split("\n", 1)[0] ?? "Subagents";
|
|
1387
|
+
return new Text(expanded ? text : theme.fg("accent", cleanText(summary, 160)) + theme.fg("dim", " · expand for report"), 0, 0);
|
|
1388
|
+
});
|
|
1389
|
+
pi.on?.("agent_start", () => { moderatorActive = true; moderatorInterrupted = false; clearTimeout(wakeTimer); wakeTimer = undefined; });
|
|
1390
|
+
pi.on?.("turn_end", boundary);
|
|
1391
|
+
pi.on?.("agent_before_settle", (event) => { moderatorInterrupted = event.outcome !== "completed"; return boundary(event); });
|
|
1392
|
+
// An interrupted run keeps its events for the user's next prompt rather than auto-waking.
|
|
1393
|
+
pi.on?.("agent_settled", () => { moderatorActive = false; scheduleWake(); });
|
|
1360
1394
|
registry.subscribe(() => {
|
|
1361
1395
|
if (!watched.size) return;
|
|
1362
1396
|
for (const [key, record] of watched) {
|
|
@@ -1365,14 +1399,11 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1365
1399
|
watched.delete(key);
|
|
1366
1400
|
if (record.run.state !== "stopped") {
|
|
1367
1401
|
// Freeze reports at the settled boundary. A fast resume must not overwrite a queued
|
|
1368
|
-
// initial report, and multiple turns
|
|
1369
|
-
|
|
1402
|
+
// initial report, and multiple turns before delivery must each be delivered.
|
|
1403
|
+
queueEvent(`${key}:${record.run.resumes}`, { record, kind: "turn", label: `${record.run.name} turn ended`,
|
|
1404
|
+
content: settledRecord(record), detail: { key, turn: record.run.resumes } });
|
|
1370
1405
|
}
|
|
1371
1406
|
}
|
|
1372
|
-
if (settledQueue.size && !notifyTimer) {
|
|
1373
|
-
notifyTimer = setTimeout(flushSettled, NOTIFY_COALESCE_MS);
|
|
1374
|
-
notifyTimer.unref?.();
|
|
1375
|
-
}
|
|
1376
1407
|
});
|
|
1377
1408
|
|
|
1378
1409
|
const delegateTool: ToolDefinition<typeof Parameters> = {
|
|
@@ -1872,13 +1903,12 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, regi
|
|
|
1872
1903
|
const deliverControl = (record: SubagentRecord, operation: { id: string; action: string }, result: { content: Array<{ text: string }>; details?: unknown; isError?: boolean }) => {
|
|
1873
1904
|
// A session switch discards the old fleet. Never wake a new session with stale handoffs.
|
|
1874
1905
|
if (registry.get(record.key) !== record) return;
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
} catch (error) { console.error("pi-jar: could not deliver a control event", error); }
|
|
1906
|
+
const status = result.isError ? "failed" : "completed";
|
|
1907
|
+
// The stop handoff supersedes any queued turn report for the same agent.
|
|
1908
|
+
if (operation.action === "stop") deleteSettled(record.key);
|
|
1909
|
+
queueEvent(operation.id, { record, kind: "control", label: `${operation.action} ${record.run.name} ${status}`,
|
|
1910
|
+
content: `${operation.action} ${operation.id} ${status}:\n${result.content.map(part => part.text).join("\n")}`,
|
|
1911
|
+
detail: { operationId: operation.id, action: operation.action, key: record.key, status, report: result.details } });
|
|
1882
1912
|
};
|
|
1883
1913
|
|
|
1884
1914
|
pi.registerTool({
|
package/src/model-roles.ts
CHANGED
|
@@ -139,7 +139,12 @@ function writeConfig(file: string, config: RoleConfig): void {
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
function profileRolePath(directory: string, profileId = "default"): string {
|
|
143
|
+
if (!/^[a-z0-9-]+$/.test(profileId)) throw new Error("Invalid profile ID");
|
|
144
|
+
return profileId === "default" ? join(directory, ROLE_FILE) : join(directory, "pi-jar-profiles", `${profileId}-roles.json`);
|
|
145
|
+
}
|
|
146
|
+
export const profileRoleFile = (directory: string, profileId?: string) => profileRolePath(directory, profileId);
|
|
147
|
+
export const projectRoleFile = (cwd: string, profileId?: string) => profileRolePath(join(cwd, ".pi"), profileId);
|
|
143
148
|
|
|
144
149
|
export function describeRole(row: RoleRow): string {
|
|
145
150
|
const target = row.error ? "⚠ " + row.error
|
|
@@ -152,6 +157,7 @@ export class ModelRoleManager {
|
|
|
152
157
|
private global: RoleConfig = readConfig(join(getAgentDir(), ROLE_FILE));
|
|
153
158
|
private project: RoleConfig = { version: 2, roles: {} };
|
|
154
159
|
private cwd: string | undefined;
|
|
160
|
+
private profileId = "default";
|
|
155
161
|
private active: string | undefined;
|
|
156
162
|
private readonly pi: ExtensionAPI;
|
|
157
163
|
/** True while pi-jar itself is switching models, so its own changes are not seen as manual. */
|
|
@@ -166,11 +172,21 @@ export class ModelRoleManager {
|
|
|
166
172
|
}
|
|
167
173
|
|
|
168
174
|
/** Reload global and project files; project assignments override global ones per role. */
|
|
169
|
-
load(cwd?: string): void {
|
|
170
|
-
|
|
175
|
+
load(cwd?: string, profileRoles?: RoleConfig, profileId = "default"): void {
|
|
176
|
+
const profileFile = profileRoleFile(getAgentDir(), profileId);
|
|
177
|
+
this.global = profileRoles && !existsSync(profileFile) ? parseRoleConfig(profileRoles) : readConfig(profileFile);
|
|
171
178
|
this.cwd = cwd;
|
|
172
|
-
this.
|
|
179
|
+
this.profileId = profileId;
|
|
180
|
+
this.project = cwd ? readConfig(projectRoleFile(cwd, profileId)) : { version: 2, roles: {} };
|
|
181
|
+
}
|
|
182
|
+
activeProfileId(): string { return this.profileId; }
|
|
183
|
+
/** Restore the label only, after a failed profile transaction restored model and effort. */
|
|
184
|
+
restoreActiveRole(role: string | undefined, ctx: ExtensionContext): void {
|
|
185
|
+
if (role !== undefined && !isRoleName(role)) throw new Error("Invalid role name");
|
|
186
|
+
this.active = role;
|
|
187
|
+
this.status(ctx);
|
|
173
188
|
}
|
|
189
|
+
profileRoleConfig(): RoleConfig { return structuredClone(this.global); }
|
|
174
190
|
|
|
175
191
|
private merged(): Record<string, string> { return { ...this.global.roles, ...this.project.roles }; }
|
|
176
192
|
activeRole(): string | undefined { return this.active; }
|
|
@@ -288,7 +304,7 @@ export class ModelRoleManager {
|
|
|
288
304
|
const epoch = this.manualEpoch;
|
|
289
305
|
return async () => {
|
|
290
306
|
// A model or effort the user picked during the workflow wins over the saved one.
|
|
291
|
-
if (this.manualEpoch !== epoch)
|
|
307
|
+
if (this.manualEpoch !== epoch) return;
|
|
292
308
|
this.applying = true;
|
|
293
309
|
try { if (previousModel) await this.pi.setModel(previousModel); this.pi.setThinkingLevel(previousThinking); }
|
|
294
310
|
finally { this.applying = false; }
|
|
@@ -310,6 +326,9 @@ export class ModelRoleManager {
|
|
|
310
326
|
return await this.activate(next, ctx) ? next : undefined;
|
|
311
327
|
}
|
|
312
328
|
|
|
329
|
+
/** Invalidate temporary role restorations after a profile change. */
|
|
330
|
+
invalidateTemporaryRestores(): void { this.manualEpoch++; this.active = undefined; }
|
|
331
|
+
|
|
313
332
|
/** Assign (or clear with undefined) a role in the chosen scope. */
|
|
314
333
|
update(role: string, spec: string | undefined, scope: RoleScope = this.scopeOf(role) ?? "global"): void {
|
|
315
334
|
if (!isRoleName(role)) throw new Error("Invalid role name: " + role);
|
|
@@ -320,7 +339,7 @@ export class ModelRoleManager {
|
|
|
320
339
|
const roles = { ...target.roles };
|
|
321
340
|
if (normalized) roles[role] = normalized; else delete roles[role];
|
|
322
341
|
const next: RoleConfig = { ...target, version: 2, roles };
|
|
323
|
-
writeConfig(scope === "project" ? projectRoleFile(this.cwd
|
|
342
|
+
writeConfig(scope === "project" ? projectRoleFile(this.cwd!, this.profileId) : profileRoleFile(getAgentDir(), this.profileId), next);
|
|
324
343
|
if (scope === "project") this.project = next; else this.global = next;
|
|
325
344
|
}
|
|
326
345
|
|
|
@@ -346,11 +365,11 @@ export class ModelRoleManager {
|
|
|
346
365
|
if (scope === "project" && !this.cwd) throw new Error("Project roles need a working directory");
|
|
347
366
|
const target = scope === "project" ? this.project : this.global;
|
|
348
367
|
const next: RoleConfig = { ...target, fallbacks: { ...target.fallbacks, [role]: [...new Set(normalized)] } };
|
|
349
|
-
writeConfig(scope === "project" ? projectRoleFile(this.cwd
|
|
368
|
+
writeConfig(scope === "project" ? projectRoleFile(this.cwd!, this.profileId) : profileRoleFile(getAgentDir(), this.profileId), next);
|
|
350
369
|
if (scope === "project") this.project = next; else this.global = next;
|
|
351
370
|
}
|
|
352
371
|
|
|
353
|
-
register(openUi?: (ctx: ExtensionContext) => Promise<void>, options: { activateDefault?: () => boolean } = {}): void {
|
|
372
|
+
register(openUi?: (ctx: ExtensionContext) => Promise<void>, options: { activateDefault?: () => boolean; beforeSessionStart?: (ctx: ExtensionContext) => { roles?: RoleConfig; profileId?: string } | undefined } = {}): void {
|
|
354
373
|
const manual = (_event: unknown, ctx: ExtensionContext) => {
|
|
355
374
|
if (this.applying) return;
|
|
356
375
|
this.manualEpoch++;
|
|
@@ -359,7 +378,8 @@ export class ModelRoleManager {
|
|
|
359
378
|
this.pi.on("model_select", manual);
|
|
360
379
|
this.pi.on("thinking_level_select", manual);
|
|
361
380
|
this.pi.on("session_start", async (_event, ctx) => {
|
|
362
|
-
|
|
381
|
+
const profile = options.beforeSessionStart?.(ctx);
|
|
382
|
+
this.load(ctx.cwd, profile?.roles, profile?.profileId);
|
|
363
383
|
this.active = undefined;
|
|
364
384
|
// Delegated children already received their role model/effort via CLI arguments.
|
|
365
385
|
if (options.activateDefault?.() !== false && this.resolveCandidates("default").length) await this.activate("default", ctx, true);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { JarProfile, ProfileStore } from "./profiles.ts";
|
|
3
|
+
|
|
4
|
+
export type ProfileAction = "create" | "switch";
|
|
5
|
+
|
|
6
|
+
export interface ProfileUiOptions {
|
|
7
|
+
store: ProfileStore;
|
|
8
|
+
themes: readonly string[];
|
|
9
|
+
/** Copies the active profile's settings and roles into a new profile. */
|
|
10
|
+
create(name: string, theme: string): JarProfile;
|
|
11
|
+
/** Why the current session cannot switch profiles, or undefined when it can. */
|
|
12
|
+
switchBlocked(): string | undefined;
|
|
13
|
+
select(name: string): Promise<void>;
|
|
14
|
+
openRoles(): Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const CREATE = "+ Create profile…";
|
|
18
|
+
const FOLLOW = "Follow Pi";
|
|
19
|
+
|
|
20
|
+
/** Name and theme are the only creation questions; roles are configured afterwards in the roles UI. */
|
|
21
|
+
export async function promptNewProfile(ctx: ExtensionContext, options: Pick<ProfileUiOptions, "store" | "themes" | "create">): Promise<JarProfile | undefined> {
|
|
22
|
+
const name = await ctx.ui.input("New profile name (1–32 characters)", "");
|
|
23
|
+
if (name === undefined) return undefined;
|
|
24
|
+
if (/[\u0000-\u001f\u007f-\u009f]/.test(name)) { ctx.ui.notify("Profile name cannot contain control characters", "warning"); return undefined; }
|
|
25
|
+
const normalized = name.trim().replace(/\s+/g, " ");
|
|
26
|
+
if (!normalized || normalized.length > 32 || /[\\/\0]/.test(normalized)) {
|
|
27
|
+
ctx.ui.notify("Profile name must be 1–32 characters and cannot contain slashes", "warning"); return undefined;
|
|
28
|
+
}
|
|
29
|
+
if (options.store.list().some((profile) => profile.name.toLocaleLowerCase() === normalized.toLocaleLowerCase())) {
|
|
30
|
+
ctx.ui.notify("A profile with that name already exists", "warning"); return undefined;
|
|
31
|
+
}
|
|
32
|
+
const selected = await ctx.ui.select("Choose a theme for " + normalized, [FOLLOW, ...options.themes.filter((item) => item !== "follow")]);
|
|
33
|
+
if (!selected) return undefined;
|
|
34
|
+
try { return options.create(normalized, selected === FOLLOW ? "follow" : selected); }
|
|
35
|
+
catch (error) { ctx.ui.notify("Could not create profile: " + (error as Error).message, "error"); return undefined; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** /profiles and the settings Profiles tab: create a profile or switch to one, nothing else. */
|
|
39
|
+
export async function openProfiles(ctx: ExtensionContext, options: ProfileUiOptions, action?: ProfileAction): Promise<void> {
|
|
40
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
41
|
+
const active = options.store.activeName;
|
|
42
|
+
if (action !== "create") {
|
|
43
|
+
const labels = new Map(options.store.list().map((profile) => [profile.name === active ? `${profile.name} (active)` : profile.name, profile.name]));
|
|
44
|
+
const choice = await ctx.ui.select(action === "switch" ? "Switch profile" : "Profiles", action === "switch" ? [...labels.keys()] : [CREATE, ...labels.keys()]);
|
|
45
|
+
if (!choice) return;
|
|
46
|
+
if (choice !== CREATE) { await options.select(labels.get(choice)!); return; }
|
|
47
|
+
}
|
|
48
|
+
const profile = await promptNewProfile(ctx, options);
|
|
49
|
+
if (!profile) return;
|
|
50
|
+
if (options.switchBlocked()) {
|
|
51
|
+
ctx.ui.notify(`Created profile ${profile.name} with ${active}'s model roles. Switch to it in a fresh session with /profiles, then set its roles with /roles.`, "info");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
await options.select(profile.name);
|
|
55
|
+
if (options.store.activeName !== profile.name) return;
|
|
56
|
+
ctx.ui.notify(`Created profile ${profile.name}. Its model roles start as a copy of ${active}'s; set them now in Roles (later: /roles).`, "info");
|
|
57
|
+
await options.openRoles();
|
|
58
|
+
}
|
package/src/profiles.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isRoleName, normalizeSpec, parseRoleConfig, type RoleConfig } from "./model-roles.ts";
|
|
4
|
+
import { isMaxSubagents, defaultVisualSettings, loadVisualSettings, type JarVisualSettings } from "./settings.ts";
|
|
5
|
+
import { ACCENT_NAMES } from "./accent.ts";
|
|
6
|
+
import { FOOTER_FIELDS } from "./footer-settings.ts";
|
|
7
|
+
import { ICON_SETS } from "./icons.ts";
|
|
8
|
+
|
|
9
|
+
export interface JarProfile { id: string; name: string; theme: string; settings: JarVisualSettings; roles: RoleConfig }
|
|
10
|
+
interface ProfileDocument { version: 1; active: string; profiles: JarProfile[] }
|
|
11
|
+
export const PROFILE_FILE = "pi-jar-profiles.json";
|
|
12
|
+
export const DEFAULT_PROFILE = "Default";
|
|
13
|
+
export const PROFILE_ENTRY = "pi-jar.profile";
|
|
14
|
+
export function conversationStarted(entries: readonly unknown[]): boolean {
|
|
15
|
+
return entries.some((entry) => isPlainObject(entry) && (entry.role === "user"
|
|
16
|
+
|| (entry.type === "message" && isPlainObject(entry.message) && entry.message.role === "user")));
|
|
17
|
+
}
|
|
18
|
+
export function pinnedProfileId(entries: readonly unknown[]): string | undefined {
|
|
19
|
+
for (const entry of [...entries].reverse()) {
|
|
20
|
+
if (isPlainObject(entry) && entry.type === "custom" && entry.customType === PROFILE_ENTRY
|
|
21
|
+
&& isPlainObject(entry.data) && entry.data.version === 1 && typeof entry.data.id === "string") return entry.data.id;
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
const safeName = (value: string) => value.trim().replace(/\s+/g, " ");
|
|
26
|
+
const profileId = () => `profile-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
27
|
+
const isPlainObject = (value: unknown): value is Record<string, unknown> => !!value && typeof value === "object" && !Array.isArray(value);
|
|
28
|
+
|
|
29
|
+
function validProfileName(value: unknown): value is string {
|
|
30
|
+
return typeof value === "string" && value.length > 0 && value.length <= 32 && !/[\\/\0\u0001-\u001f\u007f-\u009f]/.test(value) && value.trim() === value;
|
|
31
|
+
}
|
|
32
|
+
function normalizeProfile(value: unknown): JarProfile | undefined {
|
|
33
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
34
|
+
const raw = value as Record<string, unknown>;
|
|
35
|
+
if (!validProfileName(raw.name) || typeof raw.theme !== "string" || !raw.theme || !isPlainObject(raw.settings)
|
|
36
|
+
|| raw.settings.version !== 1 || !isPlainObject(raw.roles) || raw.roles.version !== 2 || !isPlainObject(raw.roles.roles)) return undefined;
|
|
37
|
+
const settings = raw.settings;
|
|
38
|
+
const footer = settings.footer;
|
|
39
|
+
if (!["accent", "animations", "ui", "composer", "mascot", "suggestions", "goalRounds", "maxSubagents", "advisor", "advisorGates", "icons", "footer"].every((key) => key in settings)
|
|
40
|
+
|| !isPlainObject(settings.footer) || typeof settings.animations !== "boolean" || typeof settings.ui !== "boolean"
|
|
41
|
+
|| typeof settings.composer !== "boolean" || typeof settings.mascot !== "boolean" || typeof settings.suggestions !== "boolean"
|
|
42
|
+
|| typeof settings.advisor !== "boolean" || typeof settings.advisorGates !== "boolean"
|
|
43
|
+
|| (settings.contextDiet !== undefined && typeof settings.contextDiet !== "boolean") || !Number.isInteger(settings.goalRounds)
|
|
44
|
+
|| (settings.goalRounds as number) < 1 || (settings.goalRounds as number) > 50
|
|
45
|
+
|| !isMaxSubagents(settings.maxSubagents) || typeof settings.accent !== "string"
|
|
46
|
+
|| !(settings.accent === "follow" || settings.accent === "default" || ACCENT_NAMES.includes(settings.accent as never))
|
|
47
|
+
|| !ICON_SETS.includes(settings.icons as never)
|
|
48
|
+
|| !isPlainObject(footer) || !FOOTER_FIELDS.every((field) => typeof footer[field] === "boolean")) return undefined;
|
|
49
|
+
if (raw.name === DEFAULT_PROFILE && raw.id !== "default") return undefined;
|
|
50
|
+
if (raw.name !== DEFAULT_PROFILE && (typeof raw.id !== "string" || !/^profile-[a-z0-9-]{8,64}$/.test(raw.id))) return undefined;
|
|
51
|
+
for (const [role, spec] of Object.entries(raw.roles.roles)) if (!isRoleName(role) || typeof spec !== "string" || !normalizeSpec(spec)) return undefined;
|
|
52
|
+
return { id: raw.id as string, name: raw.name, theme: raw.theme, settings: { ...raw.settings, contextDiet: settings.contextDiet ?? false } as unknown as JarVisualSettings, roles: parseRoleConfig(raw.roles) };
|
|
53
|
+
}
|
|
54
|
+
function atomicSave(file: string, document: ProfileDocument): string {
|
|
55
|
+
mkdirSync(join(file, ".."), { recursive: true });
|
|
56
|
+
const temporary = `${file}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
57
|
+
const text = JSON.stringify(document, null, 2) + "\n";
|
|
58
|
+
try { writeFileSync(temporary, text, { flag: "wx" }); renameSync(temporary, file); }
|
|
59
|
+
catch (error) { try { unlinkSync(temporary); } catch {} throw error; }
|
|
60
|
+
return text;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export class ProfileStore {
|
|
64
|
+
private document: ProfileDocument;
|
|
65
|
+
private savedContents: string | undefined;
|
|
66
|
+
readonly directory: string;
|
|
67
|
+
constructor(directory: string, initialTheme = "pi-jar-dark", initialRoles: RoleConfig = { version: 2, roles: {} }) {
|
|
68
|
+
this.directory = directory;
|
|
69
|
+
const file = join(directory, PROFILE_FILE);
|
|
70
|
+
let loaded: ProfileDocument | undefined;
|
|
71
|
+
let missing = false;
|
|
72
|
+
try {
|
|
73
|
+
this.savedContents = readFileSync(file, "utf8");
|
|
74
|
+
const raw: unknown = JSON.parse(this.savedContents);
|
|
75
|
+
if (!isPlainObject(raw) || raw.version !== 1 || !Array.isArray(raw.profiles) || typeof raw.active !== "string") throw new Error("Unsupported or malformed pi-jar profiles file");
|
|
76
|
+
const profiles: JarProfile[] = [];
|
|
77
|
+
const names = new Set<string>(); const ids = new Set<string>();
|
|
78
|
+
for (const entry of raw.profiles) {
|
|
79
|
+
const profile = normalizeProfile(entry);
|
|
80
|
+
if (!profile) throw new Error("Malformed profile entry");
|
|
81
|
+
const name = profile.name.toLocaleLowerCase();
|
|
82
|
+
if (names.has(name) || ids.has(profile.id)) throw new Error("Duplicate profile name or id");
|
|
83
|
+
names.add(name); ids.add(profile.id); profiles.push(profile);
|
|
84
|
+
}
|
|
85
|
+
if (profiles.filter((profile) => profile.name === DEFAULT_PROFILE && profile.id === "default").length !== 1) throw new Error("Missing or invalid Default profile");
|
|
86
|
+
const active = profiles.find((profile) => profile.name === raw.active || profile.id === raw.active)?.name;
|
|
87
|
+
if (!active) throw new Error("Active profile does not exist");
|
|
88
|
+
loaded = { version: 1, active, profiles };
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") missing = true;
|
|
91
|
+
else throw new Error(`Cannot load ${PROFILE_FILE}: ${(error as Error).message}`);
|
|
92
|
+
}
|
|
93
|
+
if (!loaded && missing) {
|
|
94
|
+
let legacy = defaultVisualSettings();
|
|
95
|
+
try { legacy = loadVisualSettings(directory); } catch { /* default settings */ }
|
|
96
|
+
loaded = { version: 1, active: DEFAULT_PROFILE, profiles: [{ id: "default", name: DEFAULT_PROFILE, theme: initialTheme, settings: legacy, roles: parseRoleConfig(initialRoles) }] };
|
|
97
|
+
}
|
|
98
|
+
this.document = loaded!;
|
|
99
|
+
if (missing) this.persist();
|
|
100
|
+
}
|
|
101
|
+
private persist(next = this.document): void {
|
|
102
|
+
const file = join(this.directory, PROFILE_FILE);
|
|
103
|
+
let current: string | undefined;
|
|
104
|
+
try { current = readFileSync(file, "utf8"); }
|
|
105
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
|
|
106
|
+
if (current !== this.savedContents) throw new Error("Profiles changed in another session or externally; reload before saving");
|
|
107
|
+
this.savedContents = atomicSave(file, next);
|
|
108
|
+
}
|
|
109
|
+
list(): JarProfile[] { return this.document.profiles.map((profile) => structuredClone(profile)); }
|
|
110
|
+
active(): JarProfile { return structuredClone(this.document.profiles.find((profile) => profile.name === this.document.active)!); }
|
|
111
|
+
get activeName(): string { return this.document.active; }
|
|
112
|
+
update(name: string, patch: Partial<Pick<JarProfile, "theme" | "settings">>): JarProfile {
|
|
113
|
+
const index = this.document.profiles.findIndex((profile) => profile.name === name);
|
|
114
|
+
if (index < 0) throw new Error("Unknown profile: " + name);
|
|
115
|
+
const profiles = [...this.document.profiles];
|
|
116
|
+
const profile = normalizeProfile(structuredClone({ ...profiles[index]!, ...patch }));
|
|
117
|
+
if (!profile) throw new Error("Invalid profile settings or theme");
|
|
118
|
+
profiles[index] = profile;
|
|
119
|
+
const next = { ...this.document, profiles };
|
|
120
|
+
this.persist(next);
|
|
121
|
+
this.document = next;
|
|
122
|
+
return structuredClone(profile);
|
|
123
|
+
}
|
|
124
|
+
add(name: string, theme: string, settings: JarVisualSettings, roles: RoleConfig = { version: 2, roles: {} }): JarProfile {
|
|
125
|
+
if (typeof name !== "string" || /[\u0000-\u001f\u007f-\u009f]/.test(name)) throw new Error("Profile name cannot contain control characters");
|
|
126
|
+
const normalized = safeName(name);
|
|
127
|
+
if (!validProfileName(normalized)) throw new Error("Profile name must be 1–32 characters and cannot contain slashes");
|
|
128
|
+
if (this.document.profiles.some((profile) => profile.name.toLocaleLowerCase() === normalized.toLocaleLowerCase())) throw new Error("A profile with that name already exists");
|
|
129
|
+
const id = profileId();
|
|
130
|
+
const profile = normalizeProfile({ id, name: normalized, theme, settings: structuredClone(settings), roles });
|
|
131
|
+
if (!profile) throw new Error("Invalid profile settings, theme, or roles");
|
|
132
|
+
const next = { ...this.document, profiles: [...this.document.profiles, profile] };
|
|
133
|
+
this.persist(next);
|
|
134
|
+
this.document = next;
|
|
135
|
+
return structuredClone(profile);
|
|
136
|
+
}
|
|
137
|
+
activate(name: string): JarProfile {
|
|
138
|
+
if (!this.document.profiles.some((profile) => profile.name === name)) throw new Error("Unknown profile: " + name);
|
|
139
|
+
const next = { ...this.document, active: name };
|
|
140
|
+
this.persist(next);
|
|
141
|
+
this.document = next;
|
|
142
|
+
return this.active();
|
|
143
|
+
}
|
|
144
|
+
cycle(): JarProfile {
|
|
145
|
+
const index = this.document.profiles.findIndex((profile) => profile.name === this.document.active);
|
|
146
|
+
return this.activate(this.document.profiles[(index + 1) % this.document.profiles.length]!.name);
|
|
147
|
+
}
|
|
148
|
+
}
|
package/src/settings-ui.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } fr
|
|
|
3
3
|
import { FOOTER_FIELDS, type FooterField } from "./footer-settings.ts";
|
|
4
4
|
import { ICON_SETS } from "./icons.ts";
|
|
5
5
|
import { GOAL_ROUND_CHOICES, MAX_SUBAGENT_CHOICES, type JarAccent, type JarVisualSettings } from "./settings.ts";
|
|
6
|
+
import type { ProfileAction } from "./profile-ui.ts";
|
|
6
7
|
|
|
7
8
|
const LABELS: Record<FooterField, string> = {
|
|
8
9
|
model: "Model", effort: "Model effort", sessionName: "Session name", cwd: "Working directory",
|
|
@@ -10,8 +11,7 @@ const LABELS: Record<FooterField, string> = {
|
|
|
10
11
|
extras: "Extension statuses", branch: "Git branch"
|
|
11
12
|
};
|
|
12
13
|
|
|
13
|
-
type Page = "appearance" | "footer" | "pi";
|
|
14
|
-
const PAGES: Page[] = ["appearance", "footer", "pi"];
|
|
14
|
+
type Page = "appearance" | "footer" | "pi" | "profiles";
|
|
15
15
|
|
|
16
16
|
/** Pi-level preferences pi-jar can toggle (written to Pi's own settings). */
|
|
17
17
|
export interface PiPreferences {
|
|
@@ -26,18 +26,22 @@ export async function openJarSettings(
|
|
|
26
26
|
current: () => JarVisualSettings,
|
|
27
27
|
update: (settings: JarVisualSettings) => void,
|
|
28
28
|
availableAccents: readonly string[],
|
|
29
|
-
pi?: PiPreferences
|
|
29
|
+
pi?: PiPreferences,
|
|
30
|
+
profiles?: { activeName(): string; open(action: ProfileAction): Promise<void> }
|
|
30
31
|
): Promise<void> {
|
|
31
32
|
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
const PAGES: Page[] = profiles ? ["appearance", "footer", "pi", "profiles"] : ["appearance", "footer", "pi"];
|
|
34
|
+
let lastPage: Page = "appearance";
|
|
35
|
+
while (true) {
|
|
36
|
+
const action = await ctx.ui.custom<ProfileAction | undefined>((tui, theme, _keys, done) => {
|
|
37
|
+
let page: Page = lastPage;
|
|
34
38
|
let selected = 0;
|
|
35
39
|
let width = 64;
|
|
36
40
|
const accents: JarAccent[] = ["follow", ...availableAccents.filter((name): name is JarAccent =>
|
|
37
41
|
name === "default" || ["gray", "pink", "teal", "azure", "violet", "amber"].includes(name))];
|
|
38
42
|
let choosing: "goalRounds" | "maxSubagents" | undefined;
|
|
39
43
|
const choices = () => choosing === "maxSubagents" ? MAX_SUBAGENT_CHOICES : GOAL_ROUND_CHOICES;
|
|
40
|
-
const fields = () => choosing ? choices().length : page === "footer" ? FOOTER_FIELDS.length : page === "pi" ?
|
|
44
|
+
const fields = () => choosing ? choices().length : page === "footer" ? FOOTER_FIELDS.length : page === "pi" ? 7 : page === "profiles" ? 2 : 7;
|
|
41
45
|
const finishChoice = () => { selected = choosing === "maxSubagents" ? 3 : 2; choosing = undefined; };
|
|
42
46
|
const piError = (error: unknown) => ctx.ui.notify("Could not change Pi setting: " + (error as Error).message, "error");
|
|
43
47
|
const explainPool = () => choosing === "maxSubagents" || (page === "pi" && !choosing && selected === 3);
|
|
@@ -50,6 +54,10 @@ export async function openJarSettings(
|
|
|
50
54
|
if (choosing === "maxSubagents") update({ ...state, maxSubagents: MAX_SUBAGENT_CHOICES[index]! });
|
|
51
55
|
else update({ ...state, goalRounds: GOAL_ROUND_CHOICES[index]! });
|
|
52
56
|
finishChoice();
|
|
57
|
+
} else if (page === "profiles") {
|
|
58
|
+
lastPage = page;
|
|
59
|
+
done(index === 0 ? "switch" : "create");
|
|
60
|
+
return;
|
|
53
61
|
} else if (page === "footer") {
|
|
54
62
|
const field = FOOTER_FIELDS[index];
|
|
55
63
|
if (field) update({ ...state, footer: { ...state.footer, [field]: !state.footer[field] } });
|
|
@@ -66,6 +74,7 @@ export async function openJarSettings(
|
|
|
66
74
|
}
|
|
67
75
|
else if (index === 4) update({ ...state, advisor: !state.advisor });
|
|
68
76
|
else if (index === 5) update({ ...state, advisorGates: !state.advisorGates });
|
|
77
|
+
else if (index === 6) update({ ...state, contextDiet: !state.contextDiet });
|
|
69
78
|
} else if (index === 0) {
|
|
70
79
|
const at = Math.max(0, accents.indexOf(state.accent));
|
|
71
80
|
update({ ...state, accent: accents[(at + 1) % accents.length]! });
|
|
@@ -90,7 +99,7 @@ export async function openJarSettings(
|
|
|
90
99
|
invalidate() {},
|
|
91
100
|
handleInput(data: string) {
|
|
92
101
|
if (matchesKey(data, Key.escape) || data === "q") {
|
|
93
|
-
if (choosing) { finishChoice(); tui.requestRender(); } else done();
|
|
102
|
+
if (choosing) { finishChoice(); tui.requestRender(); } else done(undefined);
|
|
94
103
|
return;
|
|
95
104
|
}
|
|
96
105
|
if (choosing && (matchesKey(data, Key.tab) || matchesKey(data, Key.right)
|
|
@@ -112,19 +121,20 @@ export async function openJarSettings(
|
|
|
112
121
|
tui.requestRender(); return { handled: true };
|
|
113
122
|
}
|
|
114
123
|
if (event.type !== "click" || event.button !== "left") return;
|
|
115
|
-
if (event.y === 0 && event.x >= width - 4) { done(); return { handled: true }; }
|
|
124
|
+
if (event.y === 0 && event.x >= width - 4) { done(undefined); return { handled: true }; }
|
|
116
125
|
if (event.y === 1) {
|
|
117
126
|
choosing = undefined;
|
|
118
127
|
if (event.x >= 2 && event.x < Math.min(width - 2, 19)) { page = "appearance"; selected = 0; }
|
|
119
128
|
else if (event.x >= 19 && event.x < Math.min(width - 2, 32)) { page = "footer"; selected = 0; }
|
|
120
|
-
else if (event.x >= 32 && event.x < width - 2) { page = "pi"; selected = 0; }
|
|
129
|
+
else if (event.x >= 32 && event.x < Math.min(width - 2, 41)) { page = "pi"; selected = 0; }
|
|
130
|
+
else if (profiles && event.x >= 41 && event.x < width - 2) { page = "profiles"; selected = 0; }
|
|
121
131
|
else return;
|
|
122
132
|
tui.requestRender(); return { handled: true, focus: true };
|
|
123
133
|
}
|
|
124
134
|
if (event.y >= 5 && event.y < 5 + visibleCount() && event.x >= 2 && event.x < width - 2) {
|
|
125
135
|
selected = firstVisible() + event.y - 5; apply(selected); return { handled: true, focus: true };
|
|
126
136
|
}
|
|
127
|
-
if (event.y === 5 + visibleCount() && event.x >= 2 && event.x < width - 2) { done(); return { handled: true }; }
|
|
137
|
+
if (event.y === 5 + visibleCount() && event.x >= 2 && event.x < width - 2) { done(undefined); return { handled: true }; }
|
|
128
138
|
},
|
|
129
139
|
render(available: number): string[] {
|
|
130
140
|
width = Math.max(8, available);
|
|
@@ -136,7 +146,8 @@ export async function openJarSettings(
|
|
|
136
146
|
+ "─".repeat(Math.max(0, width - 4 - visibleWidth(title))) + "×╮");
|
|
137
147
|
const tabs = line(theme.fg(page === "appearance" ? "accent" : "muted", "[ Appearance ]")
|
|
138
148
|
+ " " + theme.fg(page === "footer" ? "accent" : "muted", "[ Footer ]")
|
|
139
|
-
+ " " + theme.fg(page === "pi" ? "accent" : "muted", "[ Pi ]")
|
|
149
|
+
+ " " + theme.fg(page === "pi" ? "accent" : "muted", "[ Pi ]")
|
|
150
|
+
+ (profiles ? " " + theme.fg(page === "profiles" ? "accent" : "muted", "[ Profiles ]") : ""));
|
|
140
151
|
const prefs = pi?.get();
|
|
141
152
|
const divider = theme.fg("dim", "├" + "─".repeat(Math.max(0, width - 2)) + "┤");
|
|
142
153
|
const allRows = choosing
|
|
@@ -149,7 +160,11 @@ export async function openJarSettings(
|
|
|
149
160
|
row(2, "Goal auto rounds", String(state.goalRounds), true),
|
|
150
161
|
row(3, "Max subagents (retained live pool)", String(state.maxSubagents), true),
|
|
151
162
|
row(4, "Advisor (jar_advisor, /advisor)", state.advisor ? "ON" : "OFF", state.advisor),
|
|
152
|
-
row(5, "Advisor gates (loops, repeated failures)", state.advisorGates ? "ON" : "OFF", state.advisorGates)
|
|
163
|
+
row(5, "Advisor gates (loops, repeated failures)", state.advisorGates ? "ON" : "OFF", state.advisorGates),
|
|
164
|
+
row(6, "Context diet (prior-turn reasoning)", state.contextDiet ? "ON" : "OFF", state.contextDiet)
|
|
165
|
+
] : page === "profiles" ? [
|
|
166
|
+
row(0, "Switch profile…", profiles!.activeName(), true),
|
|
167
|
+
row(1, "Create profile…", "OPEN", true)
|
|
153
168
|
] : [
|
|
154
169
|
row(0, "Accent", state.accent === "follow" ? "FOLLOW PI" : state.accent.toUpperCase(), true),
|
|
155
170
|
row(1, "Motion", state.animations ? "ON" : "OFF", state.animations),
|
|
@@ -163,11 +178,12 @@ export async function openJarSettings(
|
|
|
163
178
|
const rows = allRows.slice(start, start + pageSize());
|
|
164
179
|
return [top, tabs, divider,
|
|
165
180
|
line(theme.fg("accent", choosing === "maxSubagents" ? " MAX SUBAGENTS · RETAINED LIVE POOL"
|
|
166
|
-
: choosing === "goalRounds" ? " GOAL AUTO ROUNDS" : page === "footer" ? " FOOTER VISIBILITY" : page === "pi" ? " PI & WORKFLOWS" : " APPEARANCE & MOTION")),
|
|
181
|
+
: choosing === "goalRounds" ? " GOAL AUTO ROUNDS" : page === "footer" ? " FOOTER VISIBILITY" : page === "pi" ? " PI & WORKFLOWS" : page === "profiles" ? " PROFILES" : " APPEARANCE & MOTION")),
|
|
167
182
|
line(theme.fg("dim", explainPool() ? " Live pool includes idle/paused subagents"
|
|
168
183
|
: choosing ? " Select a value; Enter/click to save, Esc to cancel"
|
|
169
184
|
: page === "footer" ? ` Footer fields ${start + 1}–${start + rows.length}/${fields()}`
|
|
170
|
-
: page === "pi" ? " Fullscreen mode enables clicks and copy-on-select"
|
|
185
|
+
: page === "pi" ? " Fullscreen mode enables clicks and copy-on-select"
|
|
186
|
+
: page === "profiles" ? " Switch only in a fresh, idle session; roles via /roles" : " Accent cycles through loaded themes")),
|
|
171
187
|
...rows,
|
|
172
188
|
line(theme.fg("dim", choosing ? " ↑↓: choose · Enter/click: save · Esc: cancel"
|
|
173
189
|
: " Tab: section · ↑↓: choose · Enter/click: change · Esc")),
|
|
@@ -176,4 +192,10 @@ export async function openJarSettings(
|
|
|
176
192
|
}
|
|
177
193
|
};
|
|
178
194
|
});
|
|
195
|
+
if (!action || !profiles) return;
|
|
196
|
+
try { await profiles.open(action); }
|
|
197
|
+
catch (error) { ctx.ui.notify("Could not change profile: " + (error as Error).message, "error"); }
|
|
198
|
+
// A new profile hands off to the roles UI; reopening settings over it would bury that step.
|
|
199
|
+
if (action === "create") return;
|
|
200
|
+
}
|
|
179
201
|
}
|