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.
@@ -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
- /** One in-flight request per provider, negative-cache failures, never show expired values. */
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 published;
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 cached.value;
133
- if (!this.pending.has(provider)) {
134
- const controller = new AbortController();
135
- this.pending.set(provider, controller);
136
- // A microtask keeps IO outside the synchronous render cycle.
137
- queueMicrotask(() => {
138
- if (controller.signal.aborted) {
139
- if (this.pending.get(provider) === controller) this.pending.delete(provider);
140
- return;
141
- }
142
- void this.fetcher(provider, controller.signal).then((value) => {
143
- if (!controller.signal.aborted) {
144
- this.entries.set(provider, { value, expiresAt: Date.now() + QUOTA_TTL_MS });
145
- this.changed();
146
- }
147
- }).catch(() => {}).finally(() => {
148
- if (this.pending.get(provider) === controller) this.pending.delete(provider);
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 undefined;
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
  }
@@ -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
- await ctx.ui.custom<void>((tui, theme, _keys, done) => {
33
- let page: Page = "appearance";
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" ? 6 : 7;
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" : " Accent cycles through loaded themes")),
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, loadFooterSettings, type FooterSettings } from "./footer-settings.ts";
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(loadFooterSettings(directory));
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();