pi-jar 0.1.1 → 0.1.3

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,18 +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
33
  import { TASK_ENTRY, TodoStore } 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";
42
+ import { clearSessionBranchCache, sessionBranch } from "../src/session-branch.ts";
40
43
  import { registerChangeReview } from "../src/diff-view.ts";
41
- 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";
42
45
  import { hopefulWelcomeMessage, welcomeHit, welcomeLines, type WelcomeAction } from "../src/welcome.ts";
43
46
 
44
47
  const WELCOME_KEY = "pi-jar.welcome";
@@ -48,6 +51,9 @@ const VERSION = (() => {
48
51
  })();
49
52
  /** Ignore the click Pi may synthesize right after a press we already acted on. */
50
53
  const WELCOME_CLICK_DEDUPE_MS = 400;
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"]);
51
57
  export default function piJar(pi: ExtensionAPI): void {
52
58
  installCompactBuiltinTools(pi);
53
59
  let demo = false;
@@ -74,9 +80,7 @@ export default function piJar(pi: ExtensionAPI): void {
74
80
  let footerSettings = visualSettings.footer;
75
81
  let welcomeStatuses = (): ReadonlyMap<string, string> => new Map();
76
82
  const working = new WorkingState();
77
- let workingClock: ReturnType<typeof setInterval> | undefined;
78
83
  let workingIndicatorKey = "";
79
- const stopWorkingClock = () => { if (workingClock) clearInterval(workingClock); workingClock = undefined; };
80
84
  let openSettings: (ctx: ExtensionContext) => Promise<void> = async () => {};
81
85
  let settingsOpen = false;
82
86
 
@@ -131,12 +135,71 @@ export default function piJar(pi: ExtensionAPI): void {
131
135
  { triggerTurn: true, deliverAs: "followUp" });
132
136
  } catch (error) { console.error("pi-jar: could not deliver shell event", error); }
133
137
  };
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"}`] : [])];
138
+ /** Footer indicators owned by pi-jar; live subagents and shells get their own rows. */
139
+ const footerChips = () => changeCount ? [withIcon("changes", `${changeCount} file${changeCount === 1 ? "" : "s"} · /diff`)] : [];
140
+ const subagents = new DelegateRegistry();
141
+ subagents.subscribe(() => footerTui?.requestRender());
142
+ /** Finished work stays visible briefly so a quick run is not a flicker. */
143
+ const ACTIVITY_LINGER_MS = 5000;
144
+ const subagentState = (run: DelegateRun): ActivityState => run.state === "working" ? "running" : run.state === "queued" ? "queued"
145
+ : run.state === "done" ? "done" : run.error === "stopped" ? "stopped" : "failed";
146
+ const shellActivityState = (job: Omit<ShellJob, "lines">): ActivityState => job.status === "running" ? "running"
147
+ : job.status === "killed" ? "stopped" : job.status === "exited" && job.exitCode === 0 ? "done" : "failed";
148
+ 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`; };
149
+ const footerActivity = (now: number): FooterActivity[] => {
150
+ const rows: FooterActivity[] = [];
151
+ for (const { key, run } of subagents.records()) {
152
+ if (run.endedAt && now - run.endedAt > ACTIVITY_LINGER_MS) continue;
153
+ rows.push({ kind: "subagent", id: key, name: run.name, state: subagentState(run),
154
+ detail: run.error ?? run.activity ?? cleanText(run.task, 120),
155
+ stats: [run.tools ? `${run.tools} tools` : "", run.startedAt ? seconds((run.endedAt ?? now) - run.startedAt) : ""].filter(Boolean).join(" · ") });
156
+ }
157
+ for (const job of shells?.summaries() ?? []) {
158
+ if (job.endedAt && now - job.endedAt > ACTIVITY_LINGER_MS) continue;
159
+ // Shell output does not repaint the footer, so show stable facts rather than a stale clock.
160
+ rows.push({ kind: "shell", id: job.id, name: `${job.id} ${job.name}`, state: shellActivityState(job), detail: job.command,
161
+ ...(job.watch ? { stats: `watch /${cleanText(job.watch, 20)}/` + (job.matched ? " ✓" : "") } : {}) });
162
+ }
163
+ return rows;
164
+ };
165
+ let overlayOpen = false;
166
+ /** Subagents, shells and other extensions' roles in one live split view. */
167
+ const openActivity = async (ctx: ExtensionContext, initial?: ActivityTarget) => {
168
+ if (!ctx.hasUI || ctx.mode !== "tui" || overlayOpen) return;
169
+ overlayOpen = true;
170
+ try {
171
+ await openActivityView(ctx, {
172
+ subagents, ...(shells ? { shells } : {}),
173
+ roles: () => collectStatuses(welcomeStatuses(), Date.now()).roles.filter((role) => !role.id.startsWith("delegate-"))
174
+ .map((role) => ({ id: role.id, name: role.name, state: role.state, ...(role.task ? { task: role.task } : {}) }))
175
+ }, initial);
176
+ } finally { overlayOpen = false; }
177
+ };
178
+ /** Searchable project sessions with a details pane; Enter resumes the selected one. */
179
+ const openSessions = async (ctx: ExtensionContext | ExtensionCommandContext, query = "") => {
180
+ if (!ctx.hasUI || ctx.mode !== "tui") { ctx.ui.notify("Session search requires the interactive TUI", "warning"); return; }
181
+ if (overlayOpen) return;
182
+ let sessions;
183
+ try { sessions = (await SessionManager.list(ctx.cwd)).sort((a, b) => b.modified.getTime() - a.modified.getTime()); }
184
+ catch (error) { ctx.ui.notify("Could not search sessions: " + String(error), "error"); return; }
185
+ if (!sessions.length) { ctx.ui.notify("No sessions found for this project", "info"); return; }
186
+ overlayOpen = true;
187
+ let selected: string | undefined;
188
+ try { selected = await pickSession(ctx, sessions, query, (session) => sessionDetails(session.path)); }
189
+ finally { overlayOpen = false; }
190
+ if (!selected || selected === ctx.sessionManager.getSessionFile()) return;
191
+ if (!("switchSession" in ctx)) {
192
+ // Clicks and shortcuts get a plain context; switching needs a command, so stage it.
193
+ ctx.ui.setEditorText(`/jar resume ${selected}`);
194
+ ctx.ui.notify("Press Enter to resume that session", "info");
195
+ return;
196
+ }
197
+ // Never access the old command context after a successful switch.
198
+ const result = await ctx.switchSession(selected);
199
+ if (result.cancelled) ctx.ui.notify("Session switch cancelled", "info");
139
200
  };
201
+ const openFooterTarget = (ctx: ExtensionContext, target: FooterTarget) => target.kind === "session" ? openSessions(ctx)
202
+ : openActivity(ctx, target.kind === "activity" ? undefined : target);
140
203
  registerAskTool(pi);
141
204
  const modelRoles = new ModelRoleManager(pi);
142
205
  const sideUsage = new SideUsage();
@@ -144,7 +207,7 @@ export default function piJar(pi: ExtensionAPI): void {
144
207
  registerInfoPanels(pi, { side: sideUsage, quotaEnabled: () => quotaCache?.enabled ?? false,
145
208
  quota: (ctx) => quotaCache?.get(ctx.model?.provider, welcomeStatuses(), Date.now()) });
146
209
  modelRoles.register((ctx) => openRolesUi(ctx, modelRoles));
147
- registerDelegate(pi, modelRoles);
210
+ registerDelegate(pi, modelRoles, subagents);
148
211
  const planMode = new PlanMode(pi, () => todos, modelRoles, updateTaskWidget);
149
212
  planMode.register();
150
213
  const goalLoop = new GoalLoop(pi, {
@@ -158,8 +221,7 @@ export default function piJar(pi: ExtensionAPI): void {
158
221
  const suggestions = new SuggestionState();
159
222
  composer.attachSuggestions(suggestions);
160
223
  const suggest = registerSuggestions(pi, suggestions, {
161
- enabled: () => enabled && visualSettings.composer && visualSettings.suggestions && composer.enabled,
162
- skip: () => planMode.isEnabled() || !!goals?.isActive()
224
+ enabled: () => enabled && visualSettings.composer && visualSettings.suggestions && composer.enabled
163
225
  });
164
226
  let liveTui: { setCopyOnSelect?: (enabled: boolean) => void } | undefined;
165
227
  /** Pi's own settings (TUI mode, copy-on-select); written through Pi's SettingsManager. */
@@ -175,8 +237,6 @@ export default function piJar(pi: ExtensionAPI): void {
175
237
  };
176
238
 
177
239
  const applyWorking = (ctx: ExtensionContext) => {
178
- const active = enabled && working.phase !== "idle";
179
- if (!active || !ctx.hasUI || ctx.mode !== "tui") stopWorkingClock();
180
240
  if (!ctx.hasUI || ctx.mode !== "tui") return;
181
241
  composer.setActivity(enabled ? working.phase : "idle", animations);
182
242
  try {
@@ -187,14 +247,12 @@ export default function piJar(pi: ExtensionAPI): void {
187
247
  ctx.ui.setWorkingMessage?.(view.message);
188
248
  const indicatorKey = `${working.phase}:${animations}`;
189
249
  if (indicatorKey !== workingIndicatorKey) {
190
- ctx.ui.setWorkingIndicator({ frames: view.frames, intervalMs: 600 });
250
+ // Pi's normal streaming/tool lifecycle already causes renders. A static indicator avoids
251
+ // forcing the entire transcript to repaint on a timer in long sessions.
252
+ ctx.ui.setWorkingIndicator({ frames: [view.frames[0] ?? "✢"] });
191
253
  workingIndicatorKey = indicatorKey;
192
254
  }
193
- if (active && !workingClock) {
194
- workingClock = setInterval(() => applyWorking(ctx), 1000);
195
- workingClock.unref?.();
196
- }
197
- } catch { stopWorkingClock(); /* Working decoration must never interrupt a Pi turn. */ }
255
+ } catch { /* Working decoration must never interrupt a Pi turn. */ }
198
256
  };
199
257
 
200
258
  const stopWelcome = (ctx?: ExtensionContext) => {
@@ -358,32 +416,22 @@ export default function piJar(pi: ExtensionAPI): void {
358
416
  welcomeStatuses = () => footerData.getExtensionStatuses();
359
417
  welcomeBranch = () => footerData.getGitBranch();
360
418
  let frame = 0;
361
- let memory = `ram ${Math.round(process.memoryUsage.rss() / 1048576)} MiB`;
362
- let memoryTimer: ReturnType<typeof setTimeout> | undefined;
363
- const MEMORY_SAMPLE_MS = 10_000;
364
- const sampleMemory = () => {
365
- if (disposed) return;
366
- const next = `ram ${Math.round(process.memoryUsage.rss() / 1048576)} MiB`;
367
- if (next !== memory) { memory = next; tui.requestRender(); }
368
- memoryTimer = setTimeout(sampleMemory, MEMORY_SAMPLE_MS);
369
- memoryTimer.unref?.();
370
- };
371
- if (footerSettings.memory) {
372
- memoryTimer = setTimeout(sampleMemory, MEMORY_SAMPLE_MS);
373
- memoryTimer.unref?.();
374
- }
375
- let timer: ReturnType<typeof setInterval> | undefined;
376
419
  let expiryTimer: ReturnType<typeof setTimeout> | undefined;
420
+ let expiryAt: number | undefined;
421
+ let memory = `ram ${Math.round(process.memoryUsage.rss() / 1048576)} MiB`;
422
+ let memorySampledAt = 0;
423
+ let context = "ctx ?";
424
+ let contextSampledAt = 0;
377
425
  let disposed = false;
426
+ let hits: FooterHit[] = [];
427
+ let pressed: { target: FooterTarget; at: number } | undefined;
378
428
  const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
379
429
  const dispose = () => {
380
430
  if (disposed) return;
381
431
  disposed = true;
382
- if (memoryTimer) clearTimeout(memoryTimer);
383
- if (timer) clearInterval(timer);
384
432
  if (expiryTimer) clearTimeout(expiryTimer);
385
- timer = undefined;
386
433
  expiryTimer = undefined;
434
+ expiryAt = undefined;
387
435
  if (footerTui === tui) { footerTui = undefined; welcomeStatuses = () => new Map<string, string>(); welcomeBranch = () => null; }
388
436
  unsubscribe();
389
437
  };
@@ -391,38 +439,67 @@ export default function piJar(pi: ExtensionAPI): void {
391
439
  return {
392
440
  dispose,
393
441
  invalidate() {},
442
+ handleMouse(event: TuiMouseEvent) {
443
+ if (event.button !== "left") return;
444
+ const hit = hits.find((item) => item.y === event.y && event.x >= item.x0 && event.x < item.x1);
445
+ if (!hit) return;
446
+ if (event.type !== "press" && event.type !== "click") return { handled: true };
447
+ // Act on press (click synthesis is not guaranteed through multiplexers); drop the echo click.
448
+ const now = Date.now();
449
+ const same = pressed && JSON.stringify(pressed.target) === JSON.stringify(hit.target);
450
+ if (event.type === "click" && same && now - pressed!.at < WELCOME_CLICK_DEDUPE_MS) return { handled: true };
451
+ pressed = { target: hit.target, at: now };
452
+ queueMicrotask(() => { void openFooterTarget(ctx, hit.target).catch((error) => ctx.ui.notify("pi-jar: " + String(error), "error")); });
453
+ return { handled: true, capture: event.type === "press" };
454
+ },
394
455
  render(width: number): string[] {
395
456
  try {
396
457
  const now = Date.now();
397
458
  const statuses = footerData.getExtensionStatuses();
398
459
  const live = collectStatuses(statuses, now);
399
- const roles: JarRole[] = demo ? createDemoRoles() : live.roles;
400
- if (expiryTimer) clearTimeout(expiryTimer);
401
- expiryTimer = undefined;
460
+ // jar_delegate runs get their own activity rows; other extensions' roles stay as chips.
461
+ const roles: JarRole[] = demo ? createDemoRoles() : live.roles.filter((role) => !role.id.startsWith("delegate-"));
402
462
  const nearest = demo ? undefined : roles.reduce<number | undefined>((min, role) =>
403
463
  role.expiresAt != null ? Math.min(min ?? Infinity, role.expiresAt) : min, undefined);
404
- if (nearest != null) expiryTimer = setTimeout(() => tui.requestRender(), Math.max(1, nearest - now));
464
+ if (nearest !== expiryAt) {
465
+ if (expiryTimer) clearTimeout(expiryTimer);
466
+ expiryTimer = undefined;
467
+ expiryAt = nearest;
468
+ if (nearest != null) {
469
+ expiryTimer = setTimeout(() => { expiryTimer = undefined; expiryAt = undefined; tui.requestRender(); }, Math.max(1, nearest - now));
470
+ expiryTimer.unref?.();
471
+ }
472
+ }
473
+ // Do not schedule cosmetic footer repaints. Role state still updates on Pi's native
474
+ // stream/tool renders, which keeps long transcripts responsive.
405
475
  const active = animations && footerSettings.roles && roles.some((role) => ACTIVE_STATES.has(role.state));
406
- if (active && !timer) {
407
- timer = setInterval(() => { frame += 1; tui.requestRender(); }, 600);
408
- timer.unref?.();
409
- } else if (!active && timer) { clearInterval(timer); timer = undefined; }
410
- const usage = ctx.getContextUsage();
411
- const context = usage?.percent == null || !Number.isFinite(usage.percent)
412
- ? "ctx ?" : `ctx ${Math.round(usage.percent)}%`;
476
+ if (active) frame++;
477
+ if (now - contextSampledAt >= 1000) {
478
+ const usage = ctx.getContextUsage();
479
+ context = usage?.percent == null || !Number.isFinite(usage.percent)
480
+ ? "ctx ?" : `ctx ${Math.round(usage.percent)}%`;
481
+ contextSampledAt = now;
482
+ }
483
+ if (now - memorySampledAt >= 5000) {
484
+ memory = `ram ${Math.round(process.memoryUsage.rss() / 1048576)} MiB`;
485
+ memorySampledAt = now;
486
+ }
413
487
  const quota = footerSettings.quota ? quotaCache?.get(ctx.model?.provider, statuses, now) : undefined;
414
- return renderFooter({
488
+ const layout = renderFooterLayout({
415
489
  model: ctx.model?.id ?? "no-model", effort: ctx.model?.reasoning === false ? "off" : (pi.getThinkingLevel?.() ?? "off"),
416
490
  sessionName: ctx.sessionManager?.getSessionName?.(),
417
491
  cwd: ctx.cwd, settings: footerSettings, branch: footerData.getGitBranch(),
418
- context, goal: goalLoop.progress(), chips: footerChips(), memory, cost: formatCost(cost), quota, roles, extras: live.extras,
492
+ context, goal: goalLoop.progress(), chips: footerChips(), activity: footerActivity(now),
493
+ memory, cost: formatCost(cost), quota, roles, extras: live.extras,
419
494
  demo, animations, frame, motionBudget: ctx.isIdle() ? 2 : 1
420
495
  }, width, ctx.ui.theme ?? theme);
496
+ hits = layout.hits;
497
+ return layout.lines;
421
498
  } catch {
422
- if (timer) clearInterval(timer);
499
+ hits = [];
423
500
  if (expiryTimer) clearTimeout(expiryTimer);
424
- timer = undefined;
425
501
  expiryTimer = undefined;
502
+ expiryAt = undefined;
426
503
  return width > 0 ? ["pi-jar: UI unavailable (run /jar ui off)".slice(0, width)] : [];
427
504
  }
428
505
  }
@@ -443,6 +520,7 @@ export default function piJar(pi: ExtensionAPI): void {
443
520
  return;
444
521
  }
445
522
  visualSettings = next;
523
+ setIconSet(next.icons);
446
524
  enabled = next.ui;
447
525
  animations = next.animations;
448
526
  if (footerSettings.quota && !next.footer.quota) quotaCache?.stop();
@@ -481,24 +559,28 @@ export default function piJar(pi: ExtensionAPI): void {
481
559
  try { cost = sessionCost(ctx); } catch { cost = 0; }
482
560
  footerTui?.requestRender();
483
561
  };
484
- const restoreTodos = (ctx: ExtensionContext) => {
485
- try { todos?.restore(ctx.sessionManager.getBranch()); } catch { todos?.restore([]); }
562
+ const addMessageCost = (message: { usage?: { cost?: { total?: number } } }) => {
563
+ const value = message.usage?.cost?.total;
564
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) cost += value;
565
+ };
566
+ const restoreTodos = (ctx: ExtensionContext, branch?: readonly unknown[]) => {
567
+ try { todos?.restore(branch ?? sessionBranch(ctx)); } catch { todos?.restore([]); }
486
568
  updateTaskWidget(ctx);
487
569
  };
488
- const restoreGoal = (ctx: ExtensionContext) => {
489
- try { goals?.restore(ctx.sessionManager.getBranch()); } catch { goals?.restore([]); }
570
+ const restoreGoal = (ctx: ExtensionContext, branch?: readonly unknown[]) => {
571
+ try { goals?.restore(branch ?? sessionBranch(ctx)); } catch { goals?.restore([]); }
490
572
  footerTui?.requestRender();
491
573
  };
492
574
  pi.on("session_start", (_event, ctx) => {
493
575
  sideUsage.clear();
494
576
  migrateLegacySettings(getAgentDir());
495
577
  visualSettings = loadVisualSettings(getAgentDir());
578
+ setIconSet(visualSettings.icons);
496
579
  animations = visualSettings.animations;
497
580
  enabled = visualSettings.ui;
498
581
  footerSettings = visualSettings.footer;
499
582
  demo = false;
500
583
  composer.disable(ctx);
501
- stopWorkingClock();
502
584
  workingIndicatorKey = "";
503
585
  working.end();
504
586
  quotaCache?.stop();
@@ -506,11 +588,14 @@ export default function piJar(pi: ExtensionAPI): void {
506
588
  changes = new ChangeTracker(() => ctx.cwd);
507
589
  changeCount = 0;
508
590
  shells?.dispose();
591
+ subagents.clear();
509
592
  shells = new ShellManager(onShellEvent);
510
593
  shells.onChange = () => footerTui?.requestRender();
511
594
  goals = new GoalStore((entry) => pi.appendEntry(GOAL_ENTRY, entry));
512
- restoreTodos(ctx);
513
- restoreGoal(ctx);
595
+ let branch: readonly unknown[] = [];
596
+ try { branch = sessionBranch(ctx); } catch { /* keep empty */ }
597
+ restoreTodos(ctx, branch);
598
+ restoreGoal(ctx, branch);
514
599
  // Quota is enabled per session; no credentials or consent are persisted.
515
600
  quotaCache = new QuotaCache(
516
601
  (provider: QuotaProvider, signal) => fetchQuota(provider, (id) => ctx.modelRegistry.getProviderAuth(id), signal),
@@ -528,7 +613,20 @@ export default function piJar(pi: ExtensionAPI): void {
528
613
  if (visualSettings.composer && enabled) composer.enable(ctx);
529
614
  suggest.sync();
530
615
  if (visualSettings.accent !== "follow") selectAccent(ctx, visualSettings.accent);
531
- showWelcome(ctx);
616
+ if (branch.length >= LARGE_SESSION_ENTRIES) {
617
+ try {
618
+ const prefs = piPreferences(ctx)?.get();
619
+ if (prefs && !prefs.fullscreen) {
620
+ ctx.ui.notify(`Large session (${branch.length} entries): regular TUI redraw cost grows with history. Enable Fullscreen in /jar settings for smoother rendering.`, "warning");
621
+ }
622
+ } catch { /* Performance hint is optional. */ }
623
+ }
624
+ // Resumed sessions already have a transcript to paint. Rebuilding the animated welcome and
625
+ // scanning other session files competes with that expensive initial render for no real benefit.
626
+ // A fresh session is not empty: Pi records the startup thinking level/model as entries.
627
+ const resumed = branch.some((entry) => !(typeof entry === "object" && entry !== null && "type" in entry
628
+ && typeof entry.type === "string" && SESSION_SETUP_ENTRIES.has(entry.type)));
629
+ if (!resumed) showWelcome(ctx);
532
630
  });
533
631
  // Any real prompt dismisses the welcome. Slash commands bypass `input`, so `agent_start` covers them.
534
632
  const dismissWelcome = (ctx: ExtensionContext) => {
@@ -553,6 +651,7 @@ export default function piJar(pi: ExtensionAPI): void {
553
651
  pi.on("message_end", (event, ctx) => {
554
652
  if (event.message.role === "assistant") {
555
653
  working.reportOutputTokens(event.message.usage?.output ?? 0);
654
+ addMessageCost(event.message);
556
655
  applyWorking(ctx);
557
656
  }
558
657
  });
@@ -561,19 +660,26 @@ export default function piJar(pi: ExtensionAPI): void {
561
660
  pi.on("ui_prompt_start", (_event, ctx) => { working.prompt(true); applyWorking(ctx); });
562
661
  pi.on("ui_prompt_end", (_event, ctx) => { working.prompt(false); applyWorking(ctx); });
563
662
  // A request may span several tool turns; keep its elapsed time and reported tokens until agent_end.
564
- pi.on("turn_end", (_event, ctx) => { applyWorking(ctx); updateCost(ctx); });
663
+ pi.on("turn_end", (_event, ctx) => { applyWorking(ctx); });
565
664
  pi.on("agent_end", (_event, ctx) => { working.end(); applyWorking(ctx); });
566
665
  pi.on("agent_before_settle", (event) => { if (event.outcome === "error") composer.flash("error"); });
567
666
  pi.on("agent_settled", (_event, ctx) => { working.end(); applyWorking(ctx); });
568
- pi.on("session_tree", (_event, ctx) => { restoreTodos(ctx); restoreGoal(ctx); updateCost(ctx); composer.refreshSession(ctx); });
667
+ pi.on("session_tree", (_event, ctx) => {
668
+ let branch: readonly unknown[] = [];
669
+ try { branch = sessionBranch(ctx); } catch { /* keep empty */ }
670
+ restoreTodos(ctx, branch);
671
+ restoreGoal(ctx, branch);
672
+ updateCost(ctx);
673
+ composer.refreshSession(ctx);
674
+ });
569
675
  pi.on("session_compact", (_event, ctx) => { restoreTodos(ctx); restoreGoal(ctx); updateCost(ctx); });
570
676
  pi.on("model_select", (_event, _ctx) => footerTui?.requestRender());
571
677
  pi.on("thinking_level_select", (_event, _ctx) => footerTui?.requestRender());
572
678
  pi.on("session_info_changed", (_event, ctx) => { composer.refreshSession(ctx); footerTui?.requestRender(); });
573
679
  pi.on("session_shutdown", (_event, ctx) => {
680
+ clearSessionBranchCache(ctx);
574
681
  stopWelcome(ctx);
575
682
  composer.disable(ctx);
576
- stopWorkingClock();
577
683
  workingIndicatorKey = "";
578
684
  working.end();
579
685
  try { if (ctx.hasUI && ctx.mode === "tui") { ctx.ui.setWorkingMessage?.(); ctx.ui.setWorkingIndicator(); ctx.ui.setWidget("pi-jar.todos", undefined); } } catch {}
@@ -584,6 +690,7 @@ export default function piJar(pi: ExtensionAPI): void {
584
690
  changeCount = 0;
585
691
  shells?.dispose();
586
692
  shells = undefined;
693
+ subagents.clear();
587
694
  goals = undefined;
588
695
  disposeFooter?.();
589
696
  disposeFooter = undefined;
@@ -607,7 +714,7 @@ export default function piJar(pi: ExtensionAPI): void {
607
714
  earlier = (await SessionManager.list(ctx.cwd)).filter((session) => session.path !== current)
608
715
  .sort((a, b) => b.modified.getTime() - a.modified.getTime()).slice(0, 50);
609
716
  } catch (error) { ctx.ui.notify("pi-jar: earlier sessions unavailable: " + String(error), "warning"); }
610
- const picked = await openPromptSearch(ctx, collectPrompts(ctx.sessionManager.getEntries(), earlier));
717
+ const picked = await openPromptSearch(ctx, collectPrompts(sessionBranch(ctx), earlier));
611
718
  if (picked !== undefined) ctx.ui.setEditorText(picked);
612
719
  }
613
720
  });
@@ -615,6 +722,10 @@ export default function piJar(pi: ExtensionAPI): void {
615
722
  description: "Cycle pi-jar model roles",
616
723
  handler: async (ctx) => { await modelRoles.cycle(ctx); footerTui?.requestRender(); }
617
724
  });
725
+ pi.registerShortcut?.(Key.ctrlAlt("a"), {
726
+ description: "Subagents and background shells (pi-jar)",
727
+ handler: async (ctx) => { await openActivity(ctx); }
728
+ });
618
729
  pi.registerShortcut?.(Key.ctrlAlt("s"), {
619
730
  description: "Open pi-jar settings",
620
731
  handler: async (ctx) => {
@@ -638,25 +749,24 @@ export default function piJar(pi: ExtensionAPI): void {
638
749
  }
639
750
  // `/jar resume` without a number opens the same searchable picker.
640
751
  if (command === "sessions" || command.startsWith("sessions ") || command === "resume") {
641
- if (!ctx.hasUI || ctx.mode !== "tui") { ctx.ui.notify("Session search requires the interactive TUI", "warning"); return; }
642
- let sessions;
643
- try { sessions = (await SessionManager.list(ctx.cwd)).sort((a, b) => b.modified.getTime() - a.modified.getTime()); }
644
- catch (error) { ctx.ui.notify("Could not search sessions: " + String(error), "error"); return; }
645
- if (!sessions.length) { ctx.ui.notify("No sessions found for this project", "info"); return; }
646
- const selected = await pickSession(ctx, sessions, command === "resume" ? "" : args.trim().slice(8).trim());
647
- if (selected && selected !== ctx.sessionManager.getSessionFile()) {
648
- // Never access the old command context after a successful switch.
649
- const result = await ctx.switchSession(selected);
650
- if (result.cancelled) ctx.ui.notify("Session switch cancelled", "info");
651
- }
752
+ await openSessions(ctx, command === "resume" ? "" : args.trim().slice(8).trim());
652
753
  return;
653
754
  }
654
755
  if (command.startsWith("resume ")) {
655
- const index = Number(command.slice(7).trim());
756
+ const arg = args.trim().slice(7).trim();
757
+ const index = Number(arg);
656
758
  let recent = welcomeRecent;
657
- try { if (!recent.length) recent = await loadRecent(ctx); } catch (error) { ctx.ui.notify("Could not list sessions: " + String(error), "error"); return; }
658
- const target = Number.isInteger(index) && index >= 1 ? recent[index - 1] : undefined;
659
- if (!target) { ctx.ui.notify(`No recent session ${command.slice(7).trim()}; try /jar sessions`, "warning"); return; }
759
+ let target: { path: string } | undefined;
760
+ try {
761
+ if (arg.endsWith(".jsonl")) {
762
+ // Clicks stage a path in the editor; restrict it to Pi's sessions for this project.
763
+ target = (await SessionManager.list(ctx.cwd)).find((session) => session.path === arg);
764
+ } else {
765
+ if (!recent.length) recent = await loadRecent(ctx);
766
+ target = Number.isInteger(index) && index >= 1 ? recent[index - 1] : undefined;
767
+ }
768
+ } catch (error) { ctx.ui.notify("Could not list sessions: " + String(error), "error"); return; }
769
+ if (!target) { ctx.ui.notify(`No recent session ${arg}; try /jar sessions`, "warning"); return; }
660
770
  const result = await ctx.switchSession(target.path);
661
771
  if (result.cancelled) ctx.ui.notify("Session switch cancelled", "info");
662
772
  return;
@@ -751,10 +861,23 @@ export default function piJar(pi: ExtensionAPI): void {
751
861
  }
752
862
  if (command === "welcome") { showWelcome(ctx); return; }
753
863
  if (command === "commit" || command.startsWith("commit ")) { await jarCommit(pi, ctx, modelRoles, sideUsage, args.trim().slice(6).trim()); return; }
754
- if (command === "shells") {
755
- if (!shells) return;
756
- if (!ctx.hasUI || ctx.mode !== "tui") { ctx.ui.notify(shells.list().map((job) => `${job.id} ${job.name} ${job.status}`).join("\n") || "No background shells", "info"); return; }
757
- await openShellsView(ctx, shells);
864
+ if (command === "shells" || command === "activity" || command === "agents") {
865
+ if (!ctx.hasUI || ctx.mode !== "tui") {
866
+ const lines = [...subagents.records().map(({ run }) => `${run.name} ${run.state}`), ...(shells?.summaries() ?? []).map((job) => `${job.id} ${job.name} ${job.status}`)];
867
+ ctx.ui.notify(lines.join("\n") || "Nothing running", "info");
868
+ return;
869
+ }
870
+ const shell = command === "shells" ? shells?.summaries().find((job) => job.status === "running") ?? shells?.summaries()[0] : undefined;
871
+ await openActivity(ctx, shell ? { kind: "shell", id: shell.id } : undefined);
872
+ return;
873
+ }
874
+ if (command === "icons" || command.startsWith("icons ")) {
875
+ const requested = command.slice(5).trim();
876
+ const next = ICON_SETS.find((set) => set === requested)
877
+ ?? (requested ? undefined : ICON_SETS[(ICON_SETS.indexOf(visualSettings.icons) + 1) % ICON_SETS.length]);
878
+ if (!next) { ctx.ui.notify(`Icons: ${ICON_SETS.join(" | ")} (nerd needs a Nerd Font)`, "error"); return; }
879
+ applyVisualSettings({ ...visualSettings, icons: next }, ctx);
880
+ ctx.ui.notify(`pi-jar icons: ${next}`, "info");
758
881
  return;
759
882
  }
760
883
  if (command === "demo") demo = true;
@@ -766,7 +889,7 @@ export default function piJar(pi: ExtensionAPI): void {
766
889
  else if (command === "quota on" && quotaCache) { quotaCache.enabled = true; quotaDisabledByUser = false; }
767
890
  else if (command === "quota off" && quotaCache) { quotaCache.enabled = false; quotaDisabledByUser = true; quotaCache.stop(); }
768
891
  else {
769
- 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");
892
+ 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");
770
893
  return;
771
894
  }
772
895
  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.1",
3
+ "version": "0.1.3",
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",