pi-jar 0.1.8 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -13
- package/docs/DISCUSSION_BROKER_PLAN.md +333 -0
- package/docs/INTEGRATIONS.md +1 -1
- package/docs/LONG_SESSION_PERFORMANCE_PLAN.md +731 -0
- package/docs/SUBAGENT-TOOLS.md +2 -0
- package/extensions/index.ts +304 -76
- package/package.json +2 -2
- package/src/activity-view.ts +10 -6
- package/src/advisor.ts +226 -73
- package/src/composer.ts +21 -4
- package/src/context-diet.ts +94 -0
- package/src/delegate-worktree.ts +33 -13
- package/src/delegate.ts +736 -429
- package/src/democracy.ts +6 -4
- package/src/discussion-hub.ts +418 -0
- package/src/discussion.ts +419 -187
- package/src/footer-settings.ts +3 -29
- package/src/model-roles.ts +29 -9
- package/src/perf.ts +419 -0
- package/src/profile-wizard.ts +61 -0
- package/src/profiles.ts +148 -0
- package/src/quota.ts +65 -25
- package/src/settings-ui.ts +46 -14
- package/src/settings.ts +6 -13
- package/src/shell-notifications.ts +33 -6
- package/src/shells.ts +138 -29
- package/src/side-model.ts +3 -1
- package/src/welcome.ts +6 -1
package/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/quota.ts
CHANGED
|
@@ -3,8 +3,22 @@ export interface QuotaWindow { used: number; resetsAt?: number }
|
|
|
3
3
|
export interface Quota { fiveHour?: QuotaWindow; week?: QuotaWindow }
|
|
4
4
|
|
|
5
5
|
export const QUOTA_TTL_MS = 5 * 60_000;
|
|
6
|
+
/** Consecutive failures double the retry delay from the TTL up to this, so a broken lookup cannot hot-loop. */
|
|
7
|
+
export const QUOTA_MAX_BACKOFF_MS = 30 * 60_000;
|
|
6
8
|
export const QUOTA_PREFIX = "pi-jar.quota.";
|
|
7
9
|
|
|
10
|
+
export interface QuotaProviderStats {
|
|
11
|
+
provider: QuotaProvider;
|
|
12
|
+
pending: boolean;
|
|
13
|
+
/** Consecutive failed lookups. */
|
|
14
|
+
failures: number;
|
|
15
|
+
/** Last settled lookup, if any: whether it produced a value, how long ago it started, when the next may start. */
|
|
16
|
+
ok?: boolean;
|
|
17
|
+
ageMs?: number;
|
|
18
|
+
retryInMs?: number;
|
|
19
|
+
}
|
|
20
|
+
export interface QuotaStats { enabled: boolean; requests: number; failures: number; providers: QuotaProviderStats[] }
|
|
21
|
+
|
|
8
22
|
function windowValue(value: unknown): QuotaWindow | undefined {
|
|
9
23
|
if (!value || typeof value !== "object") return undefined;
|
|
10
24
|
const item = value as Record<string, unknown>;
|
|
@@ -108,10 +122,16 @@ export async function fetchQuota(provider: QuotaProvider, resolveAuth: (provider
|
|
|
108
122
|
} catch { return undefined; }
|
|
109
123
|
}
|
|
110
124
|
|
|
111
|
-
/**
|
|
125
|
+
/**
|
|
126
|
+
* One in-flight request per provider; failures are negative-cached with exponential backoff;
|
|
127
|
+
* expired values are never shown. Reads never start IO: renders call `get` on every frame, and
|
|
128
|
+
* lookups start only from `refresh` at session boundaries.
|
|
129
|
+
*/
|
|
112
130
|
export class QuotaCache {
|
|
113
|
-
private entries = new Map<QuotaProvider, { value?: Quota; expiresAt: number }>();
|
|
131
|
+
private entries = new Map<QuotaProvider, { value?: Quota; requestedAt: number; expiresAt: number; failures: number }>();
|
|
114
132
|
private pending = new Map<QuotaProvider, AbortController>();
|
|
133
|
+
private requests = 0;
|
|
134
|
+
private failures = 0;
|
|
115
135
|
enabled = false;
|
|
116
136
|
private readonly fetcher: (provider: QuotaProvider, signal: AbortSignal) => Promise<Quota | undefined>;
|
|
117
137
|
private readonly changed: () => void;
|
|
@@ -122,34 +142,54 @@ export class QuotaCache {
|
|
|
122
142
|
get(provider: string | undefined, statuses: ReadonlyMap<string, string>, now: number): Quota | undefined {
|
|
123
143
|
if (provider !== "openai-codex" && provider !== "anthropic") return undefined;
|
|
124
144
|
const published = publishedQuota(statuses, provider, now);
|
|
125
|
-
if (published)
|
|
145
|
+
if (published) return published;
|
|
146
|
+
if (!this.enabled) return undefined;
|
|
147
|
+
const cached = this.entries.get(provider);
|
|
148
|
+
return cached && cached.expiresAt > now ? cached.value : undefined;
|
|
149
|
+
}
|
|
150
|
+
/** Start one background lookup when nothing fresh is cached and no retry delay is pending; true if it started. */
|
|
151
|
+
refresh(provider: string | undefined, statuses: ReadonlyMap<string, string>, now: number): boolean {
|
|
152
|
+
if (provider !== "openai-codex" && provider !== "anthropic" || !this.enabled) return false;
|
|
153
|
+
if (publishedQuota(statuses, provider, now)) {
|
|
154
|
+
// A publisher is authoritative; an authenticated fallback would only duplicate it.
|
|
126
155
|
this.pending.get(provider)?.abort();
|
|
127
156
|
this.pending.delete(provider);
|
|
128
|
-
return
|
|
157
|
+
return false;
|
|
129
158
|
}
|
|
130
|
-
if (!this.enabled) return undefined;
|
|
131
159
|
const cached = this.entries.get(provider);
|
|
132
|
-
if (cached && cached.expiresAt > now) return
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
160
|
+
if (cached && cached.expiresAt > now || this.pending.has(provider)) return false;
|
|
161
|
+
const controller = new AbortController();
|
|
162
|
+
this.pending.set(provider, controller);
|
|
163
|
+
this.requests++;
|
|
164
|
+
// A microtask keeps IO outside the caller's synchronous event handler.
|
|
165
|
+
queueMicrotask(() => {
|
|
166
|
+
if (controller.signal.aborted) {
|
|
167
|
+
if (this.pending.get(provider) === controller) this.pending.delete(provider);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
// A rejected lookup is a failure like an empty one: both back off instead of retrying at once.
|
|
171
|
+
void this.fetcher(provider, controller.signal).catch(() => undefined).then((value) => {
|
|
172
|
+
if (controller.signal.aborted) return;
|
|
173
|
+
const failures = value ? 0 : (this.entries.get(provider)?.failures ?? 0) + 1;
|
|
174
|
+
if (!value) this.failures++;
|
|
175
|
+
const ttl = value ? QUOTA_TTL_MS : Math.min(QUOTA_MAX_BACKOFF_MS, QUOTA_TTL_MS * 2 ** (failures - 1));
|
|
176
|
+
this.entries.set(provider, { ...(value ? { value } : {}), requestedAt: now, expiresAt: now + ttl, failures });
|
|
177
|
+
// An expired value was already hidden, so only a new value changes what renders show.
|
|
178
|
+
if (value) this.changed();
|
|
179
|
+
}).finally(() => {
|
|
180
|
+
if (this.pending.get(provider) === controller) this.pending.delete(provider);
|
|
150
181
|
});
|
|
151
|
-
}
|
|
152
|
-
return
|
|
182
|
+
});
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
/** Diagnostics: per-provider cache age and retry delay, computed on demand. */
|
|
186
|
+
stats(now = Date.now()): QuotaStats {
|
|
187
|
+
const providers = new Set<QuotaProvider>([...this.entries.keys(), ...this.pending.keys()]);
|
|
188
|
+
return { enabled: this.enabled, requests: this.requests, failures: this.failures, providers: [...providers].map((provider) => {
|
|
189
|
+
const entry = this.entries.get(provider);
|
|
190
|
+
return { provider, pending: this.pending.has(provider), failures: entry?.failures ?? 0,
|
|
191
|
+
...(entry ? { ok: entry.value !== undefined, ageMs: now - entry.requestedAt, retryInMs: Math.max(0, entry.expiresAt - now) } : {}) };
|
|
192
|
+
}) };
|
|
153
193
|
}
|
|
154
194
|
stop(): void { for (const controller of this.pending.values()) controller.abort(); this.pending.clear(); this.entries.clear(); }
|
|
155
195
|
}
|
package/src/settings-ui.ts
CHANGED
|
@@ -3,6 +3,9 @@ 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 { createProfileWizard } from "./profile-wizard.ts";
|
|
7
|
+
import type { JarProfile, ProfileStore } from "./profiles.ts";
|
|
8
|
+
import type { RoleConfig, ModelRoleManager } from "./model-roles.ts";
|
|
6
9
|
|
|
7
10
|
const LABELS: Record<FooterField, string> = {
|
|
8
11
|
model: "Model", effort: "Model effort", sessionName: "Session name", cwd: "Working directory",
|
|
@@ -10,8 +13,7 @@ const LABELS: Record<FooterField, string> = {
|
|
|
10
13
|
extras: "Extension statuses", branch: "Git branch"
|
|
11
14
|
};
|
|
12
15
|
|
|
13
|
-
type Page = "appearance" | "footer" | "pi";
|
|
14
|
-
const PAGES: Page[] = ["appearance", "footer", "pi"];
|
|
16
|
+
type Page = "appearance" | "footer" | "pi" | "profiles";
|
|
15
17
|
|
|
16
18
|
/** Pi-level preferences pi-jar can toggle (written to Pi's own settings). */
|
|
17
19
|
export interface PiPreferences {
|
|
@@ -26,18 +28,22 @@ export async function openJarSettings(
|
|
|
26
28
|
current: () => JarVisualSettings,
|
|
27
29
|
update: (settings: JarVisualSettings) => void,
|
|
28
30
|
availableAccents: readonly string[],
|
|
29
|
-
pi?: PiPreferences
|
|
31
|
+
pi?: PiPreferences,
|
|
32
|
+
profiles?: { store: ProfileStore; themes: readonly string[]; roles: () => RoleConfig; roleManager?: ModelRoleManager; create: (name: string, theme: string, settings: JarVisualSettings, roles: RoleConfig) => JarProfile; select(name: string): Promise<void>; setTheme(name: string): void }
|
|
30
33
|
): Promise<void> {
|
|
31
34
|
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
const PAGES: Page[] = profiles ? ["appearance", "footer", "pi", "profiles"] : ["appearance", "footer", "pi"];
|
|
36
|
+
let lastPage: Page = "appearance";
|
|
37
|
+
while (true) {
|
|
38
|
+
const action = await ctx.ui.custom<"create" | "switch" | "theme" | undefined>((tui, theme, _keys, done) => {
|
|
39
|
+
let page: Page = lastPage;
|
|
34
40
|
let selected = 0;
|
|
35
41
|
let width = 64;
|
|
36
42
|
const accents: JarAccent[] = ["follow", ...availableAccents.filter((name): name is JarAccent =>
|
|
37
43
|
name === "default" || ["gray", "pink", "teal", "azure", "violet", "amber"].includes(name))];
|
|
38
44
|
let choosing: "goalRounds" | "maxSubagents" | undefined;
|
|
39
45
|
const choices = () => choosing === "maxSubagents" ? MAX_SUBAGENT_CHOICES : GOAL_ROUND_CHOICES;
|
|
40
|
-
const fields = () => choosing ? choices().length : page === "footer" ? FOOTER_FIELDS.length : page === "pi" ?
|
|
46
|
+
const fields = () => choosing ? choices().length : page === "footer" ? FOOTER_FIELDS.length : page === "pi" ? 7 : page === "profiles" ? 3 : 7;
|
|
41
47
|
const finishChoice = () => { selected = choosing === "maxSubagents" ? 3 : 2; choosing = undefined; };
|
|
42
48
|
const piError = (error: unknown) => ctx.ui.notify("Could not change Pi setting: " + (error as Error).message, "error");
|
|
43
49
|
const explainPool = () => choosing === "maxSubagents" || (page === "pi" && !choosing && selected === 3);
|
|
@@ -50,6 +56,10 @@ export async function openJarSettings(
|
|
|
50
56
|
if (choosing === "maxSubagents") update({ ...state, maxSubagents: MAX_SUBAGENT_CHOICES[index]! });
|
|
51
57
|
else update({ ...state, goalRounds: GOAL_ROUND_CHOICES[index]! });
|
|
52
58
|
finishChoice();
|
|
59
|
+
} else if (page === "profiles") {
|
|
60
|
+
lastPage = page;
|
|
61
|
+
done(index === 0 ? "switch" : index === 1 ? "theme" : "create");
|
|
62
|
+
return;
|
|
53
63
|
} else if (page === "footer") {
|
|
54
64
|
const field = FOOTER_FIELDS[index];
|
|
55
65
|
if (field) update({ ...state, footer: { ...state.footer, [field]: !state.footer[field] } });
|
|
@@ -66,6 +76,7 @@ export async function openJarSettings(
|
|
|
66
76
|
}
|
|
67
77
|
else if (index === 4) update({ ...state, advisor: !state.advisor });
|
|
68
78
|
else if (index === 5) update({ ...state, advisorGates: !state.advisorGates });
|
|
79
|
+
else if (index === 6) update({ ...state, contextDiet: !state.contextDiet });
|
|
69
80
|
} else if (index === 0) {
|
|
70
81
|
const at = Math.max(0, accents.indexOf(state.accent));
|
|
71
82
|
update({ ...state, accent: accents[(at + 1) % accents.length]! });
|
|
@@ -90,7 +101,7 @@ export async function openJarSettings(
|
|
|
90
101
|
invalidate() {},
|
|
91
102
|
handleInput(data: string) {
|
|
92
103
|
if (matchesKey(data, Key.escape) || data === "q") {
|
|
93
|
-
if (choosing) { finishChoice(); tui.requestRender(); } else done();
|
|
104
|
+
if (choosing) { finishChoice(); tui.requestRender(); } else done(undefined);
|
|
94
105
|
return;
|
|
95
106
|
}
|
|
96
107
|
if (choosing && (matchesKey(data, Key.tab) || matchesKey(data, Key.right)
|
|
@@ -112,19 +123,20 @@ export async function openJarSettings(
|
|
|
112
123
|
tui.requestRender(); return { handled: true };
|
|
113
124
|
}
|
|
114
125
|
if (event.type !== "click" || event.button !== "left") return;
|
|
115
|
-
if (event.y === 0 && event.x >= width - 4) { done(); return { handled: true }; }
|
|
126
|
+
if (event.y === 0 && event.x >= width - 4) { done(undefined); return { handled: true }; }
|
|
116
127
|
if (event.y === 1) {
|
|
117
128
|
choosing = undefined;
|
|
118
129
|
if (event.x >= 2 && event.x < Math.min(width - 2, 19)) { page = "appearance"; selected = 0; }
|
|
119
130
|
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; }
|
|
131
|
+
else if (event.x >= 32 && event.x < Math.min(width - 2, 41)) { page = "pi"; selected = 0; }
|
|
132
|
+
else if (profiles && event.x >= 41 && event.x < width - 2) { page = "profiles"; selected = 0; }
|
|
121
133
|
else return;
|
|
122
134
|
tui.requestRender(); return { handled: true, focus: true };
|
|
123
135
|
}
|
|
124
136
|
if (event.y >= 5 && event.y < 5 + visibleCount() && event.x >= 2 && event.x < width - 2) {
|
|
125
137
|
selected = firstVisible() + event.y - 5; apply(selected); return { handled: true, focus: true };
|
|
126
138
|
}
|
|
127
|
-
if (event.y === 5 + visibleCount() && event.x >= 2 && event.x < width - 2) { done(); return { handled: true }; }
|
|
139
|
+
if (event.y === 5 + visibleCount() && event.x >= 2 && event.x < width - 2) { done(undefined); return { handled: true }; }
|
|
128
140
|
},
|
|
129
141
|
render(available: number): string[] {
|
|
130
142
|
width = Math.max(8, available);
|
|
@@ -136,7 +148,8 @@ export async function openJarSettings(
|
|
|
136
148
|
+ "─".repeat(Math.max(0, width - 4 - visibleWidth(title))) + "×╮");
|
|
137
149
|
const tabs = line(theme.fg(page === "appearance" ? "accent" : "muted", "[ Appearance ]")
|
|
138
150
|
+ " " + theme.fg(page === "footer" ? "accent" : "muted", "[ Footer ]")
|
|
139
|
-
+ " " + theme.fg(page === "pi" ? "accent" : "muted", "[ Pi ]")
|
|
151
|
+
+ " " + theme.fg(page === "pi" ? "accent" : "muted", "[ Pi ]")
|
|
152
|
+
+ (profiles ? " " + theme.fg(page === "profiles" ? "accent" : "muted", "[ Profiles ]") : ""));
|
|
140
153
|
const prefs = pi?.get();
|
|
141
154
|
const divider = theme.fg("dim", "├" + "─".repeat(Math.max(0, width - 2)) + "┤");
|
|
142
155
|
const allRows = choosing
|
|
@@ -149,7 +162,12 @@ export async function openJarSettings(
|
|
|
149
162
|
row(2, "Goal auto rounds", String(state.goalRounds), true),
|
|
150
163
|
row(3, "Max subagents (retained live pool)", String(state.maxSubagents), true),
|
|
151
164
|
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)
|
|
165
|
+
row(5, "Advisor gates (loops, repeated failures)", state.advisorGates ? "ON" : "OFF", state.advisorGates),
|
|
166
|
+
row(6, "Context diet (prior-turn reasoning)", state.contextDiet ? "ON" : "OFF", state.contextDiet)
|
|
167
|
+
] : page === "profiles" ? [
|
|
168
|
+
row(0, "Active profile", profiles!.store.activeName, true),
|
|
169
|
+
row(1, "Theme", profiles!.store.active().theme, true),
|
|
170
|
+
row(2, "Create profile…", "OPEN", true)
|
|
153
171
|
] : [
|
|
154
172
|
row(0, "Accent", state.accent === "follow" ? "FOLLOW PI" : state.accent.toUpperCase(), true),
|
|
155
173
|
row(1, "Motion", state.animations ? "ON" : "OFF", state.animations),
|
|
@@ -163,11 +181,12 @@ export async function openJarSettings(
|
|
|
163
181
|
const rows = allRows.slice(start, start + pageSize());
|
|
164
182
|
return [top, tabs, divider,
|
|
165
183
|
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")),
|
|
184
|
+
: choosing === "goalRounds" ? " GOAL AUTO ROUNDS" : page === "footer" ? " FOOTER VISIBILITY" : page === "pi" ? " PI & WORKFLOWS" : page === "profiles" ? " PROFILES" : " APPEARANCE & MOTION")),
|
|
167
185
|
line(theme.fg("dim", explainPool() ? " Live pool includes idle/paused subagents"
|
|
168
186
|
: choosing ? " Select a value; Enter/click to save, Esc to cancel"
|
|
169
187
|
: page === "footer" ? ` Footer fields ${start + 1}–${start + rows.length}/${fields()}`
|
|
170
|
-
: page === "pi" ? " Fullscreen mode enables clicks and copy-on-select"
|
|
188
|
+
: page === "pi" ? " Fullscreen mode enables clicks and copy-on-select"
|
|
189
|
+
: page === "profiles" ? " Switch only in a fresh, idle session" : " Accent cycles through loaded themes")),
|
|
171
190
|
...rows,
|
|
172
191
|
line(theme.fg("dim", choosing ? " ↑↓: choose · Enter/click: save · Esc: cancel"
|
|
173
192
|
: " Tab: section · ↑↓: choose · Enter/click: change · Esc")),
|
|
@@ -176,4 +195,17 @@ export async function openJarSettings(
|
|
|
176
195
|
}
|
|
177
196
|
};
|
|
178
197
|
});
|
|
198
|
+
if (!action || !profiles) return;
|
|
199
|
+
try {
|
|
200
|
+
if (action === "create") {
|
|
201
|
+
await createProfileWizard(ctx, profiles.store, { themes: profiles.themes, settings: { ...current(), accent: "follow" }, roles: profiles.roles(), roleManager: profiles.roleManager, create: profiles.create });
|
|
202
|
+
} else if (action === "switch") {
|
|
203
|
+
const name = await ctx.ui.select("Choose a profile", profiles.store.list().map((profile) => profile.name));
|
|
204
|
+
if (name) await profiles.select(name);
|
|
205
|
+
} else {
|
|
206
|
+
const name = await ctx.ui.select("Profile theme", ["Follow Pi", ...profiles.themes.filter((name) => name !== "follow")]);
|
|
207
|
+
if (name) profiles.setTheme(name === "Follow Pi" ? "follow" : name);
|
|
208
|
+
}
|
|
209
|
+
} catch (error) { ctx.ui.notify("Could not change profile: " + (error as Error).message, "error"); }
|
|
210
|
+
}
|
|
179
211
|
}
|
package/src/settings.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { ACCENT_NAMES } from "./accent.ts";
|
|
4
|
-
import { DEFAULT_FOOTER_SETTINGS, FOOTER_FIELDS,
|
|
4
|
+
import { DEFAULT_FOOTER_SETTINGS, FOOTER_FIELDS, type FooterSettings } from "./footer-settings.ts";
|
|
5
5
|
import { ICON_SETS, type IconSet } from "./icons.ts";
|
|
6
6
|
|
|
7
7
|
export type JarAccent = "follow" | "default" | (typeof ACCENT_NAMES)[number];
|
|
@@ -23,6 +23,8 @@ export interface JarVisualSettings {
|
|
|
23
23
|
advisor: boolean;
|
|
24
24
|
/** Consult the advisor automatically when the agent repeats a tool call or keeps failing. */
|
|
25
25
|
advisorGates: boolean;
|
|
26
|
+
/** Opt-in provider-context pruning of reasoning from completed earlier user turns. */
|
|
27
|
+
contextDiet: boolean;
|
|
26
28
|
/** Glyph set for footer, composer and views: unicode, Nerd Font icons, or plain ascii. */
|
|
27
29
|
icons: IconSet;
|
|
28
30
|
footer: FooterSettings;
|
|
@@ -37,7 +39,7 @@ export function isMaxSubagents(value: unknown): value is MaxSubagents {
|
|
|
37
39
|
|
|
38
40
|
export const SETTINGS_FILE = "pi-jar-settings.json";
|
|
39
41
|
export function defaultVisualSettings(footer: FooterSettings = DEFAULT_FOOTER_SETTINGS): JarVisualSettings {
|
|
40
|
-
return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, maxSubagents: 4, advisor: true, advisorGates: true, icons: "unicode", footer: { ...footer } };
|
|
42
|
+
return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, maxSubagents: 4, advisor: true, advisorGates: true, contextDiet: false, icons: "unicode", footer: { ...footer } };
|
|
41
43
|
}
|
|
42
44
|
|
|
43
45
|
/** Legacy footer choices are imported only while the new file is absent. Never modify the old file. */
|
|
@@ -45,7 +47,7 @@ export function loadVisualSettings(directory: string): JarVisualSettings {
|
|
|
45
47
|
let text: string;
|
|
46
48
|
try { text = readFileSync(join(directory, SETTINGS_FILE), "utf8"); }
|
|
47
49
|
catch (error) {
|
|
48
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return defaultVisualSettings(
|
|
50
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return defaultVisualSettings();
|
|
49
51
|
return defaultVisualSettings();
|
|
50
52
|
}
|
|
51
53
|
let raw: unknown;
|
|
@@ -70,22 +72,13 @@ export function loadVisualSettings(directory: string): JarVisualSettings {
|
|
|
70
72
|
maxSubagents: isMaxSubagents(value.maxSubagents) ? value.maxSubagents : fallback.maxSubagents,
|
|
71
73
|
advisor: typeof value.advisor === "boolean" ? value.advisor : fallback.advisor,
|
|
72
74
|
advisorGates: typeof value.advisorGates === "boolean" ? value.advisorGates : fallback.advisorGates,
|
|
75
|
+
contextDiet: typeof value.contextDiet === "boolean" ? value.contextDiet : fallback.contextDiet,
|
|
73
76
|
icons: ICON_SETS.some((set) => set === value.icons) ? value.icons as IconSet : fallback.icons,
|
|
74
77
|
footer: Object.fromEntries(FOOTER_FIELDS.map((field) => [field,
|
|
75
78
|
typeof footer[field] === "boolean" ? footer[field] : fallback.footer[field]])) as FooterSettings
|
|
76
79
|
};
|
|
77
80
|
}
|
|
78
81
|
|
|
79
|
-
export function migrateLegacySettings(directory: string): boolean {
|
|
80
|
-
if (existsSync(join(directory, SETTINGS_FILE)) || !existsSync(join(directory, "pi-jar-footer.json"))) return false;
|
|
81
|
-
try {
|
|
82
|
-
const legacy: unknown = JSON.parse(readFileSync(join(directory, "pi-jar-footer.json"), "utf8"));
|
|
83
|
-
if (!legacy || typeof legacy !== "object" || Array.isArray(legacy)) return false;
|
|
84
|
-
saveVisualSettings(directory, defaultVisualSettings(loadFooterSettings(directory)));
|
|
85
|
-
return true;
|
|
86
|
-
} catch { return false; } // Leave malformed legacy preferences untouched for manual repair.
|
|
87
|
-
}
|
|
88
|
-
|
|
89
82
|
export function saveVisualSettings(directory: string, settings: JarVisualSettings): void {
|
|
90
83
|
mkdirSync(directory, { recursive: true });
|
|
91
84
|
const target = join(directory, SETTINGS_FILE);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionAPI, CustomMessageEntryDraft } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, CustomMessageEntryDraft } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Text } from "@earendil-works/pi-tui";
|
|
3
3
|
import { cleanText } from "./status.ts";
|
|
4
4
|
import { describe, SHELL_MESSAGE, type ShellEvent, type ShellManager } from "./shells.ts";
|
|
@@ -34,14 +34,40 @@ export function registerShellNotifications(pi: ExtensionAPI, manager: () => Shel
|
|
|
34
34
|
let interrupted = false;
|
|
35
35
|
let deliveryFailures = 0;
|
|
36
36
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
37
|
+
let feedbackTimer: ReturnType<typeof setTimeout> | undefined;
|
|
38
|
+
let context: ExtensionContext | undefined;
|
|
39
|
+
const shown = new Set<string>();
|
|
40
|
+
const eventKey = (event: ShellEvent) => `${event.kind}:${event.job.id}`;
|
|
37
41
|
const warned = new Set<string>();
|
|
38
|
-
const cancelTimer = () => { clearTimeout(timer); timer = undefined; };
|
|
42
|
+
const cancelTimer = () => { clearTimeout(timer); timer = undefined; clearTimeout(feedbackTimer); feedbackTimer = undefined; };
|
|
39
43
|
const take = () => {
|
|
40
44
|
cancelTimer();
|
|
41
45
|
const events = manager()?.takeNotifications() ?? [];
|
|
46
|
+
shown.clear();
|
|
42
47
|
return events.length ? shellNotification(events) : undefined;
|
|
43
48
|
};
|
|
44
49
|
const notify = () => {
|
|
50
|
+
// UI feedback must not wait for the remote model to finish reasoning. Keep model transport
|
|
51
|
+
// boundary-safe and acknowledgeable: this preview does not consume or enqueue a follow-up.
|
|
52
|
+
if (active && !interrupted && context?.hasUI && !feedbackTimer && manager()?.pendingNotifications()) {
|
|
53
|
+
const owner = manager(), ctx = context;
|
|
54
|
+
feedbackTimer = setTimeout(() => {
|
|
55
|
+
feedbackTimer = undefined;
|
|
56
|
+
if (!active || interrupted || owner !== manager() || ctx !== context) return;
|
|
57
|
+
const snapshot = owner?.notificationSnapshot() ?? [];
|
|
58
|
+
const known = new Set(snapshot.map(eventKey));
|
|
59
|
+
for (const key of shown) if (!known.has(key)) shown.delete(key);
|
|
60
|
+
const events = snapshot.filter(event => !shown.has(eventKey(event)));
|
|
61
|
+
if (!events.length) return;
|
|
62
|
+
const summary = shellNotification(events).details as ShellNotificationDetails;
|
|
63
|
+
const text = `${summary.summary}: ${events.slice(0, 3).map(event => describe(event.job)).join("; ")}. Model delivery waits for a safe turn boundary.`;
|
|
64
|
+
try {
|
|
65
|
+
ctx.ui.notify(cleanText(text, 420), events.some(event => event.job.exitCode !== 0 && event.kind === "exit") ? "warning" : "info");
|
|
66
|
+
for (const event of events) shown.add(eventKey(event));
|
|
67
|
+
} catch (error) { console.error("pi-jar: could not show shell completion", error); }
|
|
68
|
+
}, 300);
|
|
69
|
+
feedbackTimer.unref?.();
|
|
70
|
+
}
|
|
45
71
|
if (active || interrupted || timer || !manager()?.pendingNotifications()) return;
|
|
46
72
|
const owner = manager();
|
|
47
73
|
timer = setTimeout(() => {
|
|
@@ -59,16 +85,17 @@ export function registerShellNotifications(pi: ExtensionAPI, manager: () => Shel
|
|
|
59
85
|
}, 300);
|
|
60
86
|
timer.unref?.();
|
|
61
87
|
};
|
|
62
|
-
const reset = () => { cancelTimer(); active = false; interrupted = false; deliveryFailures = 0; warned.clear(); };
|
|
88
|
+
const reset = () => { cancelTimer(); active = false; interrupted = false; context = undefined; deliveryFailures = 0; warned.clear(); shown.clear(); };
|
|
63
89
|
|
|
64
90
|
pi.registerMessageRenderer?.<ShellNotificationDetails>(SHELL_MESSAGE, (message, { expanded }, theme) => {
|
|
65
91
|
const text = typeof message.content === "string" ? message.content : "Background shells";
|
|
66
92
|
const summary = message.details?.summary ?? text.split("\n", 1)[0] ?? "Background shells";
|
|
67
93
|
return new Text(expanded ? text : theme.fg("accent", cleanText(summary, 160)) + theme.fg("dim", " · expand for status"), 0, 0);
|
|
68
94
|
});
|
|
69
|
-
pi.on?.("session_start", reset);
|
|
95
|
+
pi.on?.("session_start", (_event, ctx) => { reset(); context = ctx; });
|
|
70
96
|
pi.on?.("session_shutdown", reset);
|
|
71
|
-
pi.on?.("before_agent_start", () => {
|
|
97
|
+
pi.on?.("before_agent_start", (_event, ctx) => {
|
|
98
|
+
context = ctx;
|
|
72
99
|
active = true;
|
|
73
100
|
interrupted = false;
|
|
74
101
|
const entry = take();
|
|
@@ -76,7 +103,7 @@ export function registerShellNotifications(pi: ExtensionAPI, manager: () => Shel
|
|
|
76
103
|
const { type: _type, ...message } = entry;
|
|
77
104
|
return { message };
|
|
78
105
|
});
|
|
79
|
-
pi.on?.("agent_start", () => { active = true; cancelTimer(); });
|
|
106
|
+
pi.on?.("agent_start", (_event, ctx) => { context = ctx; active = true; cancelTimer(); });
|
|
80
107
|
pi.on?.("turn_end", event => {
|
|
81
108
|
if (event.outcome !== "completed" || event.context?.canContinue === false) return;
|
|
82
109
|
const entry = take();
|