pi-jar 0.1.2 → 0.1.4

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.
@@ -1,4 +1,4 @@
1
- import { getAgentDir, SessionManager, SettingsManager, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
1
+ import { getAgentDir, SessionManager, SettingsManager, type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { basename } from "node:path";
3
3
  import { execFile } from "node:child_process";
4
4
  import { existsSync, readFileSync } from "node:fs";
@@ -9,7 +9,7 @@ import { ComposerStyle } from "../src/composer.ts";
9
9
  import { installCompactBuiltinTools } from "../src/compact-tools.ts";
10
10
  import { WELCOME_INTERVAL_MS } from "../src/animations.ts";
11
11
  import { promptText } from "../src/dialogs.ts";
12
- import { renderFooter } from "../src/footer.ts";
12
+ import { renderFooterLayout, type ActivityState, type FooterActivity, type FooterHit, type FooterTarget } from "../src/footer.ts";
13
13
  import { openJarHistory } from "../src/history-ui.ts";
14
14
  import { GOAL_ENTRY, GoalStore } from "../src/goals.ts";
15
15
  import { GoalLoop } from "../src/goal-loop.ts";
@@ -27,19 +27,21 @@ import { PlanMode } from "../src/plan.ts";
27
27
  import { openRolesUi } from "../src/roles-ui.ts";
28
28
  import { registerSuggestions, SuggestionState } from "../src/suggest.ts";
29
29
  import { createDemoRoles } from "../src/roles.ts";
30
- import { ACTIVE_STATES, collectStatuses, type JarRole } from "../src/status.ts";
30
+ import { ACTIVE_STATES, cleanText, collectStatuses, type JarRole } from "../src/status.ts";
31
31
  import { manageTasks } from "../src/tasks-ui.ts";
32
32
  import { registerTaskTool, todoRow } from "../src/task-tool.ts";
33
- import { TASK_ENTRY, TodoStore } from "../src/tasks.ts";
33
+ import { TASK_ENTRY, TodoStore, todoProgress, todoTotals } from "../src/tasks.ts";
34
34
  import { formatCost, sessionCost } from "../src/usage.ts";
35
35
  import { WorkingState } from "../src/working.ts";
36
36
  import { ChangeTracker } from "../src/changes.ts";
37
- import { registerDelegate } from "../src/delegate.ts";
37
+ import { DelegateRegistry, registerDelegate, type DelegateRun } from "../src/delegate.ts";
38
+ import { openActivityView, type ActivityTarget } from "../src/activity-view.ts";
39
+ import { ICON_SETS, setIconSet, withIcon } from "../src/icons.ts";
38
40
  import { collectPrompts, openPromptSearch } from "../src/prompt-search.ts";
39
- import { ago, recentSessions, type RecentSession } from "../src/session-gallery.ts";
41
+ import { ago, recentSessions, sessionDetails, type RecentSession } from "../src/session-gallery.ts";
40
42
  import { clearSessionBranchCache, sessionBranch } from "../src/session-branch.ts";
41
43
  import { registerChangeReview } from "../src/diff-view.ts";
42
- import { openShellsView, registerShells, SHELL_MESSAGE, shellEventMessage, ShellManager, type ShellEvent } from "../src/shells.ts";
44
+ import { registerShells, SHELL_MESSAGE, shellEventMessage, ShellManager, type ShellEvent, type ShellJob } from "../src/shells.ts";
43
45
  import { hopefulWelcomeMessage, welcomeHit, welcomeLines, type WelcomeAction } from "../src/welcome.ts";
44
46
 
45
47
  const WELCOME_KEY = "pi-jar.welcome";
@@ -50,6 +52,8 @@ const VERSION = (() => {
50
52
  /** Ignore the click Pi may synthesize right after a press we already acted on. */
51
53
  const WELCOME_CLICK_DEDUPE_MS = 400;
52
54
  const LARGE_SESSION_ENTRIES = 800;
55
+ /** Entries Pi writes when a fresh session starts; a branch holding only these has no transcript yet. */
56
+ const SESSION_SETUP_ENTRIES = new Set(["thinking_level_change", "model_change", "session_info"]);
53
57
  export default function piJar(pi: ExtensionAPI): void {
54
58
  installCompactBuiltinTools(pi);
55
59
  let demo = false;
@@ -58,6 +62,17 @@ export default function piJar(pi: ExtensionAPI): void {
58
62
  let enabled = visualSettings.ui;
59
63
  let disposeFooter: (() => void) | undefined;
60
64
  let footerTui: { requestRender(): void } | undefined;
65
+ /** Footer facts that walk the session to compute; refreshed on the events that change them, never per frame. */
66
+ let footerContext = "ctx ?";
67
+ let footerSessionName: string | undefined;
68
+ const sampleFooter = (ctx: ExtensionContext) => {
69
+ try {
70
+ const usage = ctx.getContextUsage();
71
+ footerContext = usage?.percent == null || !Number.isFinite(usage.percent) ? "ctx ?" : `ctx ${Math.round(usage.percent)}%`;
72
+ } catch { footerContext = "ctx ?"; }
73
+ try { footerSessionName = ctx.sessionManager?.getSessionName?.(); } catch { footerSessionName = undefined; }
74
+ footerTui?.requestRender();
75
+ };
61
76
  let quotaCache: QuotaCache | undefined;
62
77
  let cost = 0;
63
78
  let welcomeInterval: ReturnType<typeof setInterval> | undefined;
@@ -89,27 +104,37 @@ export default function piJar(pi: ExtensionAPI): void {
89
104
  if (open.length) todosAcknowledged = false;
90
105
  const visible = enabled && items.length > 0 && (open.length > 0 || !todosAcknowledged);
91
106
  try {
92
- ctx.ui.setWidget("pi-jar.todos", !visible ? undefined : (_tui, theme) => ({
93
- invalidate() {},
94
- render(width: number) {
95
- const colors = ctx.ui.theme ?? theme;
96
- const all = todos?.all() ?? [];
97
- const done = all.filter((item) => item.done).length;
98
- // Show a window of up to 8 rows that keeps the running (or next open) task in view.
99
- const focus = Math.max(0, all.findIndex((item) => item.status === "in_progress") >= 0
100
- ? all.findIndex((item) => item.status === "in_progress") : all.findIndex((item) => !item.done));
101
- const start = Math.max(0, Math.min(focus - 2, all.length - 8));
102
- const shown = all.slice(start, start + 8);
103
- const rows = [
104
- colors.fg("accent", "Tasks") + colors.fg("dim", ` · ${done}/${all.length} done` + (done === all.length ? " · all complete" : "") + " · /jar tasks"),
105
- ...(start > 0 ? [colors.fg("dim", ` … ${start} earlier`)] : []),
106
- ...shown.map((item) => todoRow(item, (color, text) => colors.fg(color, text), (text) => colors.bold(text))),
107
- ...(start + shown.length < all.length ? [colors.fg("dim", ` … +${all.length - start - shown.length} more`)] : [])
108
- ];
109
- // Pi inserts a spacer before widgets, but not between widgets and the composer.
110
- return [...rows.map((line) => truncateToWidth(line, Math.max(0, width))), truncateToWidth(" ", Math.max(0, width))];
111
- }
112
- }));
107
+ // Rows are rebuilt only when the list changes (every change reinstalls the widget), the width or the theme.
108
+ ctx.ui.setWidget("pi-jar.todos", !visible ? undefined : (_tui, theme) => {
109
+ let memo: { width: number; sample: string; lines: string[] } | undefined;
110
+ return {
111
+ invalidate() { memo = undefined; },
112
+ render(width: number) {
113
+ const colors = ctx.ui.theme ?? theme;
114
+ const sample = colors.fg("accent", "·") + colors.fg("dim", "·");
115
+ if (memo && memo.width === width && memo.sample === sample) return memo.lines.slice();
116
+ const all = todos?.all() ?? [];
117
+ const { done, total } = todoTotals(all);
118
+ const parents = new Set<string>();
119
+ for (const item of all) if (item.parentId) parents.add(item.parentId);
120
+ // Show a window of up to 8 rows that keeps the running leaf (or next open one) in view.
121
+ const running = all.findIndex((item) => item.status === "in_progress" && !parents.has(item.id));
122
+ const focus = Math.max(0, running >= 0 ? running : all.findIndex((item) => !item.done && !parents.has(item.id)));
123
+ const start = Math.max(0, Math.min(focus - 2, all.length - 8));
124
+ const shown = all.slice(start, start + 8);
125
+ const rows = [
126
+ colors.fg("accent", "Tasks") + colors.fg("dim", ` · ${done}/${total} done` + (done === total ? " · all complete" : "") + " · /jar tasks"),
127
+ ...(start > 0 ? [colors.fg("dim", ` … ${start} earlier`)] : []),
128
+ ...shown.map((item) => todoRow(item, (color, text) => colors.fg(color, text), (text) => colors.bold(text), parents.has(item.id) ? todoProgress(all, item.id) : undefined)),
129
+ ...(start + shown.length < all.length ? [colors.fg("dim", ` … +${all.length - start - shown.length} more`)] : [])
130
+ ];
131
+ // Pi inserts a spacer before widgets, but not between widgets and the composer.
132
+ const lines = [...rows.map((line) => truncateToWidth(line, Math.max(0, width))), truncateToWidth(" ", Math.max(0, width))];
133
+ memo = { width, sample, lines };
134
+ return lines.slice();
135
+ }
136
+ };
137
+ });
113
138
  } catch { /* Optional widget; task data remains available via /jar tasks and jar_todo. */ }
114
139
  };
115
140
  registerTaskTool(pi, () => todos, (ctx) => updateTaskWidget(ctx));
@@ -131,12 +156,71 @@ export default function piJar(pi: ExtensionAPI): void {
131
156
  { triggerTurn: true, deliverAs: "followUp" });
132
157
  } catch (error) { console.error("pi-jar: could not deliver shell event", error); }
133
158
  };
134
- /** Footer indicators owned by pi-jar. */
135
- const footerChips = () => {
136
- const running = shells?.running() ?? 0;
137
- return [...(changeCount ? [`± ${changeCount} file${changeCount === 1 ? "" : "s"} · /diff`] : []),
138
- ...(running ? [`⚙ ${running} shell${running === 1 ? "" : "s"}`] : [])];
159
+ /** Footer indicators owned by pi-jar; live subagents and shells get their own rows. */
160
+ const footerChips = () => changeCount ? [withIcon("changes", `${changeCount} file${changeCount === 1 ? "" : "s"} · /diff`)] : [];
161
+ const subagents = new DelegateRegistry();
162
+ subagents.subscribe(() => footerTui?.requestRender());
163
+ /** Finished work stays visible briefly so a quick run is not a flicker. */
164
+ const ACTIVITY_LINGER_MS = 5000;
165
+ const subagentState = (run: DelegateRun): ActivityState => run.state === "working" ? "running" : run.state === "queued" ? "queued"
166
+ : run.state === "done" ? "done" : run.error === "stopped" ? "stopped" : "failed";
167
+ const shellActivityState = (job: Omit<ShellJob, "lines">): ActivityState => job.status === "running" ? "running"
168
+ : job.status === "killed" ? "stopped" : job.status === "exited" && job.exitCode === 0 ? "done" : "failed";
169
+ const seconds = (ms: number) => { const s = Math.max(0, Math.round(ms / 1000)); return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`; };
170
+ const footerActivity = (now: number): FooterActivity[] => {
171
+ const rows: FooterActivity[] = [];
172
+ for (const { key, run } of subagents.records()) {
173
+ if (run.endedAt && now - run.endedAt > ACTIVITY_LINGER_MS) continue;
174
+ rows.push({ kind: "subagent", id: key, name: run.name, state: subagentState(run),
175
+ detail: run.error ?? run.activity ?? cleanText(run.task, 120),
176
+ stats: [run.tools ? `${run.tools} tools` : "", run.startedAt ? seconds((run.endedAt ?? now) - run.startedAt) : ""].filter(Boolean).join(" · ") });
177
+ }
178
+ for (const job of shells?.summaries() ?? []) {
179
+ if (job.endedAt && now - job.endedAt > ACTIVITY_LINGER_MS) continue;
180
+ // Shell output does not repaint the footer, so show stable facts rather than a stale clock.
181
+ rows.push({ kind: "shell", id: job.id, name: `${job.id} ${job.name}`, state: shellActivityState(job), detail: job.command,
182
+ ...(job.watch ? { stats: `watch /${cleanText(job.watch, 20)}/` + (job.matched ? " ✓" : "") } : {}) });
183
+ }
184
+ return rows;
185
+ };
186
+ let overlayOpen = false;
187
+ /** Subagents, shells and other extensions' roles in one live split view. */
188
+ const openActivity = async (ctx: ExtensionContext, initial?: ActivityTarget) => {
189
+ if (!ctx.hasUI || ctx.mode !== "tui" || overlayOpen) return;
190
+ overlayOpen = true;
191
+ try {
192
+ await openActivityView(ctx, {
193
+ subagents, ...(shells ? { shells } : {}),
194
+ roles: () => collectStatuses(welcomeStatuses(), Date.now()).roles.filter((role) => !role.id.startsWith("delegate-"))
195
+ .map((role) => ({ id: role.id, name: role.name, state: role.state, ...(role.task ? { task: role.task } : {}) }))
196
+ }, initial);
197
+ } finally { overlayOpen = false; }
139
198
  };
199
+ /** Searchable project sessions with a details pane; Enter resumes the selected one. */
200
+ const openSessions = async (ctx: ExtensionContext | ExtensionCommandContext, query = "") => {
201
+ if (!ctx.hasUI || ctx.mode !== "tui") { ctx.ui.notify("Session search requires the interactive TUI", "warning"); return; }
202
+ if (overlayOpen) return;
203
+ let sessions;
204
+ try { sessions = (await SessionManager.list(ctx.cwd)).sort((a, b) => b.modified.getTime() - a.modified.getTime()); }
205
+ catch (error) { ctx.ui.notify("Could not search sessions: " + String(error), "error"); return; }
206
+ if (!sessions.length) { ctx.ui.notify("No sessions found for this project", "info"); return; }
207
+ overlayOpen = true;
208
+ let selected: string | undefined;
209
+ try { selected = await pickSession(ctx, sessions, query, (session) => sessionDetails(session.path)); }
210
+ finally { overlayOpen = false; }
211
+ if (!selected || selected === ctx.sessionManager.getSessionFile()) return;
212
+ if (!("switchSession" in ctx)) {
213
+ // Clicks and shortcuts get a plain context; switching needs a command, so stage it.
214
+ ctx.ui.setEditorText(`/jar resume ${selected}`);
215
+ ctx.ui.notify("Press Enter to resume that session", "info");
216
+ return;
217
+ }
218
+ // Never access the old command context after a successful switch.
219
+ const result = await ctx.switchSession(selected);
220
+ if (result.cancelled) ctx.ui.notify("Session switch cancelled", "info");
221
+ };
222
+ const openFooterTarget = (ctx: ExtensionContext, target: FooterTarget) => target.kind === "session" ? openSessions(ctx)
223
+ : openActivity(ctx, target.kind === "activity" ? undefined : target);
140
224
  registerAskTool(pi);
141
225
  const modelRoles = new ModelRoleManager(pi);
142
226
  const sideUsage = new SideUsage();
@@ -144,7 +228,7 @@ export default function piJar(pi: ExtensionAPI): void {
144
228
  registerInfoPanels(pi, { side: sideUsage, quotaEnabled: () => quotaCache?.enabled ?? false,
145
229
  quota: (ctx) => quotaCache?.get(ctx.model?.provider, welcomeStatuses(), Date.now()) });
146
230
  modelRoles.register((ctx) => openRolesUi(ctx, modelRoles));
147
- registerDelegate(pi, modelRoles);
231
+ registerDelegate(pi, modelRoles, subagents, { changes: () => changes, changed: refreshChanges });
148
232
  const planMode = new PlanMode(pi, () => todos, modelRoles, updateTaskWidget);
149
233
  planMode.register();
150
234
  const goalLoop = new GoalLoop(pi, {
@@ -357,9 +441,9 @@ export default function piJar(pi: ExtensionAPI): void {
357
441
  let expiryAt: number | undefined;
358
442
  let memory = `ram ${Math.round(process.memoryUsage.rss() / 1048576)} MiB`;
359
443
  let memorySampledAt = 0;
360
- let context = "ctx ?";
361
- let contextSampledAt = 0;
362
444
  let disposed = false;
445
+ let hits: FooterHit[] = [];
446
+ let pressed: { target: FooterTarget; at: number } | undefined;
363
447
  const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
364
448
  const dispose = () => {
365
449
  if (disposed) return;
@@ -374,12 +458,26 @@ export default function piJar(pi: ExtensionAPI): void {
374
458
  return {
375
459
  dispose,
376
460
  invalidate() {},
461
+ handleMouse(event: TuiMouseEvent) {
462
+ if (event.button !== "left") return;
463
+ const hit = hits.find((item) => item.y === event.y && event.x >= item.x0 && event.x < item.x1);
464
+ if (!hit) return;
465
+ if (event.type !== "press" && event.type !== "click") return { handled: true };
466
+ // Act on press (click synthesis is not guaranteed through multiplexers); drop the echo click.
467
+ const now = Date.now();
468
+ const same = pressed && JSON.stringify(pressed.target) === JSON.stringify(hit.target);
469
+ if (event.type === "click" && same && now - pressed!.at < WELCOME_CLICK_DEDUPE_MS) return { handled: true };
470
+ pressed = { target: hit.target, at: now };
471
+ queueMicrotask(() => { void openFooterTarget(ctx, hit.target).catch((error) => ctx.ui.notify("pi-jar: " + String(error), "error")); });
472
+ return { handled: true, capture: event.type === "press" };
473
+ },
377
474
  render(width: number): string[] {
378
475
  try {
379
476
  const now = Date.now();
380
477
  const statuses = footerData.getExtensionStatuses();
381
478
  const live = collectStatuses(statuses, now);
382
- const roles: JarRole[] = demo ? createDemoRoles() : live.roles;
479
+ // jar_delegate runs get their own activity rows; other extensions' roles stay as chips.
480
+ const roles: JarRole[] = demo ? createDemoRoles() : live.roles.filter((role) => !role.id.startsWith("delegate-"));
383
481
  const nearest = demo ? undefined : roles.reduce<number | undefined>((min, role) =>
384
482
  role.expiresAt != null ? Math.min(min ?? Infinity, role.expiresAt) : min, undefined);
385
483
  if (nearest !== expiryAt) {
@@ -394,27 +492,25 @@ export default function piJar(pi: ExtensionAPI): void {
394
492
  // Do not schedule cosmetic footer repaints. Role state still updates on Pi's native
395
493
  // stream/tool renders, which keeps long transcripts responsive.
396
494
  const active = animations && footerSettings.roles && roles.some((role) => ACTIVE_STATES.has(role.state));
397
- if (active) frame++;
398
- if (now - contextSampledAt >= 1000) {
399
- const usage = ctx.getContextUsage();
400
- context = usage?.percent == null || !Number.isFinite(usage.percent)
401
- ? "ctx ?" : `ctx ${Math.round(usage.percent)}%`;
402
- contextSampledAt = now;
403
- }
495
+ // Role glyphs advance with time, not with the render rate, so the footer memo still hits between steps.
496
+ if (active) frame = Math.floor(now / 250);
404
497
  if (now - memorySampledAt >= 5000) {
405
498
  memory = `ram ${Math.round(process.memoryUsage.rss() / 1048576)} MiB`;
406
499
  memorySampledAt = now;
407
500
  }
408
501
  const quota = footerSettings.quota ? quotaCache?.get(ctx.model?.provider, statuses, now) : undefined;
409
- return renderFooter({
502
+ const layout = renderFooterLayout({
410
503
  model: ctx.model?.id ?? "no-model", effort: ctx.model?.reasoning === false ? "off" : (pi.getThinkingLevel?.() ?? "off"),
411
- sessionName: ctx.sessionManager?.getSessionName?.(),
504
+ sessionName: footerSessionName,
412
505
  cwd: ctx.cwd, settings: footerSettings, branch: footerData.getGitBranch(),
413
- context, goal: goalLoop.progress(), chips: footerChips(),
506
+ context: footerContext, goal: goalLoop.progress(), chips: footerChips(), activity: footerActivity(now),
414
507
  memory, cost: formatCost(cost), quota, roles, extras: live.extras,
415
508
  demo, animations, frame, motionBudget: ctx.isIdle() ? 2 : 1
416
509
  }, width, ctx.ui.theme ?? theme);
510
+ hits = layout.hits;
511
+ return layout.lines;
417
512
  } catch {
513
+ hits = [];
418
514
  if (expiryTimer) clearTimeout(expiryTimer);
419
515
  expiryTimer = undefined;
420
516
  expiryAt = undefined;
@@ -438,6 +534,7 @@ export default function piJar(pi: ExtensionAPI): void {
438
534
  return;
439
535
  }
440
536
  visualSettings = next;
537
+ setIconSet(next.icons);
441
538
  enabled = next.ui;
442
539
  animations = next.animations;
443
540
  if (footerSettings.quota && !next.footer.quota) quotaCache?.stop();
@@ -492,6 +589,7 @@ export default function piJar(pi: ExtensionAPI): void {
492
589
  sideUsage.clear();
493
590
  migrateLegacySettings(getAgentDir());
494
591
  visualSettings = loadVisualSettings(getAgentDir());
592
+ setIconSet(visualSettings.icons);
495
593
  animations = visualSettings.animations;
496
594
  enabled = visualSettings.ui;
497
595
  footerSettings = visualSettings.footer;
@@ -504,6 +602,7 @@ export default function piJar(pi: ExtensionAPI): void {
504
602
  changes = new ChangeTracker(() => ctx.cwd);
505
603
  changeCount = 0;
506
604
  shells?.dispose();
605
+ subagents.clear();
507
606
  shells = new ShellManager(onShellEvent);
508
607
  shells.onChange = () => footerTui?.requestRender();
509
608
  goals = new GoalStore((entry) => pi.appendEntry(GOAL_ENTRY, entry));
@@ -524,6 +623,7 @@ export default function piJar(pi: ExtensionAPI): void {
524
623
  quotaDisabledByUser = false;
525
624
  updateCost(ctx);
526
625
  installUi(ctx);
626
+ sampleFooter(ctx);
527
627
  composer.setMascot(visualSettings.mascot);
528
628
  if (visualSettings.composer && enabled) composer.enable(ctx);
529
629
  suggest.sync();
@@ -538,7 +638,10 @@ export default function piJar(pi: ExtensionAPI): void {
538
638
  }
539
639
  // Resumed sessions already have a transcript to paint. Rebuilding the animated welcome and
540
640
  // scanning other session files competes with that expensive initial render for no real benefit.
541
- if (branch.length === 0) showWelcome(ctx);
641
+ // A fresh session is not empty: Pi records the startup thinking level/model as entries.
642
+ const resumed = branch.some((entry) => !(typeof entry === "object" && entry !== null && "type" in entry
643
+ && typeof entry.type === "string" && SESSION_SETUP_ENTRIES.has(entry.type)));
644
+ if (!resumed) showWelcome(ctx);
542
645
  });
543
646
  // Any real prompt dismisses the welcome. Slash commands bypass `input`, so `agent_start` covers them.
544
647
  const dismissWelcome = (ctx: ExtensionContext) => {
@@ -561,6 +664,8 @@ export default function piJar(pi: ExtensionAPI): void {
561
664
  pi.on("agent_start", (_event, ctx) => { dismissWelcome(ctx); working.start(); applyWorking(ctx); });
562
665
  pi.on("turn_start", (_event, ctx) => { working.start(); applyWorking(ctx); });
563
666
  pi.on("message_end", (event, ctx) => {
667
+ // Every message (user, assistant, tool result) moves the context window.
668
+ sampleFooter(ctx);
564
669
  if (event.message.role === "assistant") {
565
670
  working.reportOutputTokens(event.message.usage?.output ?? 0);
566
671
  addMessageCost(event.message);
@@ -583,11 +688,12 @@ export default function piJar(pi: ExtensionAPI): void {
583
688
  restoreGoal(ctx, branch);
584
689
  updateCost(ctx);
585
690
  composer.refreshSession(ctx);
691
+ sampleFooter(ctx);
586
692
  });
587
- pi.on("session_compact", (_event, ctx) => { restoreTodos(ctx); restoreGoal(ctx); updateCost(ctx); });
588
- pi.on("model_select", (_event, _ctx) => footerTui?.requestRender());
693
+ pi.on("session_compact", (_event, ctx) => { restoreTodos(ctx); restoreGoal(ctx); updateCost(ctx); sampleFooter(ctx); });
694
+ pi.on("model_select", (_event, ctx) => sampleFooter(ctx));
589
695
  pi.on("thinking_level_select", (_event, _ctx) => footerTui?.requestRender());
590
- pi.on("session_info_changed", (_event, ctx) => { composer.refreshSession(ctx); footerTui?.requestRender(); });
696
+ pi.on("session_info_changed", (_event, ctx) => { composer.refreshSession(ctx); sampleFooter(ctx); });
591
697
  pi.on("session_shutdown", (_event, ctx) => {
592
698
  clearSessionBranchCache(ctx);
593
699
  stopWelcome(ctx);
@@ -602,6 +708,7 @@ export default function piJar(pi: ExtensionAPI): void {
602
708
  changeCount = 0;
603
709
  shells?.dispose();
604
710
  shells = undefined;
711
+ subagents.clear();
605
712
  goals = undefined;
606
713
  disposeFooter?.();
607
714
  disposeFooter = undefined;
@@ -633,6 +740,10 @@ export default function piJar(pi: ExtensionAPI): void {
633
740
  description: "Cycle pi-jar model roles",
634
741
  handler: async (ctx) => { await modelRoles.cycle(ctx); footerTui?.requestRender(); }
635
742
  });
743
+ pi.registerShortcut?.(Key.ctrlAlt("a"), {
744
+ description: "Subagents and background shells (pi-jar)",
745
+ handler: async (ctx) => { await openActivity(ctx); }
746
+ });
636
747
  pi.registerShortcut?.(Key.ctrlAlt("s"), {
637
748
  description: "Open pi-jar settings",
638
749
  handler: async (ctx) => {
@@ -656,25 +767,24 @@ export default function piJar(pi: ExtensionAPI): void {
656
767
  }
657
768
  // `/jar resume` without a number opens the same searchable picker.
658
769
  if (command === "sessions" || command.startsWith("sessions ") || command === "resume") {
659
- if (!ctx.hasUI || ctx.mode !== "tui") { ctx.ui.notify("Session search requires the interactive TUI", "warning"); return; }
660
- let sessions;
661
- try { sessions = (await SessionManager.list(ctx.cwd)).sort((a, b) => b.modified.getTime() - a.modified.getTime()); }
662
- catch (error) { ctx.ui.notify("Could not search sessions: " + String(error), "error"); return; }
663
- if (!sessions.length) { ctx.ui.notify("No sessions found for this project", "info"); return; }
664
- const selected = await pickSession(ctx, sessions, command === "resume" ? "" : args.trim().slice(8).trim());
665
- if (selected && selected !== ctx.sessionManager.getSessionFile()) {
666
- // Never access the old command context after a successful switch.
667
- const result = await ctx.switchSession(selected);
668
- if (result.cancelled) ctx.ui.notify("Session switch cancelled", "info");
669
- }
770
+ await openSessions(ctx, command === "resume" ? "" : args.trim().slice(8).trim());
670
771
  return;
671
772
  }
672
773
  if (command.startsWith("resume ")) {
673
- const index = Number(command.slice(7).trim());
774
+ const arg = args.trim().slice(7).trim();
775
+ const index = Number(arg);
674
776
  let recent = welcomeRecent;
675
- try { if (!recent.length) recent = await loadRecent(ctx); } catch (error) { ctx.ui.notify("Could not list sessions: " + String(error), "error"); return; }
676
- const target = Number.isInteger(index) && index >= 1 ? recent[index - 1] : undefined;
677
- if (!target) { ctx.ui.notify(`No recent session ${command.slice(7).trim()}; try /jar sessions`, "warning"); return; }
777
+ let target: { path: string } | undefined;
778
+ try {
779
+ if (arg.endsWith(".jsonl")) {
780
+ // Clicks stage a path in the editor; restrict it to Pi's sessions for this project.
781
+ target = (await SessionManager.list(ctx.cwd)).find((session) => session.path === arg);
782
+ } else {
783
+ if (!recent.length) recent = await loadRecent(ctx);
784
+ target = Number.isInteger(index) && index >= 1 ? recent[index - 1] : undefined;
785
+ }
786
+ } catch (error) { ctx.ui.notify("Could not list sessions: " + String(error), "error"); return; }
787
+ if (!target) { ctx.ui.notify(`No recent session ${arg}; try /jar sessions`, "warning"); return; }
678
788
  const result = await ctx.switchSession(target.path);
679
789
  if (result.cancelled) ctx.ui.notify("Session switch cancelled", "info");
680
790
  return;
@@ -769,10 +879,23 @@ export default function piJar(pi: ExtensionAPI): void {
769
879
  }
770
880
  if (command === "welcome") { showWelcome(ctx); return; }
771
881
  if (command === "commit" || command.startsWith("commit ")) { await jarCommit(pi, ctx, modelRoles, sideUsage, args.trim().slice(6).trim()); return; }
772
- if (command === "shells") {
773
- if (!shells) return;
774
- if (!ctx.hasUI || ctx.mode !== "tui") { ctx.ui.notify(shells.summaries().map((job) => `${job.id} ${job.name} ${job.status}`).join("\n") || "No background shells", "info"); return; }
775
- await openShellsView(ctx, shells);
882
+ if (command === "shells" || command === "activity" || command === "agents") {
883
+ if (!ctx.hasUI || ctx.mode !== "tui") {
884
+ const lines = [...subagents.records().map(({ run }) => `${run.name} ${run.state}`), ...(shells?.summaries() ?? []).map((job) => `${job.id} ${job.name} ${job.status}`)];
885
+ ctx.ui.notify(lines.join("\n") || "Nothing running", "info");
886
+ return;
887
+ }
888
+ const shell = command === "shells" ? shells?.summaries().find((job) => job.status === "running") ?? shells?.summaries()[0] : undefined;
889
+ await openActivity(ctx, shell ? { kind: "shell", id: shell.id } : undefined);
890
+ return;
891
+ }
892
+ if (command === "icons" || command.startsWith("icons ")) {
893
+ const requested = command.slice(5).trim();
894
+ const next = ICON_SETS.find((set) => set === requested)
895
+ ?? (requested ? undefined : ICON_SETS[(ICON_SETS.indexOf(visualSettings.icons) + 1) % ICON_SETS.length]);
896
+ if (!next) { ctx.ui.notify(`Icons: ${ICON_SETS.join(" | ")} (nerd needs a Nerd Font)`, "error"); return; }
897
+ applyVisualSettings({ ...visualSettings, icons: next }, ctx);
898
+ ctx.ui.notify(`pi-jar icons: ${next}`, "info");
776
899
  return;
777
900
  }
778
901
  if (command === "demo") demo = true;
@@ -784,7 +907,7 @@ export default function piJar(pi: ExtensionAPI): void {
784
907
  else if (command === "quota on" && quotaCache) { quotaCache.enabled = true; quotaDisabledByUser = false; }
785
908
  else if (command === "quota off" && quotaCache) { quotaCache.enabled = false; quotaDisabledByUser = true; quotaCache.stop(); }
786
909
  else {
787
- ctx.ui.notify("Usage: /jar [status|settings|commit [note]|sessions [search]|name <title>|history|footer|tasks|ask|composer on/off|accent [preset]|hub|welcome|demo|reset|animations on/off|ui on/off|quota on/off]", "error");
910
+ ctx.ui.notify("Usage: /jar [status|settings|activity|shells|sessions [search]|icons [unicode|nerd|ascii]|commit [note]|name <title>|history|footer|tasks|ask|composer on/off|accent [preset]|hub|welcome|demo|reset|animations on/off|ui on/off|quota on/off]", "error");
788
911
  return;
789
912
  }
790
913
  if (!["animations on", "animations off", "ui on", "ui off"].includes(command)) installUi(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jar",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
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.",
5
5
  "type": "module",
6
6
  "license": "MIT",