pi-jar 0.1.0 → 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: 240 });
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,31 +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 sampleMemory = () => {
364
- if (disposed) return;
365
- memory = `ram ${Math.round(process.memoryUsage.rss() / 1048576)} MiB`;
366
- tui.requestRender();
367
- memoryTimer = setTimeout(sampleMemory, 3000);
368
- memoryTimer.unref?.();
369
- };
370
- if (footerSettings.memory) {
371
- memoryTimer = setTimeout(sampleMemory, 3000);
372
- memoryTimer.unref?.();
373
- }
374
- let timer: ReturnType<typeof setInterval> | undefined;
375
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;
376
362
  let disposed = false;
377
363
  const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
378
364
  const dispose = () => {
379
365
  if (disposed) return;
380
366
  disposed = true;
381
- if (memoryTimer) clearTimeout(memoryTimer);
382
- if (timer) clearInterval(timer);
383
367
  if (expiryTimer) clearTimeout(expiryTimer);
384
- timer = undefined;
385
368
  expiryTimer = undefined;
369
+ expiryAt = undefined;
386
370
  if (footerTui === tui) { footerTui = undefined; welcomeStatuses = () => new Map<string, string>(); welcomeBranch = () => null; }
387
371
  unsubscribe();
388
372
  };
@@ -396,30 +380,44 @@ export default function piJar(pi: ExtensionAPI): void {
396
380
  const statuses = footerData.getExtensionStatuses();
397
381
  const live = collectStatuses(statuses, now);
398
382
  const roles: JarRole[] = demo ? createDemoRoles() : live.roles;
399
- if (expiryTimer) clearTimeout(expiryTimer);
400
- expiryTimer = undefined;
401
383
  const nearest = demo ? undefined : roles.reduce<number | undefined>((min, role) =>
402
384
  role.expiresAt != null ? Math.min(min ?? Infinity, role.expiresAt) : min, undefined);
403
- 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.
404
396
  const active = animations && footerSettings.roles && roles.some((role) => ACTIVE_STATES.has(role.state));
405
- if (active && !timer) timer = setInterval(() => { frame += 1; tui.requestRender(); }, 240);
406
- else if (!active && timer) { clearInterval(timer); timer = undefined; }
407
- const usage = ctx.getContextUsage();
408
- const context = usage?.percent == null || !Number.isFinite(usage.percent)
409
- ? "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
+ }
410
408
  const quota = footerSettings.quota ? quotaCache?.get(ctx.model?.provider, statuses, now) : undefined;
411
409
  return renderFooter({
412
410
  model: ctx.model?.id ?? "no-model", effort: ctx.model?.reasoning === false ? "off" : (pi.getThinkingLevel?.() ?? "off"),
413
411
  sessionName: ctx.sessionManager?.getSessionName?.(),
414
412
  cwd: ctx.cwd, settings: footerSettings, branch: footerData.getGitBranch(),
415
- 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,
416
415
  demo, animations, frame, motionBudget: ctx.isIdle() ? 2 : 1
417
416
  }, width, ctx.ui.theme ?? theme);
418
417
  } catch {
419
- if (timer) clearInterval(timer);
420
418
  if (expiryTimer) clearTimeout(expiryTimer);
421
- timer = undefined;
422
419
  expiryTimer = undefined;
420
+ expiryAt = undefined;
423
421
  return width > 0 ? ["pi-jar: UI unavailable (run /jar ui off)".slice(0, width)] : [];
424
422
  }
425
423
  }
@@ -478,12 +476,16 @@ export default function piJar(pi: ExtensionAPI): void {
478
476
  try { cost = sessionCost(ctx); } catch { cost = 0; }
479
477
  footerTui?.requestRender();
480
478
  };
481
- const restoreTodos = (ctx: ExtensionContext) => {
482
- 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([]); }
483
485
  updateTaskWidget(ctx);
484
486
  };
485
- const restoreGoal = (ctx: ExtensionContext) => {
486
- 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([]); }
487
489
  footerTui?.requestRender();
488
490
  };
