pi-jar 0.1.9 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -5
- package/docs/INTEGRATIONS.md +1 -1
- package/docs/WORKFLOWS.md +1 -1
- package/extensions/index.ts +167 -14
- package/package.json +2 -2
- package/src/advisor.ts +226 -73
- package/src/composer.ts +21 -4
- package/src/context-diet.ts +94 -0
- package/src/delegate-worktree.ts +33 -13
- package/src/delegate.ts +70 -40
- package/src/model-roles.ts +29 -9
- package/src/profile-ui.ts +58 -0
- package/src/profiles.ts +148 -0
- package/src/settings-ui.ts +36 -14
- package/src/settings.ts +4 -1
- package/src/shell-notifications.ts +33 -6
- package/src/shells.ts +4 -1
- package/src/side-model.ts +3 -1
- package/src/welcome.ts +6 -1
package/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;
|
|
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. |
|
|
@@ -56,6 +56,7 @@ Pi only delivers mouse events in its **fullscreen** TUI mode. In regular mode th
|
|
|
56
56
|
| `/plan [request]` | Enter plan mode (optionally sending the request). `/plan review` reopens the plan view; `/plan off` exits. |
|
|
57
57
|
| `/goal <outcome>` | Start goal mode. `/goal` edits, `/goal status`, `/goal pause`, `/goal resume`, `/goal clear`. |
|
|
58
58
|
| `/roles` | Role manager. `/roles set ROLE provider/model[:effort]\|@role [--project]`, `/roles clear ROLE`, `/roles <role>` activates, `/roles cycle`, `/roles list`. |
|
|
59
|
+
| `/profiles` | Create or switch profiles. `/profiles new` creates (name and theme, then roles), `/profiles <name>` switches. |
|
|
59
60
|
| `/advisor [focus]` | Ask the advisor for a second opinion on the current work; the answer joins the conversation. |
|
|
60
61
|
| `/usage` · `/context` | Usage (cost, tokens per model, plan limits) and context-window breakdown in one tabbed panel. |
|
|
61
62
|
| `/jar commit [note]` | Draft a commit message for the staged changes with the `commit` role, edit it, and commit. Offers `git add -A` when nothing is staged. |
|
|
@@ -79,6 +80,7 @@ Pi only delivers mouse events in its **fullscreen** TUI mode. In regular mode th
|
|
|
79
80
|
| Shortcut | Action |
|
|
80
81
|
| --- | --- |
|
|
81
82
|
| <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>S</kbd> | Open pi-jar settings |
|
|
83
|
+
| <kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Tab</kbd> | Cycle profiles (theme and model roles) |
|
|
82
84
|
| <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>A</kbd> | Subagents and background shells (activity view) |
|
|
83
85
|
| <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>R</kbd> | Refresh the welcome (new message and flame), or show it again |
|
|
84
86
|
| <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>P</kbd> | Toggle plan mode |
|
|
@@ -87,6 +89,14 @@ Pi only delivers mouse events in its **fullscreen** TUI mode. In regular mode th
|
|
|
87
89
|
| <kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>H</kbd> | Search earlier prompts into the composer |
|
|
88
90
|
| <kbd>Tab</kbd> / <kbd>→</kbd> in an empty composer | Accept the dim suggestion into the input (it is not sent) |
|
|
89
91
|
|
|
92
|
+
## Profiles
|
|
93
|
+
|
|
94
|
+
Profiles keep separate themes, appearance settings, and model-role configurations. **Default** preserves your existing setup.
|
|
95
|
+
|
|
96
|
+
Run `/profiles` (or `/jar settings` → **Profiles**, **Ctrl+Alt+S**) to create a profile or switch to one. `/profiles new` creates directly and `/profiles <name>` switches. Creating asks only for a name and a theme; the new profile copies the current profile's appearance and model roles. In a fresh, idle session pi-jar then switches to it and opens the roles manager so you can set its models; otherwise it tells you to switch in a fresh session and run `/roles`. Cancelling leaves your existing setup unchanged.
|
|
97
|
+
|
|
98
|
+
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.
|
|
99
|
+
|
|
90
100
|
## Welcome screen
|
|
91
101
|
|
|
92
102
|
The welcome stays until your first prompt, then dissolves (or hides immediately with motion off).
|
|
@@ -115,7 +125,7 @@ The welcome stays until your first prompt, then dissolves (or hides immediately
|
|
|
115
125
|
| proud | `(★ᴗ★)` | a goal was completed |
|
|
116
126
|
| poked | `(^o^)` | you clicked it |
|
|
117
127
|
|
|
118
|
-
The title also shows Pi's session name (or a stable readable alias like `silver-lantern`)
|
|
128
|
+
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
129
|
|
|
120
130
|
## Plan mode
|
|
121
131
|
|
|
@@ -213,7 +223,7 @@ Roles map a purpose to a model. They live in `~/.pi/agent/pi-jar-roles.json` (Pi
|
|
|
213
223
|
|
|
214
224
|
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
225
|
|
|
216
|
-
- **`jar_advisor({ question?, draft? })`** —
|
|
226
|
+
- **`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
227
|
- **`/advisor [focus]`** — ask on demand; the answer is added to the conversation (visible) for the agent's next turn.
|
|
218
228
|
- **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
229
|
- 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 +266,8 @@ This is a pi-jar enhancement: [pi-advisor](https://github.com/philipbrembeck/pi-
|
|
|
256
266
|
Use `parent` with `append` to add a genuinely new subtask to active work; `start`, `done`, `open`, `edit`, `list`, `add` and `delete` remain available.
|
|
257
267
|
- **`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
268
|
- **`/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
|
-
-
|
|
269
|
+
- **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.
|
|
270
|
+
- **`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
271
|
- **`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
272
|
- **`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
273
|
- **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).
|
package/docs/INTEGRATIONS.md
CHANGED
|
@@ -65,7 +65,7 @@ Hidden context messages (`pi-jar.plan-context`, `pi-jar.plan-reminder`, `pi-jar.
|
|
|
65
65
|
| `jar_delegate` | up to four parallel subagents (`tasks: [{task, name?, role?}]`, `write?`) |
|
|
66
66
|
| `jar_advisor` | second opinion from the `advisor` role (`question?`, `draft?`) |
|
|
67
67
|
|
|
68
|
-
`jar_shell` events arrive as a visible `pi-jar.shell` custom message that triggers (or queues) an agent turn;
|
|
68
|
+
`jar_shell` events arrive as a visible `pi-jar.shell` custom message that triggers (or queues) an agent turn; subagent turn ends and `jar_subagent` control completions arrive as one coalesced, visible `pi-jar.subagent` message at the moderator's next turn boundary, or as a wake-up when it is idle. `jar_delegate` runs child processes with `PI_JAR_CHILD=1`, which keeps pi-jar in the child from registering `jar_delegate` again, and publishes each running subagent through the role contract above as `pi-jar.role.delegate-<batch>-<n>`. The advisor publishes `pi-jar.role.advisor` while it is consulting, and its `/advisor` and gate answers arrive as visible `pi-jar.advisor` custom messages.
|
|
69
69
|
|
|
70
70
|
## Commands that overlap other packages
|
|
71
71
|
|
package/docs/WORKFLOWS.md
CHANGED
|
@@ -143,7 +143,7 @@ watch matches or process exits → pi-jar.shell message → agent wakes (o
|
|
|
143
143
|
- `jar_delegate` shares a session-wide **Max subagents** setting in `/jar settings`: exactly 2, 4, 6, 8 or 16, default 4. All retained live children (including idle/paused and live legacy one-shots) count; the moderator does not. Concurrent/reentrant batches reserve capacity atomically and reject over-cap requests before any launch. Lowering the limit preserves the fleet and prevents further spawning until room is available. `scout` runs `pi --mode rpc --no-session`, is read-only and defaults to the `scout` role. `fork` uses `--fork <parent-session>`, is read-only and defaults to `reviewer`. `worktree` uses the same real context fork, defaults to `worker`, and runs in a detached disposable Git worktree. Explicit `task.role` overrides those defaults; each role uses its primary/fallback candidates before falling back to the parent's current model.
|
|
144
144
|
- Retained children do not exit on `agent_settled`. They become `idle`, keeping the same RPC process and model context. `jar_subagent pause` sends `clear_queue` + `abort` and leaves the agent `paused`; `resume` sends a new prompt to that same child. `steer` redirects a working child. `ask` pauses an active task, crosses a settled boundary, then starts a compact BTW-only prompt, so the old task report cannot be mistaken for the answer. It preserves the prior paused state and assigned scope for later resume.
|
|
145
145
|
- `jar_subagent peek` is the moderator's cheap status surface and reads only task progress from memory: key, name, state, done/total checklist leaves, the in-progress task, current activity and the next three open tasks (no git, transcript or report reads). `stop` is destructive/final: abort an active operation if necessary, close the child, return a handoff (workspace, changed/applied files, remaining work, last report), and finalize safe worktree changes.
|
|
146
|
-
- The moderator never has to poll:
|
|
146
|
+
- The moderator never has to poll: settled turns and `pause`/`ask`/`stop` completions wait in pi-jar's own queue, never Pi's irrevocable follow-up queue. While the moderator is running they are coalesced into one visible `pi-jar.subagent` message (their `peek` progress plus the turn's report, clipped to 1200 characters) at the next turn boundary, so the final answer already reflects them instead of being followed by a fresh wake-up. Only events arriving while the moderator is idle wake a turn; an interrupted run keeps them for the next prompt. The message renders as a one-line summary (expand for the report). `pause`, `ask` and `stop` drop queued turn reports for the agent they control.
|
|
147
147
|
- Nothing in the moderator path waits unbounded. `jar_subagent` returns as soon as the tool call is aborted (the child keeps its state); `pause` reports "not settled yet" after 15 s if the child ignores abort and still pauses when it settles; a working child that sends no RPC event for 10 minutes is killed as hung. After a child exits, its stdio gets 2 s to drain: a grandchild (MCP server, shell) that inherited the pipes cannot keep `stop` or session shutdown waiting for `close`.
|
|
148
148
|
- Worktree setup snapshots the exact starting workspace: `HEAD` plus tracked dirty changes and non-ignored untracked files are committed only inside the detached worktree. The worktree persists across idle/pause/resume. On stop, the parent target must still byte/mode-match the snapshot and the change must be trackable by `/diff`; otherwise nothing is overwritten and the workspace/error is reported. Conflicted workspaces survive report-history trimming; after resolving parent drift, retry `stop`. Finalization waits for RPC stdio closure. Snapshot blobs preserve filesystem bytes without executing clean/smudge filters or hooks. The path guard handles Pi path normalization, rejects lexical/symlink/dangling-link escapes and Git control metadata, checks read-tool filename fallbacks (NFD, curly apostrophes, AM/PM spaces), and rechecks both source and destination ancestors during reconciliation. All replacements/rollback backups are prepared before parent mutation; failed multi-file application restores earlier files. Exceptional rollback failures explicitly report actual applied paths for recovery. Worktree children receive only `read,edit,write,grep,find,ls,jar_todo,jar_discuss`; shell tools are absent. This is filesystem-write isolation, not a network/process sandbox.
|
|
149
149
|
- `jar_discuss` gives moderator and children a session-scoped structured paper. `ask` appends a question with stable id/from/to fields and `answer` references the question id (validated under the same lock as the write); both reply with the new id only, never echoing the text. Each process keeps an in-memory read cursor (the highest entry id it has been shown, reset when the paper path changes). `list` returns only unseen entries written by others — the newest that fit a 4000-character reply, each clipped to 400 characters, stating how many older entries it omitted — and advances the cursor; with nothing new it replies `No new entries (latest dN).` `since: "d12"` lists everything newer than that id instead (`since: "d0"` rereads the whole paper) and `questionId` reads one thread in full without moving the cursor. An `ask`/`answer` reply attaches unseen entries (≤1000 characters) only when all of them are relevant to the caller — answers to its questions or questions addressed to it — otherwise it adds `(N new entries; list to read)` and leaves the cursor alone, so nothing is skipped. Text is capped at 1600 characters per entry, at most 64 entries are retained, and the compact UTF-8 JSON paper is strictly bounded to 64 KiB with oldest-entry eviction and atomic rewrites. The file lock waits by yielding to the event loop (at most ~1 s), never blocking the UI. It is intentionally for terse inter-agent Q/A rather than another transcript; agents are told not to poll it.
|
package/extensions/index.ts
CHANGED
|
@@ -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,15 @@ 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";
|
|
22
|
+
import { openProfiles, type ProfileAction } from "../src/profile-ui.ts";
|
|
21
23
|
import { pickSession } from "../src/session-ui.ts";
|
|
22
24
|
import { fetchQuota, QuotaCache, type QuotaProvider } from "../src/quota.ts";
|
|
23
25
|
import { ModelRoleManager } from "../src/model-roles.ts";
|
|
24
26
|
import { jarCommit } from "../src/commit.ts";
|
|
25
27
|
import { registerAdvisor } from "../src/advisor.ts";
|
|
28
|
+
import { registerContextDiet } from "../src/context-diet.ts";
|
|
26
29
|
import { registerInfoPanels } from "../src/info-panels.ts";
|
|
27
30
|
import { SideUsage } from "../src/side-model.ts";
|
|
28
31
|
import { PlanMode } from "../src/plan.ts";
|
|
@@ -97,6 +100,7 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
97
100
|
installCompactBuiltinTools(pi);
|
|
98
101
|
let demo = false;
|
|
99
102
|
let visualSettings = defaultVisualSettings();
|
|
103
|
+
let profileStore: ProfileStore | undefined;
|
|
100
104
|
let animations = visualSettings.animations;
|
|
101
105
|
let enabled = visualSettings.ui;
|
|
102
106
|
let disposeFooter: (() => void) | undefined;
|
|
@@ -303,10 +307,52 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
303
307
|
registerAskTool(pi);
|
|
304
308
|
const modelRoles = new ModelRoleManager(pi);
|
|
305
309
|
const sideUsage = new SideUsage();
|
|
306
|
-
|
|
310
|
+
registerContextDiet(pi, () => visualSettings.contextDiet === true);
|
|
311
|
+
const advisor = registerAdvisor(pi, modelRoles, { enabled: () => !delegatedChild && visualSettings.advisor, gates: () => !delegatedChild && visualSettings.advisorGates, usage: sideUsage });
|
|
307
312
|
registerInfoPanels(pi, { side: sideUsage, quotaEnabled: () => quotaCache?.enabled ?? false,
|
|
308
313
|
quota: (ctx) => quotaCache?.get(ctx.model?.provider, welcomeStatuses(), Date.now()) });
|
|
309
|
-
|
|
314
|
+
let followingTheme: string | undefined;
|
|
315
|
+
const profileEntries = (ctx: ExtensionContext): readonly unknown[] => {
|
|
316
|
+
try { return ctx.sessionManager.getEntries?.() ?? sessionBranch(ctx); } catch { return sessionBranch(ctx); }
|
|
317
|
+
};
|
|
318
|
+
const applyProfileTheme = (name: string, ctx: ExtensionContext) => {
|
|
319
|
+
if (!ctx.hasUI || ctx.mode !== "tui" || typeof ctx.ui.setTheme !== "function") return;
|
|
320
|
+
const target = name === "follow" ? followingTheme ?? "system" : name;
|
|
321
|
+
if (!ctx.ui.getTheme?.(target)) throw new Error(`Theme ${target} is not loaded`);
|
|
322
|
+
const result = ctx.ui.setTheme(target);
|
|
323
|
+
if (result && !result.success) throw new Error(result.error ?? `Could not apply theme ${target}`);
|
|
324
|
+
};
|
|
325
|
+
const initializeProfiles = (ctx: ExtensionContext) => {
|
|
326
|
+
const previousTheme = ctx.ui.theme?.name;
|
|
327
|
+
followingTheme ??= previousTheme ?? "system";
|
|
328
|
+
try {
|
|
329
|
+
if (!existsSync(join(getAgentDir(), PROFILE_FILE))
|
|
330
|
+
&& (!ctx.hasUI || ctx.mode !== "tui" || typeof ctx.ui.getAllThemes !== "function")) {
|
|
331
|
+
profileStore = undefined; visualSettings = loadVisualSettings(getAgentDir()); return undefined;
|
|
332
|
+
}
|
|
333
|
+
modelRoles.load(ctx.cwd);
|
|
334
|
+
const legacyAccent = loadVisualSettings(getAgentDir()).accent;
|
|
335
|
+
const legacyTheme = accentTheme(legacyAccent);
|
|
336
|
+
const initialTheme = legacyAccent !== "follow" && ctx.ui.getTheme?.(legacyTheme) ? legacyTheme : previousTheme ?? "system";
|
|
337
|
+
const store = new ProfileStore(getAgentDir(), initialTheme, modelRoles.profileRoleConfig());
|
|
338
|
+
const entries = profileEntries(ctx);
|
|
339
|
+
const id = pinnedProfileId(entries) ?? (conversationStarted(entries) ? "default" : store.active().id);
|
|
340
|
+
const profile = store.list().find((item) => item.id === id);
|
|
341
|
+
if (!profile) throw new Error("This session's profile is no longer available");
|
|
342
|
+
applyProfileTheme(profile.theme, ctx);
|
|
343
|
+
if (store.activeName !== profile.name) store.activate(profile.name);
|
|
344
|
+
profileStore = store;
|
|
345
|
+
visualSettings = profile.settings;
|
|
346
|
+
composer.setProfile(profile.name);
|
|
347
|
+
return { roles: profile.roles, profileId: profile.id };
|
|
348
|
+
} catch (error) {
|
|
349
|
+
if (previousTheme) { try { applyProfileTheme(previousTheme, ctx); } catch {} }
|
|
350
|
+
profileStore = undefined; visualSettings = loadVisualSettings(getAgentDir()); composer.setProfile("Default");
|
|
351
|
+
ctx.ui.notify(`Could not load profiles: ${(error as Error).message}`, "warning");
|
|
352
|
+
return undefined;
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
modelRoles.register((ctx) => openRolesUi(ctx, modelRoles), { activateDefault: () => !delegatedChild, beforeSessionStart: initializeProfiles });
|
|
310
356
|
let delegateController: DelegateController | undefined;
|
|
311
357
|
registerDelegate(pi, modelRoles, subagents, { changes: () => changes, changed: refreshChanges,
|
|
312
358
|
discussionEnv: (key, name) => discussion?.childEnv(key, name),
|
|
@@ -478,7 +524,7 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
478
524
|
const quota = footerSettings.quota ? quotaCache?.get(ctx.model?.provider, statuses, Date.now()) : undefined;
|
|
479
525
|
const open = todos?.all().filter((item) => !item.done) ?? [];
|
|
480
526
|
const lines = welcomeLines(width, welcomeFrame, (color, text) => (ctx.ui.theme ?? theme).fg(color, text), {
|
|
481
|
-
...info, settingsClickable: tui.mode !== "regular", roles: live.roles,
|
|
527
|
+
...info, model: ctx.model?.id, profile: profileStore?.activeName ?? "Default", settingsClickable: tui.mode !== "regular", roles: live.roles,
|
|
482
528
|
quota: quota?.week?.used ?? quota?.fiveHour?.used,
|
|
483
529
|
effort: ctx.model?.reasoning === false ? "off" : pi.getThinkingLevel?.(),
|
|
484
530
|
...(modelRoles.activeRole() ? { activeRole: modelRoles.activeRole()! } : {}),
|
|
@@ -612,11 +658,26 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
612
658
|
}
|
|
613
659
|
};
|
|
614
660
|
|
|
615
|
-
const
|
|
661
|
+
const persistVisualPreferences = (next: JarVisualSettings, ctx: ExtensionContext, theme?: string) => {
|
|
662
|
+
if (profileStore) {
|
|
663
|
+
try { profileStore.update(profileStore.activeName, { settings: next, ...(theme ? { theme } : {}) }); }
|
|
664
|
+
catch (error) { ctx.ui.notify("Profile preferences changed for this session but could not be saved: " + (error as Error).message, "warning"); return; }
|
|
665
|
+
}
|
|
666
|
+
if (!profileStore || profileStore.active().id === "default") {
|
|
667
|
+
try { saveVisualSettings(getAgentDir(), next); }
|
|
668
|
+
catch { ctx.ui.notify("Visual preferences changed for this session but could not be saved", "warning"); }
|
|
669
|
+
}
|
|
670
|
+
};
|
|
671
|
+
const accentTheme = (accent: JarVisualSettings["accent"]) => accent === "follow" ? "follow" : accent === "default" ? "pi-jar-dark" : `pi-jar-dark-${accent}`;
|
|
672
|
+
const applyVisualSettings = (next: JarVisualSettings, ctx: ExtensionContext, options: { persist?: boolean; accent?: boolean } = {}) => {
|
|
616
673
|
const wasEnabled = enabled;
|
|
617
674
|
const hadMotion = animations;
|
|
675
|
+
const accentChanged = next.accent !== visualSettings.accent;
|
|
618
676
|
const quotaShown = !!footerTui && footerSettings.quota;
|
|
619
|
-
if (
|
|
677
|
+
if (options.accent !== false && accentChanged && next.accent === "follow") {
|
|
678
|
+
try { applyProfileTheme("follow", ctx); } catch (error) { ctx.ui.notify(String(error), "warning"); return; }
|
|
679
|
+
}
|
|
680
|
+
if (options.accent !== false && accentChanged && next.accent !== "follow" && !selectAccent(ctx, next.accent)) {
|
|
620
681
|
ctx.ui.notify(`Accent ${next.accent} is not loaded`, "warning");
|
|
621
682
|
return;
|
|
622
683
|
}
|
|
@@ -646,17 +707,91 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
646
707
|
welcomeTui?.requestRender();
|
|
647
708
|
// Quota shown again (field or UI back on): hiding it stopped lookups and dropped the cache.
|
|
648
709
|
if (!quotaShown) refreshQuota(ctx);
|
|
649
|
-
|
|
650
|
-
catch { ctx.ui.notify("Visual preferences changed for this session but could not be saved", "warning"); }
|
|
710
|
+
if (options.persist !== false) persistVisualPreferences(next, ctx, accentChanged ? accentTheme(next.accent) : undefined);
|
|
651
711
|
};
|
|
652
712
|
|
|
713
|
+
let switchingProfile = false;
|
|
714
|
+
const profileSwitchBlocked = (ctx: ExtensionContext): string | undefined => {
|
|
715
|
+
if (switchingProfile || advisor.isBusy() || !ctx.isIdle?.() || working.phase !== "idle" || planMode.isEnabled() || goals?.isActive() || subagents.running() > 0) {
|
|
716
|
+
return "Finish the active agent/workflow before switching profiles";
|
|
717
|
+
}
|
|
718
|
+
if (conversationStarted(profileEntries(ctx)) || pinnedProfileId(profileEntries(ctx)) !== undefined) {
|
|
719
|
+
return "This session's profile is locked. Open a new session to switch profiles";
|
|
720
|
+
}
|
|
721
|
+
return undefined;
|
|
722
|
+
};
|
|
723
|
+
const canSwitchProfile = (ctx: ExtensionContext) => {
|
|
724
|
+
const blocked = profileSwitchBlocked(ctx);
|
|
725
|
+
if (blocked) ctx.ui.notify(blocked, "warning");
|
|
726
|
+
return !blocked;
|
|
727
|
+
};
|
|
728
|
+
const switchProfile = async (name: string, ctx: ExtensionContext) => {
|
|
729
|
+
if (!profileStore || name === profileStore.activeName || !canSwitchProfile(ctx)) return;
|
|
730
|
+
const store = profileStore;
|
|
731
|
+
const previous = store.active();
|
|
732
|
+
const previousTheme = ctx.ui.theme?.name;
|
|
733
|
+
const previousModel = ctx.model;
|
|
734
|
+
const previousRole = modelRoles.activeRole();
|
|
735
|
+
const previousThinking = pi.getThinkingLevel();
|
|
736
|
+
const next = store.list().find((profile) => profile.name === name);
|
|
737
|
+
if (!next) { ctx.ui.notify("Unknown profile: " + name, "warning"); return; }
|
|
738
|
+
switchingProfile = true;
|
|
739
|
+
try {
|
|
740
|
+
applyProfileTheme(next.theme, ctx);
|
|
741
|
+
store.activate(name);
|
|
742
|
+
modelRoles.invalidateTemporaryRestores();
|
|
743
|
+
modelRoles.load(ctx.cwd, next.roles, next.id);
|
|
744
|
+
composer.setProfile(next.name);
|
|
745
|
+
applyVisualSettings(next.settings, ctx, { persist: false, accent: false });
|
|
746
|
+
if (!await modelRoles.activate("default", ctx, true)) ctx.ui.notify("Profile loaded, but its default model is unavailable; keeping the current model", "warning");
|
|
747
|
+
repaint("profile"); welcomeTui?.requestRender();
|
|
748
|
+
ctx.ui.notify(`Profile: ${next.name}`, "info");
|
|
749
|
+
} catch (error) {
|
|
750
|
+
try { if (store.activeName !== previous.name) store.activate(previous.name); } catch {}
|
|
751
|
+
if (previousTheme) { try { applyProfileTheme(previousTheme, ctx); } catch {} }
|
|
752
|
+
modelRoles.load(ctx.cwd, previous.roles, previous.id);
|
|
753
|
+
try {
|
|
754
|
+
if (previousModel && ctx.model !== previousModel && !await pi.setModel(previousModel)) throw new Error("Previous model is unavailable");
|
|
755
|
+
pi.setThinkingLevel(previousThinking);
|
|
756
|
+
modelRoles.restoreActiveRole(previousRole, ctx);
|
|
757
|
+
} catch { ctx.ui.notify("Could not restore the previous model after the failed profile switch", "warning"); }
|
|
758
|
+
composer.setProfile(previous.name);
|
|
759
|
+
applyVisualSettings(previous.settings, ctx, { persist: false, accent: false });
|
|
760
|
+
ctx.ui.notify(`Could not switch profile: ${(error as Error).message}`, "warning");
|
|
761
|
+
} finally { switchingProfile = false; }
|
|
762
|
+
};
|
|
763
|
+
const profileUi = (ctx: ExtensionContext, action?: ProfileAction) => {
|
|
764
|
+
if (!profileStore) { ctx.ui.notify("Profiles are unavailable in this session", "warning"); return Promise.resolve(); }
|
|
765
|
+
const store = profileStore;
|
|
766
|
+
return openProfiles(ctx, {
|
|
767
|
+
store, themes: ctx.ui.getAllThemes?.().map((theme) => theme.name) ?? [],
|
|
768
|
+
create: (name, theme) => store.add(name, theme, { ...visualSettings, accent: "follow" }, modelRoles.profileRoleConfig()),
|
|
769
|
+
switchBlocked: () => profileSwitchBlocked(ctx),
|
|
770
|
+
select: (name) => switchProfile(name, ctx),
|
|
771
|
+
openRoles: () => openRolesUi(ctx, modelRoles)
|
|
772
|
+
}, action);
|
|
773
|
+
};
|
|
653
774
|
openSettings = async (ctx) => {
|
|
654
775
|
if (settingsOpen) return;
|
|
655
776
|
settingsOpen = true;
|
|
656
777
|
try { await openJarSettings(ctx, () => visualSettings,
|
|
657
|
-
(next) => applyVisualSettings(next, ctx), loadedAccents(ctx), piPreferences(ctx)
|
|
778
|
+
(next) => applyVisualSettings(next, ctx), loadedAccents(ctx), piPreferences(ctx), profileStore ? {
|
|
779
|
+
activeName: () => profileStore?.activeName ?? "Default",
|
|
780
|
+
open: (action) => profileUi(ctx, action)
|
|
781
|
+
} : undefined); }
|
|
658
782
|
finally { settingsOpen = false; }
|
|
659
783
|
};
|
|
784
|
+
pi.registerCommand("profiles", {
|
|
785
|
+
description: "Create or switch pi-jar profiles: /profiles, /profiles new, /profiles <name>",
|
|
786
|
+
handler: async (args, ctx) => {
|
|
787
|
+
const target = args.trim();
|
|
788
|
+
if (!target) return profileUi(ctx);
|
|
789
|
+
if (["new", "create"].includes(target.toLowerCase())) return profileUi(ctx, "create");
|
|
790
|
+
const profile = profileStore?.list().find((item) => item.name.toLocaleLowerCase() === target.toLocaleLowerCase());
|
|
791
|
+
if (!profile) { ctx.ui.notify("Unknown profile: " + target, "warning"); return; }
|
|
792
|
+
await switchProfile(profile.name, ctx);
|
|
793
|
+
}
|
|
794
|
+
});
|
|
660
795
|
|
|
661
796
|
/** Callers are lifecycle handlers that repaint once through sampleFooter. */
|
|
662
797
|
const updateCost = (ctx: ExtensionContext) => {
|
|
@@ -675,7 +810,8 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
675
810
|
};
|
|
676
811
|
pi.on("session_start", (_event, ctx) => {
|
|
677
812
|
sideUsage.clear();
|
|
678
|
-
|
|
813
|
+
if (!profileStore) initializeProfiles(ctx);
|
|
814
|
+
composer.setProfile(profileStore?.activeName ?? "Default");
|
|
679
815
|
setIconSet(visualSettings.icons);
|
|
680
816
|
animations = visualSettings.animations;
|
|
681
817
|
enabled = visualSettings.ui;
|
|
@@ -743,7 +879,7 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
743
879
|
composer.setMascot(visualSettings.mascot);
|
|
744
880
|
if (visualSettings.composer && enabled) composer.enable(ctx);
|
|
745
881
|
suggest.sync();
|
|
746
|
-
if (visualSettings.accent !== "follow") selectAccent(ctx, visualSettings.accent);
|
|
882
|
+
if (!profileStore && visualSettings.accent !== "follow") selectAccent(ctx, visualSettings.accent);
|
|
747
883
|
if (branch.length >= LARGE_SESSION_ENTRIES) {
|
|
748
884
|
try {
|
|
749
885
|
const prefs = piPreferences(ctx)?.get();
|
|
@@ -778,11 +914,20 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
778
914
|
welcomeTui.requestRender();
|
|
779
915
|
};
|
|
780
916
|
pi.on("input", (event, ctx) => {
|
|
917
|
+
if (profileStore && !conversationStarted(profileEntries(ctx))) {
|
|
918
|
+
const id = profileStore.active().id;
|
|
919
|
+
if (pinnedProfileId(profileEntries(ctx)) !== id) pi.appendEntry?.(PROFILE_ENTRY, { version: 1, id });
|
|
920
|
+
}
|
|
781
921
|
if (event.source !== "interactive") return;
|
|
782
922
|
dismissWelcome(ctx);
|
|
783
923
|
if (!todosAcknowledged && todos?.all().every((item) => item.done)) { todosAcknowledged = true; updateTaskWidget(ctx); }
|
|
784
924
|
});
|
|
785
|
-
pi.on("agent_start", (_event, ctx) => {
|
|
925
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
926
|
+
if (profileStore && pinnedProfileId(profileEntries(ctx)) !== profileStore.active().id) {
|
|
927
|
+
pi.appendEntry?.(PROFILE_ENTRY, { version: 1, id: profileStore.active().id });
|
|
928
|
+
}
|
|
929
|
+
dismissWelcome(ctx); working.start(); applyWorking(ctx); refreshQuota(ctx);
|
|
930
|
+
});
|
|
786
931
|
pi.on("turn_start", (_event, ctx) => { working.start(); applyWorking(ctx); });
|
|
787
932
|
pi.on("message_end", (event, ctx) => {
|
|
788
933
|
// Every message (user, assistant, tool result) moves the context window, and a tool-heavy turn
|
|
@@ -882,6 +1027,15 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
882
1027
|
if (picked !== undefined) ctx.ui.setEditorText(picked);
|
|
883
1028
|
}
|
|
884
1029
|
});
|
|
1030
|
+
pi.registerShortcut?.(Key.ctrlShift("tab"), {
|
|
1031
|
+
description: "Switch pi-jar profile",
|
|
1032
|
+
handler: async (ctx) => {
|
|
1033
|
+
if (!ctx.hasUI || ctx.mode !== "tui" || !profileStore || settingsOpen) return;
|
|
1034
|
+
const profiles = profileStore.list();
|
|
1035
|
+
const index = profiles.findIndex((profile) => profile.name === profileStore!.activeName);
|
|
1036
|
+
if (profiles.length > 1) await switchProfile(profiles[(index + 1) % profiles.length]!.name, ctx);
|
|
1037
|
+
}
|
|
1038
|
+
});
|
|
885
1039
|
pi.registerShortcut?.(Key.ctrlAlt("m"), {
|
|
886
1040
|
description: "Cycle pi-jar model roles",
|
|
887
1041
|
handler: async (ctx) => { await modelRoles.cycle(ctx); repaint("roles"); }
|
|
@@ -997,8 +1151,7 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
997
1151
|
const applied = selectAccent(ctx, selected);
|
|
998
1152
|
if (applied) {
|
|
999
1153
|
visualSettings = { ...visualSettings, accent: selected as JarVisualSettings["accent"] };
|
|
1000
|
-
|
|
1001
|
-
catch { ctx.ui.notify("Accent changed for this session but could not be saved", "warning"); }
|
|
1154
|
+
persistVisualPreferences(visualSettings, ctx, accentTheme(visualSettings.accent));
|
|
1002
1155
|
applyWorking(ctx);
|
|
1003
1156
|
}
|
|
1004
1157
|
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
|
|
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.1",
|
|
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": {
|