pi-jar 0.1.1 → 0.1.2

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.
@@ -37,6 +37,7 @@ import { ChangeTracker } from "../src/changes.ts";
37
37
  import { registerDelegate } from "../src/delegate.ts";
38
38
  import { collectPrompts, openPromptSearch } from "../src/prompt-search.ts";
39
39
  import { ago, recentSessions, type RecentSession } from "../src/session-gallery.ts";
40
+ import { clearSessionBranchCache, sessionBranch } from "../src/session-branch.ts";
40
41
  import { registerChangeReview } from "../src/diff-view.ts";
41
42
  import { openShellsView, registerShells, SHELL_MESSAGE, shellEventMessage, ShellManager, type ShellEvent } from "../src/shells.ts";
42
43
  import { hopefulWelcomeMessage, welcomeHit, welcomeLines, type WelcomeAction } from "../src/welcome.ts";
@@ -48,6 +49,7 @@ const VERSION = (() => {
48
49
  })();
49
50
  /** Ignore the click Pi may synthesize right after a press we already acted on. */
50
51
  const WELCOME_CLICK_DEDUPE_MS = 400;
52
+ const LARGE_SESSION_ENTRIES = 800;
51
53
  export default function piJar(pi: ExtensionAPI): void {
52
54
  installCompactBuiltinTools(pi);
53
55
  let demo = false;
@@ -74,9 +76,7 @@ export default function piJar(pi: ExtensionAPI): void {
74
76
  let footerSettings = visualSettings.footer;
75
77
  let welcomeStatuses = (): ReadonlyMap<string, string> => new Map();
76
78
  const working = new WorkingState();
77
- let workingClock: ReturnType<typeof setInterval> | undefined;
78
79
  let workingIndicatorKey = "";
79
- const stopWorkingClock = () => { if (workingClock) clearInterval(workingClock); workingClock = undefined; };
80
80
  let openSettings: (ctx: ExtensionContext) => Promise<void> = async () => {};
81
81
  let settingsOpen = false;
82
82
 
@@ -158,8 +158,7 @@ export default function piJar(pi: ExtensionAPI): void {
158
158
  const suggestions = new SuggestionState();
159
159
  composer.attachSuggestions(suggestions);
160
160
  const suggest = registerSuggestions(pi, suggestions, {
161
- enabled: () => enabled && visualSettings.composer && visualSettings.suggestions && composer.enabled,
162
- skip: () => planMode.isEnabled() || !!goals?.isActive()
161
+ enabled: () => enabled && visualSettings.composer && visualSettings.suggestions && composer.enabled
163
162
  });
164
163
  let liveTui: { setCopyOnSelect?: (enabled: boolean) => void } | undefined;
165
164
  /** Pi's own settings (TUI mode, copy-on-select); written through Pi's SettingsManager. */
@@ -175,8 +174,6 @@ export default function piJar(pi: ExtensionAPI): void {
175
174
  };
176
175
 
177
176
  const applyWorking = (ctx: ExtensionContext) => {
178
- const active = enabled && working.phase !== "idle";
179
- if (!active || !ctx.hasUI || ctx.mode !== "tui") stopWorkingClock();
180
177
  if (!ctx.hasUI || ctx.mode !== "tui") return;
181
178
  composer.setActivity(enabled ? working.phase : "idle", animations);
182
179
  try {
@@ -187,14 +184,12 @@ export default function piJar(pi: ExtensionAPI): void {
187
184
  ctx.ui.setWorkingMessage?.(view.message);
188
185
  const indicatorKey = `${working.phase}:${animations}`;
189
186
  if (indicatorKey !== workingIndicatorKey) {
190
- ctx.ui.setWorkingIndicator({ frames: view.frames, intervalMs: 600 });
187
+ // Pi's normal streaming/tool lifecycle already causes renders. A static indicator avoids
188
+ // forcing the entire transcript to repaint on a timer in long sessions.
189
+ ctx.ui.setWorkingIndicator({ frames: [view.frames[0] ?? "✢"] });
191
190
  workingIndicatorKey = indicatorKey;
192
191
  }
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. */ }
192
+ } catch { /* Working decoration must never interrupt a Pi turn. */ }
198
193
  };
199
194
 
200
195
  const stopWelcome = (ctx?: ExtensionContext) => {
@@ -358,32 +353,20 @@ export default function piJar(pi: ExtensionAPI): void {
358
353
  welcomeStatuses = () => footerData.getExtensionStatuses();
359
354
  welcomeBranch = () => footerData.getGitBranch();
360
355
  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
356
  let expiryTimer: ReturnType<typeof setTimeout> | undefined;
357
+ let expiryAt: number | undefined;
358
+ let memory = `ram ${Math.round(process.memoryUsage.rss() / 1048576)} MiB`;
359
+ let memorySampledAt = 0;
360
+ let context = "ctx ?";
361
+ let contextSampledAt = 0;
377
362
  let disposed = false;
378
363
  const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
379
364
  const dispose = () => {
380
365
  if (disposed) return;
381
366
  disposed = true;
382
- if (memoryTimer) clearTimeout(memoryTimer);
383
- if (timer) clearInterval(timer);
384
367
  if (expiryTimer) clearTimeout(expiryTimer);
385
- timer = undefined;
386
368
  expiryTimer = undefined;
369
+ expiryAt = undefined;
387
370
  if (footerTui === tui) { footerTui = undefined; welcomeStatuses = () => new Map<string, string>(); welcomeBranch = () => null; }
388
371
  unsubscribe();
389
372
  };
@@ -397,32 +380,44 @@ export default function piJar(pi: ExtensionAPI): void {
397
380
  const statuses = footerData.getExtensionStatuses();
398
381
  const live = collectStatuses(statuses, now);
399
382
  const roles: JarRole[] = demo ? createDemoRoles() : live.roles;
400
- if (expiryTimer) clearTimeout(expiryTimer);
401
- expiryTimer = undefined;
402
383
  const nearest = demo ? undefined : roles.reduce<number | undefined>((min, role) =>
403
384
  role.expiresAt != null ? Math.min(min ?? Infinity, role.expiresAt) : min, undefined);
404
- if (nearest != null) expiryTimer = setTimeout(() => tui.requestRender(), Math.max(1, nearest - now));
385
+ if (nearest !== expiryAt) {
386
+ if (expiryTimer) clearTimeout(expiryTimer);
387
+ expiryTimer = undefined;
388
+ expiryAt = nearest;
389
+ if (nearest != null) {
390
+ expiryTimer = setTimeout(() => { expiryTimer = undefined; expiryAt = undefined; tui.requestRender(); }, Math.max(1, nearest - now));
391
+ expiryTimer.unref?.();
392
+ }
393
+ }
394
+ // Do not schedule cosmetic footer repaints. Role state still updates on Pi's native
395
+ // stream/tool renders, which keeps long transcripts responsive.
405
396
  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)}%`;
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
+ }
404
+ if (now - memorySampledAt >= 5000) {
405
+ memory = `ram ${Math.round(process.memoryUsage.rss() / 1048576)} MiB`;
406
+ memorySampledAt = now;
407
+ }
413
408
  const quota = footerSettings.quota ? quotaCache?.get(ctx.model?.provider, statuses, now) : undefined;
414
409
  return renderFooter({
415
410
  model: ctx.model?.id ?? "no-model", effort: ctx.model?.reasoning === false ? "off" : (pi.getThinkingLevel?.() ?? "off"),
416
411
  sessionName: ctx.sessionManager?.getSessionName?.(),
417
412
  cwd: ctx.cwd, settings: footerSettings, branch: footerData.getGitBranch(),
418
- context, goal: goalLoop.progress(), chips: footerChips(), memory, cost: formatCost(cost), quota, roles, extras: live.extras,
413
+ context, goal: goalLoop.progress(), chips: footerChips(),
414
+ memory, cost: formatCost(cost), quota, roles, extras: live.extras,
419
415
  demo, animations, frame, motionBudget: ctx.isIdle() ? 2 : 1
420
416
  }, width, ctx.ui.theme ?? theme);
421
417
  } catch {
422
- if (timer) clearInterval(timer);
423
418
  if (expiryTimer) clearTimeout(expiryTimer);
424
- timer = undefined;
425
419
  expiryTimer = undefined;
420
+ expiryAt = undefined;
426
421
  return width > 0 ? ["pi-jar: UI unavailable (run /jar ui off)".slice(0, width)] : [];
427
422
  }
428
423
  }
@@ -481,12 +476,16 @@ export default function piJar(pi: ExtensionAPI): void {
481
476
  try { cost = sessionCost(ctx); } catch { cost = 0; }
482
477
  footerTui?.requestRender();
483
478
  };
484
- const restoreTodos = (ctx: ExtensionContext) => {
485
- try { todos?.restore(ctx.sessionManager.getBranch()); } catch { todos?.restore([]); }
479
+ const addMessageCost = (message: { usage?: { cost?: { total?: number } } }) => {
480
+ const value = message.usage?.cost?.total;
481
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) cost += value;
482
+ };
483
+ const restoreTodos = (ctx: ExtensionContext, branch?: readonly unknown[]) => {
484
+ try { todos?.restore(branch ?? sessionBranch(ctx)); } catch { todos?.restore([]); }
486
485
  updateTaskWidget(ctx);
487
486
  };
488
- const restoreGoal = (ctx: ExtensionContext) => {
489
- try { goals?.restore(ctx.sessionManager.getBranch()); } catch { goals?.restore([]); }
487
+ const restoreGoal = (ctx: ExtensionContext, branch?: readonly unknown[]) => {
488
+ try { goals?.restore(branch ?? sessionBranch(ctx)); } catch { goals?.restore([]); }
490
489
  footerTui?.requestRender();
491
490
  };
492
491
  pi.on("session_start", (_event, ctx) => {
@@ -498,7 +497,6 @@ export default function piJar(pi: ExtensionAPI): void {
498
497
  footerSettings = visualSettings.footer;
499
498
  demo = false;
500
499
  composer.disable(ctx);
501
- stopWorkingClock();
502
500
  workingIndicatorKey = "";
503
501
  working.end();
504
502
  quotaCache?.stop();
@@ -509,8 +507,10 @@ export default function piJar(pi: ExtensionAPI): void {
509
507
  shells = new ShellManager(onShellEvent);
510
508
  shells.onChange = () => footerTui?.requestRender();
511
509
  goals = new GoalStore((entry) => pi.appendEntry(GOAL_ENTRY, entry));
512
- restoreTodos(ctx);
513
- restoreGoal(ctx);
510
+ let branch: readonly unknown[] = [];
511
+ try { branch = sessionBranch(ctx); } catch { /* keep empty */ }
512
+ restoreTodos(ctx, branch);
513
+ restoreGoal(ctx, branch);
514
514
  // Quota is enabled per session; no credentials or consent are persisted.
515
515
  quotaCache = new QuotaCache(
516
516
  (provider: QuotaProvider, signal) => fetchQuota(provider, (id) => ctx.modelRegistry.getProviderAuth(id), signal),
@@ -528,7 +528,17 @@ export default function piJar(pi: ExtensionAPI): void {
528
528
  if (visualSettings.composer && enabled) composer.enable(ctx);
529
529
  suggest.sync();
530
530
  if (visualSettings.accent !== "follow") selectAccent(ctx, visualSettings.accent);
531
- showWelcome(ctx);
531
+ if (branch.length >= LARGE_SESSION_ENTRIES) {
532
+ try {
533
+ const prefs = piPreferences(ctx)?.get();
534
+ if (prefs && !prefs.fullscreen) {
535
+ ctx.ui.notify(`Large session (${branch.length} entries): regular TUI redraw cost grows with history. Enable Fullscreen in /jar settings for smoother rendering.`, "warning");
536
+ }
537
+ } catch { /* Performance hint is optional. */ }
538
+ }
539
+ // Resumed sessions already have a transcript to paint. Rebuilding the animated welcome and
540
+ // scanning other session files competes with that expensive initial render for no real benefit.
541
+ if (branch.length === 0) showWelcome(ctx);
532
542
  });
533
543
  // Any real prompt dismisses the welcome. Slash commands bypass `input`, so `agent_start` covers them.
534
544
  const dismissWelcome = (ctx: ExtensionContext) => {
@@ -553,6 +563,7 @@ export default function piJar(pi: ExtensionAPI): void {
553
563
  pi.on("message_end", (event, ctx) => {
554
564
  if (event.message.role === "assistant") {
555
565
  working.reportOutputTokens(event.message.usage?.output ?? 0);
566
+ addMessageCost(event.message);
556
567
  applyWorking(ctx);
557
568
  }
558
569
  });
@@ -561,19 +572,26 @@ export default function piJar(pi: ExtensionAPI): void {
561
572
  pi.on("ui_prompt_start", (_event, ctx) => { working.prompt(true); applyWorking(ctx); });
562
573
  pi.on("ui_prompt_end", (_event, ctx) => { working.prompt(false); applyWorking(ctx); });
563
574
  // 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); });
575
+ pi.on("turn_end", (_event, ctx) => { applyWorking(ctx); });
565
576
  pi.on("agent_end", (_event, ctx) => { working.end(); applyWorking(ctx); });
566
577
  pi.on("agent_before_settle", (event) => { if (event.outcome === "error") composer.flash("error"); });
567
578
  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); });
579
+ pi.on("session_tree", (_event, ctx) => {
580
+ let branch: readonly unknown[] = [];
581
+ try { branch = sessionBranch(ctx); } catch { /* keep empty */ }
582
+ restoreTodos(ctx, branch);
583
+ restoreGoal(ctx, branch);
584
+ updateCost(ctx);
585
+ composer.refreshSession(ctx);
586
+ });
569
587
  pi.on("session_compact", (_event, ctx) => { restoreTodos(ctx); restoreGoal(ctx); updateCost(ctx); });
570
588
  pi.on("model_select", (_event, _ctx) => footerTui?.requestRender());
571
589
  pi.on("thinking_level_select", (_event, _ctx) => footerTui?.requestRender());
572
590
  pi.on("session_info_changed", (_event, ctx) => { composer.refreshSession(ctx); footerTui?.requestRender(); });
573
591
  pi.on("session_shutdown", (_event, ctx) => {
592
+ clearSessionBranchCache(ctx);
574
593
  stopWelcome(ctx);
575
594
  composer.disable(ctx);
576
- stopWorkingClock();
577
595
  workingIndicatorKey = "";
578
596
  working.end();
579
597
  try { if (ctx.hasUI && ctx.mode === "tui") { ctx.ui.setWorkingMessage?.(); ctx.ui.setWorkingIndicator(); ctx.ui.setWidget("pi-jar.todos", undefined); } } catch {}
@@ -607,7 +625,7 @@ export default function piJar(pi: ExtensionAPI): void {
607
625
  earlier = (await SessionManager.list(ctx.cwd)).filter((session) => session.path !== current)
608
626
  .sort((a, b) => b.modified.getTime() - a.modified.getTime()).slice(0, 50);
609
627
  } catch (error) { ctx.ui.notify("pi-jar: earlier sessions unavailable: " + String(error), "warning"); }
610
- const picked = await openPromptSearch(ctx, collectPrompts(ctx.sessionManager.getEntries(), earlier));
628
+ const picked = await openPromptSearch(ctx, collectPrompts(sessionBranch(ctx), earlier));
611
629
  if (picked !== undefined) ctx.ui.setEditorText(picked);
612
630
  }
613
631
  });
@@ -753,7 +771,7 @@ export default function piJar(pi: ExtensionAPI): void {
753
771
  if (command === "commit" || command.startsWith("commit ")) { await jarCommit(pi, ctx, modelRoles, sideUsage, args.trim().slice(6).trim()); return; }
754
772
  if (command === "shells") {
755
773
  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; }
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; }
757
775
  await openShellsView(ctx, shells);
758
776
  return;
759
777
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jar",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
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",
package/src/advisor.ts CHANGED
@@ -3,6 +3,7 @@ import { Type } from "typebox";
3
3
  import type { ModelRoleManager } from "./model-roles.ts";
4
4
  import { askRole, type SideUsage } from "./side-model.ts";
5
5
  import { ROLE_PREFIX } from "./status.ts";
6
+ import { sessionBranch } from "./session-branch.ts";
6
7
 
7
8
  export const ADVISOR_TOOL = "jar_advisor";
8
9
  export const ADVISOR_MESSAGE = "pi-jar.advisor";
@@ -26,10 +27,33 @@ export const ADVISOR_SYSTEM = [
26
27
 
27
28
  type Entry = { type: string; message?: { role?: string; content?: unknown; toolName?: string; isError?: boolean; customType?: string } };
28
29
 
29
- const textOf = (content: unknown): string => typeof content === "string" ? content
30
- : Array.isArray(content) ? content.map((part: { type?: string; text?: string; name?: string; arguments?: unknown }) =>
31
- part.type === "text" ? part.text ?? "" : part.type === "toolCall" ? `[tool ${part.name} ${JSON.stringify(part.arguments ?? {}).slice(0, 300)}]` : "").filter(Boolean).join("\n")
32
- : "";
30
+ const toolHint = (args: unknown): string => {
31
+ if (!args || typeof args !== "object") return "";
32
+ const object = args as Record<string, unknown>;
33
+ const keys = ["command", "path", "file_path", "query", "id"];
34
+ for (const key of keys) {
35
+ const value = object[key];
36
+ if (typeof value === "string" && value) return `${key}=${value.slice(0, 240)}`;
37
+ }
38
+ return "";
39
+ };
40
+
41
+ const textOf = (content: unknown, limit = 4_000): string => {
42
+ if (typeof content === "string") return content.slice(0, limit);
43
+ if (!Array.isArray(content)) return "";
44
+ let out = "";
45
+ for (const part of content as Array<{ type?: string; text?: string; name?: string; arguments?: unknown }>) {
46
+ const piece = part.type === "text" ? part.text ?? ""
47
+ : part.type === "toolCall" ? `[tool ${part.name ?? "?"}${toolHint(part.arguments) ? " " + toolHint(part.arguments) : ""}]` : "";
48
+ if (!piece) continue;
49
+ const separator = out ? "\n" : "";
50
+ const room = limit - out.length - separator.length;
51
+ if (room <= 0) break;
52
+ out += separator + piece.slice(0, room);
53
+ if (piece.length > room) break;
54
+ }
55
+ return out;
56
+ };
33
57
 
34
58
  /** The most recent conversation, newest last, capped to `limit` characters. */
35
59
  export function transcript(entries: readonly Entry[], limit = TRANSCRIPT_LIMIT): string {
@@ -38,7 +62,7 @@ export function transcript(entries: readonly Entry[], limit = TRANSCRIPT_LIMIT):
38
62
  for (let index = entries.length - 1; index >= 0 && size < limit; index--) {
39
63
  const message = entries[index]!.type === "message" ? entries[index]!.message : undefined;
40
64
  if (!message?.role) continue;
41
- let body = textOf(message.content).trim();
65
+ let body = textOf(message.content, Math.min(4_000, limit - size)).trim();
42
66
  if (!body) continue;
43
67
  if (message.role === "toolResult" && body.length > 1200) body = body.slice(0, 1200) + " …";
44
68
  const label = message.role === "toolResult" ? `tool result (${message.toolName ?? "?"}${message.isError ? ", error" : ""})` : message.role;
@@ -118,7 +142,7 @@ export function registerAdvisor(pi: ExtensionAPI, roles: ModelRoleManager, optio
118
142
  busy++;
119
143
  status(ctx, request.trigger ?? request.question ?? "reviewing the current direction");
120
144
  try {
121
- const conversation = transcript(ctx.sessionManager.getBranch() as readonly Entry[]);
145
+ const conversation = transcript(sessionBranch(ctx) as readonly Entry[]);
122
146
  return await askRole(ctx, roles, options.usage, "advisor", ADVISOR_SYSTEM, advisorPrompt(request, conversation, await gitState(ctx)), signal);
123
147
  } finally { if (--busy === 0) status(ctx); }
124
148
  };
package/src/changes.ts CHANGED
@@ -100,7 +100,6 @@ export function diffRows(ops: readonly DiffOp[], context = 3): DiffRow[] {
100
100
  export class ChangeTracker {
101
101
  private baselines = new Map<string, string | null>();
102
102
  private dirty = new Set<string>();
103
- private diffCache = new Map<string, FileChange>();
104
103
  private baselineBytes = 0;
105
104
  private readonly cwd: () => string;
106
105
  constructor(cwd: () => string) { this.cwd = cwd; }
@@ -111,7 +110,6 @@ export class ChangeTracker {
111
110
  if (this.baselines.has(target)) this.baselineBytes = Math.max(0, this.baselineBytes - this.bytes(this.baselines.get(target)!));
112
111
  this.baselines.delete(target);
113
112
  this.dirty.delete(target);
114
- this.diffCache.delete(target);
115
113
  }
116
114
 
117
115
  /** Record the pre-change content once; returns false when the file cannot be tracked. */
@@ -132,20 +130,12 @@ export class ChangeTracker {
132
130
  }
133
131
 
134
132
  /**
135
- * Mark one captured file after an edit/write completes. This performs only one cheap file read;
136
- * no line diff is calculated until the review UI is opened.
133
+ * Mark one captured file after a successful edit/write. Do not synchronously re-read the file on
134
+ * every tool result; the review UI reconciles content lazily when /diff is actually opened.
137
135
  */
138
136
  markDirty(path: string): boolean {
139
137
  const target = this.absolute(path);
140
138
  if (!this.baselines.has(target)) return false;
141
- const before = this.baselines.get(target)!;
142
- const now = readText(target);
143
- this.diffCache.delete(target);
144
- if (now === undefined) { this.drop(target); return false; }
145
- if (now === before) {
146
- this.dirty.delete(target);
147
- return false;
148
- }
149
139
  this.dirty.add(target);
150
140
  return true;
151
141
  }
@@ -155,22 +145,24 @@ export class ChangeTracker {
155
145
  const result: FileChange[] = [];
156
146
  for (const path of [...this.dirty]) {
157
147
  const before = this.baselines.get(path);
158
- if (before === undefined) { this.dirty.delete(path); this.diffCache.delete(path); continue; }
148
+ if (before === undefined) { this.dirty.delete(path); continue; }
159
149
  const now = readText(path);
160
150
  if (now === undefined) { this.drop(path); continue; }
161
- if (now === before) { this.dirty.delete(path); this.diffCache.delete(path); continue; }
162
- const cached = this.diffCache.get(path);
163
- if (cached) { result.push(cached); continue; }
151
+ if (now === before) { this.dirty.delete(path); continue; }
164
152
  const ops = lineDiff(before ?? "", now ?? "");
165
- const added = ops ? ops.filter((op) => op.op === "+").length : splitLines(now ?? "").length;
166
- const removed = ops ? ops.filter((op) => op.op === "-").length : splitLines(before ?? "").length;
153
+ let added = 0, removed = 0;
154
+ if (ops) {
155
+ for (const op of ops) { if (op.op === "+") added++; else if (op.op === "-") removed++; }
156
+ } else {
157
+ added = splitLines(now ?? "").length;
158
+ removed = splitLines(before ?? "").length;
159
+ }
167
160
  const rel = relative(this.cwd(), path);
168
161
  const change: FileChange = {
169
162
  path, rel: rel && !rel.startsWith("..") ? rel : path,
170
163
  status: before === null ? "added" : now === null ? "deleted" : "modified",
171
164
  before: before ?? "", after: now ?? "", added, removed, diff: ops ?? null
172
165
  };
173
- this.diffCache.set(path, change);
174
166
  result.push(change);
175
167
  }
176
168
  return result.sort((a, b) => a.rel.localeCompare(b.rel));
@@ -181,7 +173,7 @@ export class ChangeTracker {
181
173
  trackedBytes(): number { return this.baselineBytes; }
182
174
  /** Keep the current content: stop tracking the file. */
183
175
  accept(path: string): void { this.drop(this.absolute(path)); }
184
- acceptAll(): void { this.baselines.clear(); this.dirty.clear(); this.diffCache.clear(); this.baselineBytes = 0; }
176
+ acceptAll(): void { this.baselines.clear(); this.dirty.clear(); this.baselineBytes = 0; }
185
177
  /** Restore the pre-change content (or remove a file the agent created). */
186
178
  revert(path: string): void {
187
179
  const target = this.absolute(path);
@@ -12,12 +12,42 @@ import { Text } from "@earendil-works/pi-tui";
12
12
 
13
13
  type ToolResult = { content: Array<{ type: string; text?: string }>; details?: unknown };
14
14
 
15
- const outputText = (result: ToolResult): string => result.content
16
- .filter((item) => item.type === "text" && typeof item.text === "string")
17
- .map((item) => item.text!)
18
- .join("\n");
15
+ const SUMMARY_SCAN_CHARS = 32 * 1024;
16
+ const DIFF_SCAN_CHARS = 64 * 1024;
17
+ const resultSummaryCache = new WeakMap<object, { sample: string; lines: number; truncated: boolean }>();
18
+ const writeCountCache = new WeakMap<object, { lines: number; truncated: boolean }>();
19
+
20
+ function summarizeResult(result: ToolResult): { sample: string; lines: number; truncated: boolean } {
21
+ if (result && typeof result === "object") {
22
+ const cached = resultSummaryCache.get(result as object);
23
+ if (cached) return cached;
24
+ }
25
+ let sample = "";
26
+ let truncated = false;
27
+ let textParts = 0;
28
+ for (const item of result.content) {
29
+ if (item.type !== "text" || typeof item.text !== "string") continue;
30
+ if (textParts++ && sample.length < SUMMARY_SCAN_CHARS) sample += "\n";
31
+ const room = SUMMARY_SCAN_CHARS - sample.length;
32
+ if (room <= 0) { truncated = true; break; }
33
+ sample += item.text.slice(0, room);
34
+ if (item.text.length > room) { truncated = true; break; }
35
+ }
36
+ const value = { sample, lines: sample ? sample.split("\n").length : 0, truncated };
37
+ if (result && typeof result === "object") resultSummaryCache.set(result as object, value);
38
+ return value;
39
+ }
40
+
41
+ function firstNonEmpty(text: string): string {
42
+ for (const line of text.split("\n")) if (line.trim()) return line;
43
+ return "";
44
+ }
45
+
46
+ function boundedLineCount(text: string, limit = SUMMARY_SCAN_CHARS): { lines: number; truncated: boolean } {
47
+ const sample = text.slice(0, limit);
48
+ return { lines: sample ? sample.split("\n").length : 0, truncated: text.length > limit };
49
+ }
19
50
 
20
- const nonEmptyLines = (text: string): string[] => text.split("\n").filter((line) => line.trim().length > 0);
21
51
  const expandHint = " · [ expand ] Ctrl+O";
22
52
 
23
53
  function compactText(text: string, max = 72): string {
@@ -25,12 +55,18 @@ function compactText(text: string, max = 72): string {
25
55
  return clean.length <= max ? clean : clean.slice(0, Math.max(0, max - 1)) + "…";
26
56
  }
27
57
 
58
+ const bashCache = new WeakMap<object, { cwd: string; trusted: boolean; tool: ReturnType<typeof createBashToolDefinition> }>();
28
59
  function bashFor(ctx: ExtensionContext) {
29
- const settings = SettingsManager.create(ctx.cwd, getAgentDir(), { projectTrusted: ctx.isProjectTrusted() });
30
- return createBashToolDefinition(ctx.cwd, {
60
+ const trusted = ctx.isProjectTrusted();
61
+ const hit = bashCache.get(ctx as object);
62
+ if (hit && hit.cwd === ctx.cwd && hit.trusted === trusted) return hit.tool;
63
+ const settings = SettingsManager.create(ctx.cwd, getAgentDir(), { projectTrusted: trusted });
64
+ const tool = createBashToolDefinition(ctx.cwd, {
31
65
  shellPath: settings.getShellPath(),
32
66
  commandPrefix: settings.getShellCommandPrefix()
33
67
  });
68
+ bashCache.set(ctx as object, { cwd: ctx.cwd, trusted, tool });
69
+ return tool;
34
70
  }
35
71
 
36
72
  /**
@@ -58,9 +94,9 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
58
94
  renderResult(result, options, theme, context) {
59
95
  if (options.expanded && read.renderResult) return read.renderResult(result, options, theme, context);
60
96
  if (options.isPartial) return new Text(theme.fg("warning", "reading…"), 0, 0);
61
- const text = outputText(result);
62
- const count = text ? text.split("\n").length : 0;
63
- return new Text(theme.fg("muted", `${count} line${count === 1 ? "" : "s"}${expandHint}`), 0, 0);
97
+ const summary = summarizeResult(result);
98
+ const count = `${summary.lines}${summary.truncated ? "+" : ""}`;
99
+ return new Text(theme.fg("muted", `${count} line${summary.lines === 1 && !summary.truncated ? "" : "s"}${expandHint}`), 0, 0);
64
100
  }
65
101
  });
66
102
 
@@ -75,12 +111,13 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
75
111
  },
76
112
  renderResult(result, options, theme, context) {
77
113
  if (options.expanded && bash.renderResult) return bash.renderResult(result as Parameters<NonNullable<typeof bash.renderResult>>[0], options, theme, context);
78
- const text = outputText(result);
79
- const lines = nonEmptyLines(text);
80
- const preview = lines[0] ? ` · ${compactText(lines[0], 56)}` : "";
114
+ const summary = summarizeResult(result);
115
+ const first = firstNonEmpty(summary.sample);
116
+ const preview = first ? ` · ${compactText(first, 56)}` : "";
117
+ const count = `${summary.lines}${summary.truncated ? "+" : ""}`;
81
118
  const label = options.isPartial ? "running" : "done";
82
119
  return new Text(theme.fg(options.isPartial ? "warning" : "muted",
83
- `${label} · ${lines.length} line${lines.length === 1 ? "" : "s"}${preview}${expandHint}`), 0, 0);
120
+ `${label} · ${count} line${summary.lines === 1 && !summary.truncated ? "" : "s"}${preview}${expandHint}`), 0, 0);
84
121
  }
85
122
  });
86
123
 
@@ -96,9 +133,11 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
96
133
  const details = result.details as { diff?: string } | undefined;
97
134
  const diff = details?.diff ?? "";
98
135
  if (!diff) {
99
- const text = outputText(result);
136
+ const summary = summarizeResult(result);
137
+ const text = summary.sample;
100
138
  return new Text(theme.fg(text.startsWith("Error") ? "error" : "success", compactText(text || "applied", 96)), 0, 0);
101
139
  }
140
+ if (diff.length > DIFF_SCAN_CHARS) return new Text(theme.fg("muted", `large diff${expandHint}`), 0, 0);
102
141
  let additions = 0;
103
142
  let removals = 0;
104
143
  for (const line of diff.split("\n")) {
@@ -114,14 +153,19 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
114
153
  ...write,
115
154
  renderCall(args, theme, context) {
116
155
  if (context.expanded && write.renderCall) return write.renderCall(args, theme, context);
117
- const count = args.content.split("\n").length;
156
+ let count = args && typeof args === "object" ? writeCountCache.get(args as object) : undefined;
157
+ if (!count) {
158
+ count = boundedLineCount(args.content);
159
+ if (args && typeof args === "object") writeCountCache.set(args as object, count);
160
+ }
118
161
  return new Text(theme.fg("toolTitle", theme.bold("write ")) + theme.fg("accent", args.path)
119
- + theme.fg("dim", ` · ${count} lines`), 0, 0);
162
+ + theme.fg("dim", ` · ${count.lines}${count.truncated ? "+" : ""} lines`), 0, 0);
120
163
  },
121
164
  renderResult(result, options, theme, context) {
122
165
  if (options.expanded && write.renderResult) return write.renderResult(result, options, theme, context);
123
166
  if (options.isPartial) return new Text(theme.fg("warning", "writing…"), 0, 0);
124
- const text = outputText(result);
167
+ const summary = summarizeResult(result);
168
+ const text = summary.sample;
125
169
  return new Text(theme.fg(text.startsWith("Error") ? "error" : "success", compactText(text || "written", 96))
126
170
  + theme.fg("dim", text ? expandHint : ""), 0, 0);
127
171
  }
package/src/composer.ts CHANGED
@@ -280,7 +280,8 @@ export class ComposerStyle {
280
280
  tip: (width) => {
281
281
  if (!this.mascotOn || width < 40) return undefined;
282
282
  const mood = this.animations ? this.mascot.mood() : "idle";
283
- const tip = this.animations ? this.mascot.tip(Date.now(), this.frame) : this.mascot.tip(0, 0);
283
+ const animationFrame = this.phase === "idle" ? this.frame : Math.floor(Date.now() / COMPOSER_ACTIVE_INTERVAL_MS);
284
+ const tip = this.animations ? this.mascot.tip(Date.now(), animationFrame) : this.mascot.tip(0, 0);
284
285
  return " ".repeat(3) + paintTip(tip, mood, this.colors ? (color, text) => this.colors!.fg(color, text) : undefined);
285
286
  },
286
287
  session: () => this.session,
@@ -335,8 +336,9 @@ export class ComposerStyle {
335
336
  }
336
337
 
337
338
  /**
338
- * Active motion ticks at a modest 600 ms. Idle motion has no polling loop at all: it sleeps
339
- * until the mascot's next blink/sleep/flash deadline, then schedules the following deadline.
339
+ * Pi's own streaming/tool updates already repaint active sessions. Never add another active
340
+ * animation loop: active frames derive from wall time when Pi naturally renders. Idle only
341
+ * schedules exact blink/sleep/flash transitions.
340
342
  */
341
343
  private restartTimer(): void {
342
344
  this.stopTimer();
@@ -346,8 +348,7 @@ export class ComposerStyle {
346
348
  }
347
349
  private scheduleTimer(): void {
348
350
  if (!this.enabled || !this.animations || !this.mascotOn || this.timer) return;
349
- const active = this.phase === "generating" || this.phase === "tool" || this.phase === "waiting";
350
- const delay = active ? COMPOSER_ACTIVE_INTERVAL_MS : this.mascot.nextTransitionDelay(Date.now());
351
+ const delay = this.mascot.nextTransitionDelay(Date.now());
351
352
  if (delay === undefined) return;
352
353
  this.timer = setTimeout(() => {
353
354
  this.timer = undefined;