489
491
  pi.on("session_start", (_event, ctx) => {
@@ -495,7 +497,6 @@ export default function piJar(pi: ExtensionAPI): void {
495
497
  footerSettings = visualSettings.footer;
496
498
  demo = false;
497
499
  composer.disable(ctx);
498
- stopWorkingClock();
499
500
  workingIndicatorKey = "";
500
501
  working.end();
501
502
  quotaCache?.stop();
@@ -506,8 +507,10 @@ export default function piJar(pi: ExtensionAPI): void {
506
507
  shells = new ShellManager(onShellEvent);
507
508
  shells.onChange = () => footerTui?.requestRender();
508
509
  goals = new GoalStore((entry) => pi.appendEntry(GOAL_ENTRY, entry));
509
- restoreTodos(ctx);
510
- restoreGoal(ctx);
510
+ let branch: readonly unknown[] = [];
511
+ try { branch = sessionBranch(ctx); } catch { /* keep empty */ }
512
+ restoreTodos(ctx, branch);
513
+ restoreGoal(ctx, branch);
511
514
  // Quota is enabled per session; no credentials or consent are persisted.
512
515
  quotaCache = new QuotaCache(
513
516
  (provider: QuotaProvider, signal) => fetchQuota(provider, (id) => ctx.modelRegistry.getProviderAuth(id), signal),
@@ -525,7 +528,17 @@ export default function piJar(pi: ExtensionAPI): void {
525
528
  if (visualSettings.composer && enabled) composer.enable(ctx);
526
529
  suggest.sync();
527
530
  if (visualSettings.accent !== "follow") selectAccent(ctx, visualSettings.accent);
528
- 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);
529
542
  });
530
543
  // Any real prompt dismisses the welcome. Slash commands bypass `input`, so `agent_start` covers them.
531
544
  const dismissWelcome = (ctx: ExtensionContext) => {
@@ -550,6 +563,7 @@ export default function piJar(pi: ExtensionAPI): void {
550
563
  pi.on("message_end", (event, ctx) => {
551
564
  if (event.message.role === "assistant") {
552
565
  working.reportOutputTokens(event.message.usage?.output ?? 0);
566
+ addMessageCost(event.message);
553
567
  applyWorking(ctx);
554
568
  }
555
569
  });
@@ -558,19 +572,26 @@ export default function piJar(pi: ExtensionAPI): void {
558
572
  pi.on("ui_prompt_start", (_event, ctx) => { working.prompt(true); applyWorking(ctx); });
559
573
  pi.on("ui_prompt_end", (_event, ctx) => { working.prompt(false); applyWorking(ctx); });
560
574
  // A request may span several tool turns; keep its elapsed time and reported tokens until agent_end.
561
- pi.on("turn_end", (_event, ctx) => { applyWorking(ctx); updateCost(ctx); });
575
+ pi.on("turn_end", (_event, ctx) => { applyWorking(ctx); });
562
576
  pi.on("agent_end", (_event, ctx) => { working.end(); applyWorking(ctx); });
563
577
  pi.on("agent_before_settle", (event) => { if (event.outcome === "error") composer.flash("error"); });
564
578
  pi.on("agent_settled", (_event, ctx) => { working.end(); applyWorking(ctx); });
565
- 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
+ });
566
587
  pi.on("session_compact", (_event, ctx) => { restoreTodos(ctx); restoreGoal(ctx); updateCost(ctx); });
567
588
  pi.on("model_select", (_event, _ctx) => footerTui?.requestRender());
568
589
  pi.on("thinking_level_select", (_event, _ctx) => footerTui?.requestRender());
569
590
  pi.on("session_info_changed", (_event, ctx) => { composer.refreshSession(ctx); footerTui?.requestRender(); });
570
591
  pi.on("session_shutdown", (_event, ctx) => {
592
+ clearSessionBranchCache(ctx);
571
593
  stopWelcome(ctx);
572
594
  composer.disable(ctx);
573
- stopWorkingClock();
574
595
  workingIndicatorKey = "";
575
596
  working.end();
576
597
  try { if (ctx.hasUI && ctx.mode === "tui") { ctx.ui.setWorkingMessage?.(); ctx.ui.setWorkingIndicator(); ctx.ui.setWidget("pi-jar.todos", undefined); } } catch {}
@@ -604,7 +625,7 @@ export default function piJar(pi: ExtensionAPI): void {
604
625
  earlier = (await SessionManager.list(ctx.cwd)).filter((session) => session.path !== current)
605
626
  .sort((a, b) => b.modified.getTime() - a.modified.getTime()).slice(0, 50);
606
627
  } catch (error) { ctx.ui.notify("pi-jar: earlier sessions unavailable: " + String(error), "warning"); }
607
- const picked = await openPromptSearch(ctx, collectPrompts(ctx.sessionManager.getEntries(), earlier));
628
+ const picked = await openPromptSearch(ctx, collectPrompts(sessionBranch(ctx), earlier));
608
629
  if (picked !== undefined) ctx.ui.setEditorText(picked);
609
630
  }
610
631
  });
@@ -750,7 +771,7 @@ export default function piJar(pi: ExtensionAPI): void {
750
771
  if (command === "commit" || command.startsWith("commit ")) { await jarCommit(pi, ctx, modelRoles, sideUsage, args.trim().slice(6).trim()); return; }
751
772
  if (command === "shells") {
752
773
  if (!shells) return;
753
- 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; }
754
775
  await openShellsView(ctx, shells);
755
776
  return;
756
777
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jar",
3
- "version": "0.1.0",
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/animations.ts CHANGED
@@ -20,4 +20,4 @@ export function roleFrame(state: RoleState, frame: number, animations = true): s
20
20
 
21
21
  export const WORKING_FRAMES = ["◇", "◆", "◇", "◈"] as const;
22
22
  /** Pixel-fire tick for the welcome flame; frozen when motion is disabled. */
23
- export const WELCOME_INTERVAL_MS = 90;
23
+ export const WELCOME_INTERVAL_MS = 150;
package/src/changes.ts CHANGED
@@ -4,11 +4,14 @@ import { isAbsolute, relative, resolve } from "node:path";
4
4
  /** Files larger than this, or binary files, are not tracked for review. */
5
5
  export const MAX_TRACKED_BYTES = 1024 * 1024;
6
6
  export const MAX_TRACKED_FILES = 200;
7
- /** Above this many (old × new) changed lines the inline diff is replaced by a summary. */
8
- const MAX_DIFF_CELLS = 4_000_000;
7
+ /** Bound total retained baselines so a long editing session cannot pin hundreds of MiB. */
8
+ export const MAX_TRACKED_TOTAL_BYTES = 8 * 1024 * 1024;
9
+ /** Keep the quadratic fallback small; larger edits render a summary instead of allocating a huge matrix. */
10
+ const MAX_DIFF_CELLS = 500_000;
11
+ const MAX_DIFF_MIDDLE_LINES = 4_000;
9
12
 
10
13
  export type ChangeStatus = "added" | "modified" | "deleted";
11
- export interface FileChange { path: string; rel: string; status: ChangeStatus; before: string; after: string; added: number; removed: number }
14
+ export interface FileChange { path: string; rel: string; status: ChangeStatus; before: string; after: string; added: number; removed: number; diff?: DiffOp[] | null }
12
15
  export type DiffOp = { op: " " | "+" | "-"; text: string };
13
16
  export type DiffRow = { kind: "hunk" | "context" | "add" | "remove" | "note"; text: string; oldLine?: number; newLine?: number };
14
17
 
@@ -32,10 +35,11 @@ export function lineDiff(before: string, after: string): DiffOp[] | undefined {
32
35
  let endA = a.length, endB = b.length;
33
36
  while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
34
37
  const midA = a.slice(start, endA), midB = b.slice(start, endB);
35
- if (midA.length * midB.length > MAX_DIFF_CELLS) return undefined;
38
+ if (midA.length + midB.length > MAX_DIFF_MIDDLE_LINES || midA.length * midB.length > MAX_DIFF_CELLS) return undefined;
36
39
  const n = midA.length, m = midB.length;
37
- // lcs[i][j] = LCS length of midA[i..] and midB[j..], flattened.
38
- const lcs = new Uint32Array((n + 1) * (m + 1));
40
+ // lcs[i][j] = LCS length of midA[i..] and midB[j..], flattened. The matrix is
41
+ // intentionally capped above and Uint16 is enough because the capped middle is < 65k lines.
42
+ const lcs = new Uint16Array((n + 1) * (m + 1));
39
43
  for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) {
40
44
  lcs[i * (m + 1) + j] = midA[i] === midB[j] ? lcs[(i + 1) * (m + 1) + j + 1]! + 1
41
45
  : Math.max(lcs[(i + 1) * (m + 1) + j]!, lcs[i * (m + 1) + j + 1]!);
@@ -95,10 +99,18 @@ export function diffRows(ops: readonly DiffOp[], context = 3): DiffRow[] {
95
99
  */
96
100
  export class ChangeTracker {
97
101
  private baselines = new Map<string, string | null>();
102
+ private dirty = new Set<string>();
103
+ private baselineBytes = 0;
98
104
  private readonly cwd: () => string;
99
105
  constructor(cwd: () => string) { this.cwd = cwd; }
100
106
 
101
107
  private absolute(path: string): string { return isAbsolute(path) ? resolve(path) : resolve(this.cwd(), path); }
108
+ private bytes(content: string | null): number { return content === null ? 0 : Buffer.byteLength(content, "utf8"); }
109
+ private drop(target: string): void {
110
+ if (this.baselines.has(target)) this.baselineBytes = Math.max(0, this.baselineBytes - this.bytes(this.baselines.get(target)!));
111
+ this.baselines.delete(target);
112
+ this.dirty.delete(target);
113
+ }
102
114
 
103
115
  /** Record the pre-change content once; returns false when the file cannot be tracked. */
104
116
  capture(path: string): boolean {
@@ -110,29 +122,58 @@ export class ChangeTracker {
110
122
  if (this.baselines.size >= MAX_TRACKED_FILES) return false;
111
123
  const content = readText(target);
112
124
  if (content === undefined) return false;
125
+ const bytes = this.bytes(content);
126
+ if (this.baselineBytes + bytes > MAX_TRACKED_TOTAL_BYTES) return false;
113
127
  this.baselines.set(target, content);
128
+ this.baselineBytes += bytes;
114
129
  return true;
115
130
  }
116
131
 
132
+ /**
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.
135
+ */
136
+ markDirty(path: string): boolean {
137
+ const target = this.absolute(path);
138
+ if (!this.baselines.has(target)) return false;
139
+ this.dirty.add(target);
140
+ return true;
141
+ }
142
+
143
+ /** Materialize detailed diffs lazily for the review UI only. */
117
144
  changes(): FileChange[] {
118
145
  const result: FileChange[] = [];
119
- for (const [path, before] of this.baselines) {
146
+ for (const path of [...this.dirty]) {
147
+ const before = this.baselines.get(path);
148
+ if (before === undefined) { this.dirty.delete(path); continue; }
120
149
  const now = readText(path);
121
- if (now === undefined || now === before) continue;
150
+ if (now === undefined) { this.drop(path); continue; }
151
+ if (now === before) { this.dirty.delete(path); continue; }
122
152
  const ops = lineDiff(before ?? "", now ?? "");
123
- const added = ops ? ops.filter((op) => op.op === "+").length : splitLines(now ?? "").length;
124
- 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
+ }
125
160
  const rel = relative(this.cwd(), path);
126
- result.push({ path, rel: rel && !rel.startsWith("..") ? rel : path, status: before === null ? "added" : now === null ? "deleted" : "modified",
127
- before: before ?? "", after: now ?? "", added, removed });
161
+ const change: FileChange = {
162
+ path, rel: rel && !rel.startsWith("..") ? rel : path,
163
+ status: before === null ? "added" : now === null ? "deleted" : "modified",
164
+ before: before ?? "", after: now ?? "", added, removed, diff: ops ?? null
165
+ };
166
+ result.push(change);
128
167
  }
129
168
  return result.sort((a, b) => a.rel.localeCompare(b.rel));
130
169
  }
131
170
 
132
- count(): number { return this.changes().length; }
171
+ /** O(1): the footer never calculates file diffs just to show its badge. */
172
+ count(): number { return this.dirty.size; }
173
+ trackedBytes(): number { return this.baselineBytes; }
133
174
  /** Keep the current content: stop tracking the file. */
134
- accept(path: string): void { this.baselines.delete(this.absolute(path)); }
135
- acceptAll(): void { this.baselines.clear(); }
175
+ accept(path: string): void { this.drop(this.absolute(path)); }
176
+ acceptAll(): void { this.baselines.clear(); this.dirty.clear(); this.baselineBytes = 0; }
136
177
  /** Restore the pre-change content (or remove a file the agent created). */
137
178
  revert(path: string): void {
138
179
  const target = this.absolute(path);
@@ -140,7 +181,7 @@ export class ChangeTracker {
140
181
  const before = this.baselines.get(target)!;
141
182
  if (before === null) { if (existsSync(target)) unlinkSync(target); }
142
183
  else writeFileSync(target, before);
143
- this.baselines.delete(target);
184
+ this.drop(target);
144
185
  }
145
- clear(): void { this.baselines.clear(); }
186
+ clear(): void { this.acceptAll(); }
146
187
  }
@@ -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
  }