pi-jar 0.1.9 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-jar
2
2
 
3
- A warm, role-aware TUI and workflow extension for [Pi](https://github.com/earendil-works/pi). pi-jar turns the terminal into a more expressive agent workspace with a living pixel-fire welcome, an adaptive composer, first-class **plan**, **goal** and **role** workflows, tracked tasks, reviewable changes, background shells and parallel subagents.
3
+ A warm, role-aware TUI and workflow extension for [Pi](https://github.com/earendil-works/pi). pi-jar turns the terminal into a more expressive agent workspace with a living pixel-fire welcome, an adaptive composer, switchable **profiles**, first-class **plan**, **goal** and **role** workflows, tracked tasks, reviewable changes, background shells and parallel subagents.
4
4
 
5
5
  > Works with the current `@earendil-works/*` Pi API. Pi's core packages are peer dependencies (`"*"`), so pi-jar always runs against the Pi you have installed; it is tested against the latest release (0.87.x).
6
6
 
@@ -24,7 +24,7 @@ The showcase is captured from a real pi-jar terminal session: the torch-style `
24
24
  | **Commit** | `/jar commit [note]` drafts a message for the staged changes with the `commit` role, lets you edit it, then commits (never pushes). |
25
25
  | **Tasks & questions** | A Claude/omp-style `jar_todo` checklist with subtasks and per-task progress that the agent maintains itself, and `jar_ask` structured questions with options, multi-select and free-form answers. |
26
26
  | **Change review** | Every file the agent or its editing subagents change is remembered as it was; `/diff` (<kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>D</kbd>) shows changed files (each counted once) next to a colored diff, with accept or revert per file or all at once. |
27
- | **Background shells** | `jar_shell` runs dev servers, watchers and long tests in the background; the agent is woken when a watch pattern matches or the process exits, so it never polls. Running shells get a footer row; click it (or <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>A</kbd>) to tail or kill them. |
27
+ | **Background shells** | `jar_shell` runs dev servers, watchers and long tests in the background; watch/exit events wake an idle agent or reach it at the next safe turn boundary. Completion status appears immediately in the UI even during model reasoning. Running shells get a footer row; click it (or <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>A</kbd>) to tail or kill them. |
28
28
  | **Subagents** | The main agent becomes a moderator over a configurable retained pool (2, 4, 6, 8 or 16; default 4). `jar_delegate` starts `scout`, `fork` or sandboxed `worktree` agents and returns immediately, so the main agent can keep responding to user steering; Pi RPC event-bus messages deliver initial and resumed turn completions without polling. `jar_subagent` peeks, steers, asks BTW questions, pauses, resumes and stops them. Workers can exchange bounded structured Q/A through `jar_discuss`; worktree changes stay isolated until stop, then safely enter `/diff`. |
29
29
  | **Sessions & history** | Recent sessions (with their goal and plan) on the welcome card, one click from resuming; <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>H</kbd> searches earlier prompts; pasted images show as chips under the composer. |
30
30
  | **Footer & themes** | Responsive footer (model, effort, session, cwd, context, RAM, cost, quota, goal, roles, branch, live subagents and shells) with unicode, [Nerd Font](https://www.nerdfonts.com) or ascii icons, and seven dark themes. |
@@ -79,6 +79,7 @@ Pi only delivers mouse events in its **fullscreen** TUI mode. In regular mode th
79
79
  | Shortcut | Action |
80
80
  | --- | --- |
81
81
  | <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>S</kbd> | Open pi-jar settings |
82
+ | <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Tab</kbd> | Cycle profiles (theme and model roles) |
82
83
  | <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>A</kbd> | Subagents and background shells (activity view) |
83
84
  | <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>R</kbd> | Refresh the welcome (new message and flame), or show it again |
84
85
  | <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>P</kbd> | Toggle plan mode |
@@ -87,6 +88,14 @@ Pi only delivers mouse events in its **fullscreen** TUI mode. In regular mode th
87
88
  | <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>H</kbd> | Search earlier prompts into the composer |
88
89
  | <kbd>Tab</kbd> / <kbd>→</kbd> in an empty composer | Accept the dim suggestion into the input (it is not sent) |
89
90
 
91
+ ## Profiles
92
+
93
+ Profiles keep separate themes, appearance settings, and model-role configurations. **Default** preserves your existing setup.
94
+
95
+ Open `/jar settings` (or **Ctrl+Alt+S**) and choose **Profiles** to create or select a custom profile, or change its theme. The wizard lets you name it, choose its theme, configure roles, and review before saving. Cancelling leaves your existing setup unchanged.
96
+
97
+ Press **Ctrl+Shift+Tab** to cycle profiles **only in a fresh, idle session**. Once the first prompt is sent, the session's profile is locked—even after the agent finishes—and restored when you resume it. Open a new session to switch. Active agents, subagents, and plan/goal workflows also block switching. Configure the selected profile's roles through `/roles`; changing one profile does not change another. The selected profile is remembered across restarts and shown on the welcome screen and beside the composer mascot: `mascot · (Profile) session_name`. Some terminals require an extended keyboard protocol to distinguish Ctrl+Shift+Tab.
98
+
90
99
  ## Welcome screen
91
100
 
92
101
  The welcome stays until your first prompt, then dissolves (or hides immediately with motion off).
@@ -115,7 +124,7 @@ The welcome stays until your first prompt, then dissolves (or hides immediately
115
124
  | proud | `(★ᴗ★)` | a goal was completed |
116
125
  | poked | `(^o^)` | you clicked it |
117
126
 
118
- The title also shows Pi's session name (or a stable readable alias like `silver-lantern`). Toggle the mascot in settings; `/jar composer off` restores Pi's editor with your draft intact.
127
+ The title also shows the active profile in parentheses, then Pi's session name (or a stable readable alias like `silver-lantern`): `mascot · (Default) silver-lantern`. Toggle the mascot in settings; `/jar composer off` restores Pi's editor with your draft intact.
119
128
 
120
129
  ## Plan mode
121
130
 
@@ -213,7 +222,7 @@ Roles map a purpose to a model. They live in `~/.pi/agent/pi-jar-roles.json` (Pi
213
222
 
214
223
  The advisor is a second model (the `advisor` role; the current model if unassigned) that sees a fresh view of the recent conversation plus `git status` and a diff stat, but cannot run tools.
215
224
 
216
- - **`jar_advisor({ question?, draft? })`** — the agent is told to use it before risky or hard-to-reverse choices, after two failed attempts, and before declaring complex work done; it passes its own candidate as `draft`. Available in plan mode too.
225
+ - **`jar_advisor({ question?, draft? })`** — starts a background second opinion and immediately returns a request ID. Use `action: "status"`, `"get"`, bounded `"wait"` (at most 30 seconds), or `"cancel"` with that ID; a pending receipt is **not** reviewed approval. Advice arrives at a safe model boundary or wakes an idle agent, with immediate UI completion feedback. Requests have a 60-second deadline and bounded context; `/advisor` and automatic stuck-work gates are non-blocking too. Get completed advice before consequential decisions. Available in plan mode too.
217
226
  - **`/advisor [focus]`** — ask on demand; the answer is added to the conversation (visible) for the agent's next turn.
218
227
  - **Gates** — when the agent makes the same tool call three times within its last eight calls, the call is blocked and the advisor's review is returned instead; after three failing tool results in a row, the advice is steered into the running turn. At most two automatic consultations per prompt.
219
228
  - Settings → Pi toggles the advisor and the gates. Advisor calls are counted in `/usage`. The footer and welcome show it as a working teammate while it thinks.
@@ -256,7 +265,8 @@ This is a pi-jar enhancement: [pi-advisor](https://github.com/philipbrembeck/pi-
256
265
  Use `parent` with `append` to add a genuinely new subtask to active work; `start`, `done`, `open`, `edit`, `list`, `add` and `delete` remain available.
257
266
  - **`jar_ask`** — structured questions: numbered options with descriptions, single or multi-select, *Type your own answer* (multi-line, paste-friendly) and *Chat about this* to discuss before choosing.
258
267
  - **`/diff`** — review what the agent (and its editing subagents) changed since the first edit to each file: files with `+/−` counts on the left, a full-path header and numbered, colored diff on the right. Changed lines get a colored rail and tinted background, replaced lines highlight the changed words, code is syntax-highlighted for known languages, and collapsed regions are labeled `┄ N unchanged lines ┄`. `/` filters files; `t` switches unified and aligned side-by-side views (unified below 70 columns), `←`/`→` (or `[`/`]`) change the context from 0 lines to the whole file, `w` wraps long lines, and `n`/`p`, `Home`/`End` navigate. Large changes offer summary or explicit windowed review (`v`), with clearly labeled coarse fallback and existing safety limits—not arbitrary-file streaming. See [diff review](docs/DIFF-REVIEW.md). `a` accept (keep, stop tracking), `r` then `y` revert (restore the original, or remove a file it created), `A`/`R` for all. The footer shows `± N files · /diff` while anything is unreviewed; each file counts once no matter who edited it. Only `edit`/`write` changes inside the project are tracked; shell-made changes are not.
259
- - **`jar_shell`** — `start` (optional `name`, `watch` regex, `notify`, task/service `purpose`), `list`, `peek`, bounded `wait`, `output`, `kill`. ANSI-stripped logs have per-job caps and a 4 MiB aggregate budget; surfaced finished jobs compact to a 32 KiB tail. Live jobs retain useful tails and are never killed to reclaim logs. Watch/exit notifications are compact and coalesced; reads acknowledge observed events. Each running shell gets a footer row and an activity view (`/jar shells`) with `x` kill and `f` follow. All shells stop when the session ends.
268
+ - **Context diet (opt-in)** — `/jar settings` → **Pi** → **Context diet (prior-turn reasoning)**. Saved independently per profile; OFF by default. Removes eligible thinking blocks from completed earlier user turns only in outgoing context, never from stored history. Current reasoning signatures, user requests, tool arguments/results, and workflow state remain intact; ambiguous tool chains are left unchanged. Opaque signature character counts are diagnostics, not token savings. Existing SoL-Pi observation packing remains responsible for tool-result projection.
269
+ - **`jar_shell`** — `start` (optional `name`, `watch` regex, `notify`, task/service `purpose`), `list`, `peek`, bounded `wait`, `output`, `kill`. ANSI-stripped logs have per-job caps and a 4 MiB aggregate budget; surfaced finished jobs compact to a 32 KiB tail. Live jobs retain useful tails and are never killed to reclaim logs. Watch/exit notifications are compact and coalesced; reads acknowledge observed events. Active model responses cannot be interrupted by a completion event: the UI previews status without consuming it, and model delivery waits for a safe boundary. When no useful work remains, use bounded `wait` for the relevant finite jobs rather than waiting for an automatic wake-up. Each running shell gets a footer row and an activity view (`/jar shells`) with `x` kill and `f` follow. All shells stop when the session ends.
260
270
  - **`jar_delegate`** — spawns retained, session-scoped subagents up to **Max subagents** in `/jar settings` (2, 4, 6, 8 or 16; default 4). Idle, paused and hibernated agents count toward this pool; over-cap batches are rejected. `scout` is fresh/read-only, `fork` inherits conversation read-only, and `worktree` forks context into a path-guarded Git worktree. Settled retained agents close their Pi process after 30 seconds of quiet while keeping their private session and workspace; `resume`/`ask` relaunch that same session. Launch returns before startup or the first turn completes, including legacy one-shot `write: true` mode.
261
271
  - **`jar_subagent`** — `peek` reports progress, including the workspace path and changed files for recovery records; `steer` redirects a running worker; `resume` relaunches an idle/paused/hibernated agent. `ask`, `pause` and `stop` return accepted receipts immediately; answers and final handoffs arrive as `pi-jar.subagent` events. `stop` reconciles safe worktree changes; conflicts remain private and can be retried with `stop` after resolving parent drift. `discard` removes an unresolved recovery worktree without applying it. At most four active/recovery worktrees are admitted; resolve or discard one before starting another. Session shutdown reports paths of any preserved worktrees. Controls do not poll the child.
262
272
  - **Subagent capabilities** — each task can set `tools` or `inheritTools: true`; known-tool mode filters and child guards remain in force. See [tool capabilities](docs/SUBAGENT-TOOLS.md).
@@ -1,5 +1,5 @@
1
1
  import { getAgentDir, SessionManager, SettingsManager, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { basename } from "node:path";
2
+ import { basename, join } from "node:path";
3
3
  import { execFile } from "node:child_process";
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { Key, truncateToWidth, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
@@ -17,12 +17,14 @@ import { GOAL_ENTRY, GoalStore } from "../src/goals.ts";
17
17
  import { GoalLoop } from "../src/goal-loop.ts";
18
18
  import { FOOTER_FIELDS } from "../src/footer-settings.ts";
19
19
  import { defaultVisualSettings, loadVisualSettings, saveVisualSettings, type JarVisualSettings } from "../src/settings.ts";
20
+ import { ProfileStore, PROFILE_FILE, PROFILE_ENTRY, conversationStarted, pinnedProfileId } from "../src/profiles.ts";
20
21
  import { openJarSettings, type PiPreferences } from "../src/settings-ui.ts";
21
22
  import { pickSession } from "../src/session-ui.ts";
22
23
  import { fetchQuota, QuotaCache, type QuotaProvider } from "../src/quota.ts";
23
24
  import { ModelRoleManager } from "../src/model-roles.ts";
24
25
  import { jarCommit } from "../src/commit.ts";
25
26
  import { registerAdvisor } from "../src/advisor.ts";
27
+ import { registerContextDiet } from "../src/context-diet.ts";
26
28
  import { registerInfoPanels } from "../src/info-panels.ts";
27
29
  import { SideUsage } from "../src/side-model.ts";
28
30
  import { PlanMode } from "../src/plan.ts";
@@ -97,6 +99,7 @@ export default function piJar(pi: ExtensionAPI): void {
97
99
  installCompactBuiltinTools(pi);
98
100
  let demo = false;
99
101
  let visualSettings = defaultVisualSettings();
102
+ let profileStore: ProfileStore | undefined;
100
103
  let animations = visualSettings.animations;
101
104
  let enabled = visualSettings.ui;
102
105
  let disposeFooter: (() => void) | undefined;
@@ -303,10 +306,52 @@ export default function piJar(pi: ExtensionAPI): void {
303
306
  registerAskTool(pi);
304
307
  const modelRoles = new ModelRoleManager(pi);
305
308
  const sideUsage = new SideUsage();
306
- registerAdvisor(pi, modelRoles, { enabled: () => visualSettings.advisor, gates: () => visualSettings.advisorGates, usage: sideUsage });
309
+ registerContextDiet(pi, () => visualSettings.contextDiet === true);
310
+ const advisor = registerAdvisor(pi, modelRoles, { enabled: () => !delegatedChild && visualSettings.advisor, gates: () => !delegatedChild && visualSettings.advisorGates, usage: sideUsage });
307
311
  registerInfoPanels(pi, { side: sideUsage, quotaEnabled: () => quotaCache?.enabled ?? false,
308
312
  quota: (ctx) => quotaCache?.get(ctx.model?.provider, welcomeStatuses(), Date.now()) });
309
- modelRoles.register((ctx) => openRolesUi(ctx, modelRoles), { activateDefault: () => !delegatedChild });
313
+ let followingTheme: string | undefined;
314
+ const profileEntries = (ctx: ExtensionContext): readonly unknown[] => {
315
+ try { return ctx.sessionManager.getEntries?.() ?? sessionBranch(ctx); } catch { return sessionBranch(ctx); }
316
+ };
317
+ const applyProfileTheme = (name: string, ctx: ExtensionContext) => {
318
+ if (!ctx.hasUI || ctx.mode !== "tui" || typeof ctx.ui.setTheme !== "function") return;
319
+ const target = name === "follow" ? followingTheme ?? "system" : name;
320
+ if (!ctx.ui.getTheme?.(target)) throw new Error(`Theme ${target} is not loaded`);
321
+ const result = ctx.ui.setTheme(target);
322
+ if (result && !result.success) throw new Error(result.error ?? `Could not apply theme ${target}`);
323
+ };
324
+ const initializeProfiles = (ctx: ExtensionContext) => {
325
+ const previousTheme = ctx.ui.theme?.name;
326
+ followingTheme ??= previousTheme ?? "system";
327
+ try {
328
+ if (!existsSync(join(getAgentDir(), PROFILE_FILE))
329
+ && (!ctx.hasUI || ctx.mode !== "tui" || typeof ctx.ui.getAllThemes !== "function")) {
330
+ profileStore = undefined; visualSettings = loadVisualSettings(getAgentDir()); return undefined;
331
+ }
332
+ modelRoles.load(ctx.cwd);
333
+ const legacyAccent = loadVisualSettings(getAgentDir()).accent;
334
+ const legacyTheme = accentTheme(legacyAccent);
335
+ const initialTheme = legacyAccent !== "follow" && ctx.ui.getTheme?.(legacyTheme) ? legacyTheme : previousTheme ?? "system";
336
+ const store = new ProfileStore(getAgentDir(), initialTheme, modelRoles.profileRoleConfig());
337
+ const entries = profileEntries(ctx);
338
+ const id = pinnedProfileId(entries) ?? (conversationStarted(entries) ? "default" : store.active().id);
339
+ const profile = store.list().find((item) => item.id === id);
340
+ if (!profile) throw new Error("This session's profile is no longer available");
341
+ applyProfileTheme(profile.theme, ctx);
342
+ if (store.activeName !== profile.name) store.activate(profile.name);
343
+ profileStore = store;
344
+ visualSettings = profile.settings;
345
+ composer.setProfile(profile.name);
346
+ return { roles: profile.roles, profileId: profile.id };
347
+ } catch (error) {
348
+ if (previousTheme) { try { applyProfileTheme(previousTheme, ctx); } catch {} }
349
+ profileStore = undefined; visualSettings = loadVisualSettings(getAgentDir()); composer.setProfile("Default");
350
+ ctx.ui.notify(`Could not load profiles: ${(error as Error).message}`, "warning");
351
+ return undefined;
352
+ }
353
+ };
354
+ modelRoles.register((ctx) => openRolesUi(ctx, modelRoles), { activateDefault: () => !delegatedChild, beforeSessionStart: initializeProfiles });
310
355
  let delegateController: DelegateController | undefined;
311
356
  registerDelegate(pi, modelRoles, subagents, { changes: () => changes, changed: refreshChanges,
312
357
  discussionEnv: (key, name) => discussion?.childEnv(key, name),
@@ -478,7 +523,7 @@ export default function piJar(pi: ExtensionAPI): void {
478
523
  const quota = footerSettings.quota ? quotaCache?.get(ctx.model?.provider, statuses, Date.now()) : undefined;
479
524
  const open = todos?.all().filter((item) => !item.done) ?? [];
480
525
  const lines = welcomeLines(width, welcomeFrame, (color, text) => (ctx.ui.theme ?? theme).fg(color, text), {
481
- ...info, settingsClickable: tui.mode !== "regular", roles: live.roles,
526
+ ...info, model: ctx.model?.id, profile: profileStore?.activeName ?? "Default", settingsClickable: tui.mode !== "regular", roles: live.roles,
482
527
  quota: quota?.week?.used ?? quota?.fiveHour?.used,
483
528
  effort: ctx.model?.reasoning === false ? "off" : pi.getThinkingLevel?.(),
484
529
  ...(modelRoles.activeRole() ? { activeRole: modelRoles.activeRole()! } : {}),
@@ -612,11 +657,26 @@ export default function piJar(pi: ExtensionAPI): void {
612
657
  }
613
658
  };
614
659
 
615
- const applyVisualSettings = (next: JarVisualSettings, ctx: ExtensionContext) => {
660
+ const persistVisualPreferences = (next: JarVisualSettings, ctx: ExtensionContext, theme?: string) => {
661
+ if (profileStore) {
662
+ try { profileStore.update(profileStore.activeName, { settings: next, ...(theme ? { theme } : {}) }); }
663
+ catch (error) { ctx.ui.notify("Profile preferences changed for this session but could not be saved: " + (error as Error).message, "warning"); return; }
664
+ }
665
+ if (!profileStore || profileStore.active().id === "default") {
666
+ try { saveVisualSettings(getAgentDir(), next); }
667
+ catch { ctx.ui.notify("Visual preferences changed for this session but could not be saved", "warning"); }
668
+ }
669
+ };
670
+ const accentTheme = (accent: JarVisualSettings["accent"]) => accent === "follow" ? "follow" : accent === "default" ? "pi-jar-dark" : `pi-jar-dark-${accent}`;
671
+ const applyVisualSettings = (next: JarVisualSettings, ctx: ExtensionContext, options: { persist?: boolean; accent?: boolean } = {}) => {
616
672
  const wasEnabled = enabled;
617
673
  const hadMotion = animations;
674
+ const accentChanged = next.accent !== visualSettings.accent;
618
675
  const quotaShown = !!footerTui && footerSettings.quota;
619
- if (next.accent !== visualSettings.accent && next.accent !== "follow" && !selectAccent(ctx, next.accent)) {
676
+ if (options.accent !== false && accentChanged && next.accent === "follow") {
677
+ try { applyProfileTheme("follow", ctx); } catch (error) { ctx.ui.notify(String(error), "warning"); return; }
678
+ }
679
+ if (options.accent !== false && accentChanged && next.accent !== "follow" && !selectAccent(ctx, next.accent)) {
620
680
  ctx.ui.notify(`Accent ${next.accent} is not loaded`, "warning");
621
681
  return;
622
682
  }
@@ -646,15 +706,75 @@ export default function piJar(pi: ExtensionAPI): void {
646
706
  welcomeTui?.requestRender();
647
707
  // Quota shown again (field or UI back on): hiding it stopped lookups and dropped the cache.
648
708
  if (!quotaShown) refreshQuota(ctx);
649
- try { saveVisualSettings(getAgentDir(), next); }
650
- catch { ctx.ui.notify("Visual preferences changed for this session but could not be saved", "warning"); }
709
+ if (options.persist !== false) persistVisualPreferences(next, ctx, accentChanged ? accentTheme(next.accent) : undefined);
651
710
  };
652
711
 
712
+ let switchingProfile = false;
713
+ const canSwitchProfile = (ctx: ExtensionContext) => {
714
+ if (switchingProfile || advisor.isBusy() || !ctx.isIdle?.() || working.phase !== "idle" || planMode.isEnabled() || goals?.isActive() || subagents.running() > 0) {
715
+ ctx.ui.notify("Finish the active agent/workflow before switching profiles", "warning"); return false;
716
+ }
717
+ if (conversationStarted(profileEntries(ctx)) || pinnedProfileId(profileEntries(ctx)) !== undefined) {
718
+ ctx.ui.notify("This session's profile is locked. Open a new session to switch profiles", "warning"); return false;
719
+ }
720
+ return true;
721
+ };
722
+ const switchProfile = async (name: string, ctx: ExtensionContext) => {
723
+ if (!profileStore || name === profileStore.activeName || !canSwitchProfile(ctx)) return;
724
+ const store = profileStore;
725
+ const previous = store.active();
726
+ const previousTheme = ctx.ui.theme?.name;
727
+ const previousModel = ctx.model;
728
+ const previousRole = modelRoles.activeRole();
729
+ const previousThinking = pi.getThinkingLevel();
730
+ const next = store.list().find((profile) => profile.name === name);
731
+ if (!next) { ctx.ui.notify("Unknown profile: " + name, "warning"); return; }
732
+ switchingProfile = true;
733
+ try {
734
+ applyProfileTheme(next.theme, ctx);
735
+ store.activate(name);
736
+ modelRoles.invalidateTemporaryRestores();
737
+ modelRoles.load(ctx.cwd, next.roles, next.id);
738
+ composer.setProfile(next.name);
739
+ applyVisualSettings(next.settings, ctx, { persist: false, accent: false });
740
+ if (!await modelRoles.activate("default", ctx, true)) ctx.ui.notify("Profile loaded, but its default model is unavailable; keeping the current model", "warning");
741
+ repaint("profile"); welcomeTui?.requestRender();
742
+ ctx.ui.notify(`Profile: ${next.name}`, "info");
743
+ } catch (error) {
744
+ try { if (store.activeName !== previous.name) store.activate(previous.name); } catch {}
745
+ if (previousTheme) { try { applyProfileTheme(previousTheme, ctx); } catch {} }
746
+ modelRoles.load(ctx.cwd, previous.roles, previous.id);
747
+ try {
748
+ if (previousModel && ctx.model !== previousModel && !await pi.setModel(previousModel)) throw new Error("Previous model is unavailable");
749
+ pi.setThinkingLevel(previousThinking);
750
+ modelRoles.restoreActiveRole(previousRole, ctx);
751
+ } catch { ctx.ui.notify("Could not restore the previous model after the failed profile switch", "warning"); }
752
+ composer.setProfile(previous.name);
753
+ applyVisualSettings(previous.settings, ctx, { persist: false, accent: false });
754
+ ctx.ui.notify(`Could not switch profile: ${(error as Error).message}`, "warning");
755
+ } finally { switchingProfile = false; }
756
+ };
653
757
  openSettings = async (ctx) => {
654
758
  if (settingsOpen) return;
655
759
  settingsOpen = true;
656
760
  try { await openJarSettings(ctx, () => visualSettings,
657
- (next) => applyVisualSettings(next, ctx), loadedAccents(ctx), piPreferences(ctx)); }
761
+ (next) => applyVisualSettings(next, ctx), loadedAccents(ctx), piPreferences(ctx), profileStore ? {
762
+ store: profileStore, themes: ctx.ui.getAllThemes?.().map((theme) => theme.name) ?? [],
763
+ roles: () => modelRoles.profileRoleConfig(), roleManager: modelRoles,
764
+ create: (name, theme, settings, roles) => { const profile = profileStore!.add(name, theme, { ...settings, accent: "follow" }, roles); ctx.ui.notify(`Created profile ${name}; select it in a fresh session`, "info"); return profile; },
765
+ select: (name) => switchProfile(name, ctx),
766
+ setTheme: (name) => {
767
+ const previousTheme = ctx.ui.theme?.name;
768
+ const next = { ...visualSettings, accent: "follow" as const };
769
+ try { applyProfileTheme(name, ctx); profileStore!.update(profileStore!.activeName, { theme: name, settings: next }); }
770
+ catch (error) { if (previousTheme) applyProfileTheme(previousTheme, ctx); throw error; }
771
+ applyVisualSettings(next, ctx, { persist: false, accent: false });
772
+ if (profileStore!.active().id === "default") {
773
+ try { saveVisualSettings(getAgentDir(), next); }
774
+ catch { ctx.ui.notify("Theme saved, but legacy Default preferences could not be updated", "warning"); }
775
+ }
776
+ }
777
+ } : undefined); }
658
778
  finally { settingsOpen = false; }
659
779
  };
660
780
 
@@ -675,7 +795,8 @@ export default function piJar(pi: ExtensionAPI): void {
675
795
  };
676
796
  pi.on("session_start", (_event, ctx) => {
677
797
  sideUsage.clear();
678
- visualSettings = loadVisualSettings(getAgentDir());
798
+ if (!profileStore) initializeProfiles(ctx);
799
+ composer.setProfile(profileStore?.activeName ?? "Default");
679
800
  setIconSet(visualSettings.icons);
680
801
  animations = visualSettings.animations;
681
802
  enabled = visualSettings.ui;
@@ -743,7 +864,7 @@ export default function piJar(pi: ExtensionAPI): void {
743
864
  composer.setMascot(visualSettings.mascot);
744
865
  if (visualSettings.composer && enabled) composer.enable(ctx);
745
866
  suggest.sync();
746
- if (visualSettings.accent !== "follow") selectAccent(ctx, visualSettings.accent);
867
+ if (!profileStore && visualSettings.accent !== "follow") selectAccent(ctx, visualSettings.accent);
747
868
  if (branch.length >= LARGE_SESSION_ENTRIES) {
748
869
  try {
749
870
  const prefs = piPreferences(ctx)?.get();
@@ -778,11 +899,20 @@ export default function piJar(pi: ExtensionAPI): void {
778
899
  welcomeTui.requestRender();
779
900
  };
780
901
  pi.on("input", (event, ctx) => {
902
+ if (profileStore && !conversationStarted(profileEntries(ctx))) {
903
+ const id = profileStore.active().id;
904
+ if (pinnedProfileId(profileEntries(ctx)) !== id) pi.appendEntry?.(PROFILE_ENTRY, { version: 1, id });
905
+ }
781
906
  if (event.source !== "interactive") return;
782
907
  dismissWelcome(ctx);
783
908
  if (!todosAcknowledged && todos?.all().every((item) => item.done)) { todosAcknowledged = true; updateTaskWidget(ctx); }
784
909
  });
785
- pi.on("agent_start", (_event, ctx) => { dismissWelcome(ctx); working.start(); applyWorking(ctx); refreshQuota(ctx); });
910
+ pi.on("agent_start", (_event, ctx) => {
911
+ if (profileStore && pinnedProfileId(profileEntries(ctx)) !== profileStore.active().id) {
912
+ pi.appendEntry?.(PROFILE_ENTRY, { version: 1, id: profileStore.active().id });
913
+ }
914
+ dismissWelcome(ctx); working.start(); applyWorking(ctx); refreshQuota(ctx);
915
+ });
786
916
  pi.on("turn_start", (_event, ctx) => { working.start(); applyWorking(ctx); });
787
917
  pi.on("message_end", (event, ctx) => {
788
918
  // Every message (user, assistant, tool result) moves the context window, and a tool-heavy turn
@@ -882,6 +1012,15 @@ export default function piJar(pi: ExtensionAPI): void {
882
1012
  if (picked !== undefined) ctx.ui.setEditorText(picked);
883
1013
  }
884
1014
  });
1015
+ pi.registerShortcut?.(Key.ctrlShift("tab"), {
1016
+ description: "Switch pi-jar profile",
1017
+ handler: async (ctx) => {
1018
+ if (!ctx.hasUI || ctx.mode !== "tui" || !profileStore || settingsOpen) return;
1019
+ const profiles = profileStore.list();
1020
+ const index = profiles.findIndex((profile) => profile.name === profileStore!.activeName);
1021
+ if (profiles.length > 1) await switchProfile(profiles[(index + 1) % profiles.length]!.name, ctx);
1022
+ }
1023
+ });
885
1024
  pi.registerShortcut?.(Key.ctrlAlt("m"), {
886
1025
  description: "Cycle pi-jar model roles",
887
1026
  handler: async (ctx) => { await modelRoles.cycle(ctx); repaint("roles"); }
@@ -997,8 +1136,7 @@ export default function piJar(pi: ExtensionAPI): void {
997
1136
  const applied = selectAccent(ctx, selected);
998
1137
  if (applied) {
999
1138
  visualSettings = { ...visualSettings, accent: selected as JarVisualSettings["accent"] };
1000
- try { saveVisualSettings(getAgentDir(), visualSettings); }
1001
- catch { ctx.ui.notify("Accent changed for this session but could not be saved", "warning"); }
1139
+ persistVisualPreferences(visualSettings, ctx, accentTheme(visualSettings.accent));
1002
1140
  applyWorking(ctx);
1003
1141
  }
1004
1142
  const valid = selected === "default" || ACCENT_NAMES.some((name) => name === selected);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-jar",
3
- "version": "0.1.9",
4
- "description": "A warm, role-aware Pi terminal workspace with animated UI, plan and goal workflows, tasks, change review, model roles, background shells and subagents.",
3
+ "version": "0.2.0",
4
+ "description": "A warm, role-aware Pi terminal workspace with switchable profiles, animated UI, plan and goal workflows, tasks, change review, model roles, background shells and subagents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {