pi-jar 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -187,7 +187,7 @@ export default function piJar(pi: ExtensionAPI): void {
187
187
  ctx.ui.setWorkingMessage?.(view.message);
188
188
  const indicatorKey = `${working.phase}:${animations}`;
189
189
  if (indicatorKey !== workingIndicatorKey) {
190
- ctx.ui.setWorkingIndicator({ frames: view.frames, intervalMs: 240 });
190
+ ctx.ui.setWorkingIndicator({ frames: view.frames, intervalMs: 600 });
191
191
  workingIndicatorKey = indicatorKey;
192
192
  }
193
193
  if (active && !workingClock) {
@@ -360,15 +360,16 @@ export default function piJar(pi: ExtensionAPI): void {
360
360
  let frame = 0;
361
361
  let memory = `ram ${Math.round(process.memoryUsage.rss() / 1048576)} MiB`;
362
362
  let memoryTimer: ReturnType<typeof setTimeout> | undefined;
363
+ const MEMORY_SAMPLE_MS = 10_000;
363
364
  const sampleMemory = () => {
364
365
  if (disposed) return;
365
- memory = `ram ${Math.round(process.memoryUsage.rss() / 1048576)} MiB`;
366
- tui.requestRender();
367
- memoryTimer = setTimeout(sampleMemory, 3000);
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);
368
369
  memoryTimer.unref?.();
369
370
  };
370
371
  if (footerSettings.memory) {
371
- memoryTimer = setTimeout(sampleMemory, 3000);
372
+ memoryTimer = setTimeout(sampleMemory, MEMORY_SAMPLE_MS);
372
373
  memoryTimer.unref?.();
373
374
  }
374
375
  let timer: ReturnType<typeof setInterval> | undefined;
@@ -402,8 +403,10 @@ export default function piJar(pi: ExtensionAPI): void {
402
403
  role.expiresAt != null ? Math.min(min ?? Infinity, role.expiresAt) : min, undefined);
403
404
  if (nearest != null) expiryTimer = setTimeout(() => tui.requestRender(), Math.max(1, nearest - now));
404
405
  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; }
406
+ if (active && !timer) {
407
+ timer = setInterval(() => { frame += 1; tui.requestRender(); }, 600);
408
+ timer.unref?.();
409
+ } else if (!active && timer) { clearInterval(timer); timer = undefined; }
407
410
  const usage = ctx.getContextUsage();
408
411
  const context = usage?.percent == null || !Number.isFinite(usage.percent)
409
412
  ? "ctx ?" : `ctx ${Math.round(usage.percent)}%`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-jar",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
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/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,20 @@ 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 diffCache = new Map<string, FileChange>();
104
+ private baselineBytes = 0;
98
105
  private readonly cwd: () => string;
99
106
  constructor(cwd: () => string) { this.cwd = cwd; }
100
107
 
101
108
  private absolute(path: string): string { return isAbsolute(path) ? resolve(path) : resolve(this.cwd(), path); }
109
+ private bytes(content: string | null): number { return content === null ? 0 : Buffer.byteLength(content, "utf8"); }
110
+ private drop(target: string): void {
111
+ if (this.baselines.has(target)) this.baselineBytes = Math.max(0, this.baselineBytes - this.bytes(this.baselines.get(target)!));
112
+ this.baselines.delete(target);
113
+ this.dirty.delete(target);
114
+ this.diffCache.delete(target);
115
+ }
102
116
 
103
117
  /** Record the pre-change content once; returns false when the file cannot be tracked. */
104
118
  capture(path: string): boolean {
@@ -110,29 +124,64 @@ export class ChangeTracker {
110
124
  if (this.baselines.size >= MAX_TRACKED_FILES) return false;
111
125
  const content = readText(target);
112
126
  if (content === undefined) return false;
127
+ const bytes = this.bytes(content);
128
+ if (this.baselineBytes + bytes > MAX_TRACKED_TOTAL_BYTES) return false;
113
129
  this.baselines.set(target, content);
130
+ this.baselineBytes += bytes;
114
131
  return true;
115
132
  }
116
133
 
134
+ /**
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.
137
+ */
138
+ markDirty(path: string): boolean {
139
+ const target = this.absolute(path);
140
+ 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
+ this.dirty.add(target);
150
+ return true;
151
+ }
152
+
153
+ /** Materialize detailed diffs lazily for the review UI only. */
117
154
  changes(): FileChange[] {
118
155
  const result: FileChange[] = [];
119
- for (const [path, before] of this.baselines) {
156
+ for (const path of [...this.dirty]) {
157
+ const before = this.baselines.get(path);
158
+ if (before === undefined) { this.dirty.delete(path); this.diffCache.delete(path); continue; }
120
159
  const now = readText(path);
121
- if (now === undefined || now === before) continue;
160
+ 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; }
122
164
  const ops = lineDiff(before ?? "", now ?? "");
123
165
  const added = ops ? ops.filter((op) => op.op === "+").length : splitLines(now ?? "").length;
124
166
  const removed = ops ? ops.filter((op) => op.op === "-").length : splitLines(before ?? "").length;
125
167
  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 });
168
+ const change: FileChange = {
169
+ path, rel: rel && !rel.startsWith("..") ? rel : path,
170
+ status: before === null ? "added" : now === null ? "deleted" : "modified",
171
+ before: before ?? "", after: now ?? "", added, removed, diff: ops ?? null
172
+ };
173
+ this.diffCache.set(path, change);
174
+ result.push(change);
128
175
  }
129
176
  return result.sort((a, b) => a.rel.localeCompare(b.rel));
130
177
  }
131
178
 
132
- count(): number { return this.changes().length; }
179
+ /** O(1): the footer never calculates file diffs just to show its badge. */
180
+ count(): number { return this.dirty.size; }
181
+ trackedBytes(): number { return this.baselineBytes; }
133
182
  /** 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(); }
183
+ accept(path: string): void { this.drop(this.absolute(path)); }
184
+ acceptAll(): void { this.baselines.clear(); this.dirty.clear(); this.diffCache.clear(); this.baselineBytes = 0; }
136
185
  /** Restore the pre-change content (or remove a file the agent created). */
137
186
  revert(path: string): void {
138
187
  const target = this.absolute(path);
@@ -140,7 +189,7 @@ export class ChangeTracker {
140
189
  const before = this.baselines.get(target)!;
141
190
  if (before === null) { if (existsSync(target)) unlinkSync(target); }
142
191
  else writeFileSync(target, before);
143
- this.baselines.delete(target);
192
+ this.drop(target);
144
193
  }
145
- clear(): void { this.baselines.clear(); }
194
+ clear(): void { this.acceptAll(); }
146
195
  }
package/src/composer.ts CHANGED
@@ -11,6 +11,7 @@ type MouseEvent = Parameters<NonNullable<EditorComponent["handleMouse"]>>[0];
11
11
  const PHASE_MOODS: Record<WorkingPhase, readonly MascotMood[]> = {
12
12
  idle: ["idle"], generating: ["happy", "thinking"], tool: ["tool"], waiting: ["waiting"]
13
13
  };
14
+ export const COMPOSER_ACTIVE_INTERVAL_MS = 600;
14
15
  /** Plain one-line face for a phase; every phase has the same width. */
15
16
  export function composerIcon(phase: WorkingPhase, frame = 0): string {
16
17
  const moods = PHASE_MOODS[phase];
@@ -256,7 +257,7 @@ export class ComposerStyle {
256
257
  private owner?: EditorFactory;
257
258
  private previous?: EditorFactory;
258
259
  private tui?: TUI;
259
- private timer?: ReturnType<typeof setInterval>;
260
+ private timer?: ReturnType<typeof setTimeout>;
260
261
  private phase: WorkingPhase = "idle";
261
262
  private frame = 0;
262
263
  private animations = true;
@@ -291,7 +292,7 @@ export class ComposerStyle {
291
292
  return text;
292
293
  },
293
294
  typed: () => this.suggestions?.clear(),
294
- poke: () => { this.mascot.flash("poke", 1500); this.tui?.requestRender(); },
295
+ poke: () => { this.mascot.flash("poke", 1500); this.restartTimer(); this.tui?.requestRender(); },
295
296
  dim: (text) => this.colors?.fg("dim", text) ?? `\x1b[2m${text}\x1b[22m`,
296
297
  attachments: (text) => {
297
298
  if (!/\.(png|jpe?g|gif|webp)\b/i.test(text)) return undefined;
@@ -318,36 +319,49 @@ export class ComposerStyle {
318
319
  /** Short-lived expression, e.g. on errors or a completed goal. */
319
320
  flash(mood: "error" | "complete" | "poke", ms = 3000): void {
320
321
  this.mascot.flash(mood, ms);
322
+ this.restartTimer();
321
323
  if (this.enabled) this.tui?.requestRender();
322
324
  }
323
325
 
324
326
  setActivity(phase: WorkingPhase, animations: boolean): void {
325
327
  const changed = this.phase !== phase || this.animations !== animations;
326
328
  this.mascot.setPhase(phase);
327
- if (!changed && (this.timer || !this.enabled || !animations)) return;
329
+ if (!changed) return;
328
330
  this.phase = phase;
329
331
  this.animations = animations;
330
- if (changed) this.frame = 0;
332
+ this.frame = 0;
331
333
  this.restartTimer();
332
- if (changed && this.enabled) this.tui?.requestRender();
334
+ if (this.enabled) this.tui?.requestRender();
333
335
  }
334
336
 
335
- /** One unref'd timer drives flicker, blinks and sleepiness; it renders only when the sprite changes. */
337
+ /**
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.
340
+ */
336
341
  private restartTimer(): void {
337
342
  this.stopTimer();
338
- if (!this.enabled || !this.animations) return;
339
- const active = this.phase === "generating" || this.phase === "tool";
340
- if (!active && !this.mascotOn) return;
341
- this.timer = setInterval(() => {
343
+ if (!this.enabled || !this.animations || !this.mascotOn) return;
344
+ this.lastKey = this.mascot.key(Date.now(), this.frame);
345
+ this.scheduleTimer();
346
+ }
347
+ private scheduleTimer(): void {
348
+ 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
+ if (delay === undefined) return;
352
+ this.timer = setTimeout(() => {
353
+ this.timer = undefined;
342
354
  this.frame++;
343
- const key = this.mascotOn ? this.mascot.key(Date.now(), this.frame) : String(this.frame);
344
- if (key === this.lastKey) return;
345
- this.lastKey = key;
346
- this.tui?.requestRender();
347
- }, 240);
355
+ const key = this.mascot.key(Date.now(), this.frame);
356
+ if (key !== this.lastKey) {
357
+ this.lastKey = key;
358
+ this.tui?.requestRender();
359
+ }
360
+ this.scheduleTimer();
361
+ }, delay);
348
362
  this.timer.unref?.();
349
363
  }
350
- private stopTimer(): void { if (this.timer) clearInterval(this.timer); this.timer = undefined; }
364
+ private stopTimer(): void { if (this.timer) clearTimeout(this.timer); this.timer = undefined; }
351
365
  refreshSession(ctx: ExtensionContext): void {
352
366
  const manager = ctx.sessionManager as { getSessionId?: () => string; getSessionName?: () => string | undefined } | undefined;
353
367
  const next = sessionDisplayName(manager?.getSessionName?.(), manager?.getSessionId?.());
package/src/diff-view.ts CHANGED
@@ -18,7 +18,7 @@ export const safeLine = (text: string) => text.slice(0, 4000).replace(/\t/g, "
18
18
 
19
19
  /** Styled diff rows for one file, with a gutter of old/new line numbers. */
20
20
  export function renderDiff(change: FileChange, width: number, fg: (color: string, text: string) => string): string[] {
21
- const ops = lineDiff(change.before, change.after);
21
+ const ops = change.diff === null ? undefined : (change.diff ?? lineDiff(change.before, change.after));
22
22
  if (!ops) return [fg("warning", `Too large for an inline diff: +${change.added} −${change.removed} lines.`)];
23
23
  const rows: DiffRow[] = diffRows(ops);
24
24
  if (!rows.length) return [fg("dim", "No textual changes.")];
@@ -142,12 +142,34 @@ export async function openDiffView(ctx: ExtensionContext, tracker: ChangeTracker
142
142
 
143
143
  /** Track edit/write targets before they run, and expose `/diff` plus ctrl+alt+d. */
144
144
  export function registerChangeReview(pi: ExtensionAPI, tracker: () => ChangeTracker | undefined, changed: () => void): void {
145
+ // Keep the path that belonged to each in-flight edit/write so tool completion dirties only
146
+ // that file. Older Pi builds may omit toolCallId here, hence the small FIFO fallback.
147
+ const pending = new Map<string, string>();
148
+ const fallback: string[] = [];
145
149
  pi.on("tool_call", (event) => {
146
150
  if (event.toolName !== "edit" && event.toolName !== "write") return;
147
151
  const path = (event.input as { path?: unknown }).path;
148
- if (typeof path === "string" && path.trim()) tracker()?.capture(path);
152
+ if (typeof path !== "string" || !path.trim() || !tracker()?.capture(path)) return;
153
+ const id = (event as { toolCallId?: unknown }).toolCallId;
154
+ if (typeof id === "string" && id) pending.set(id, path);
155
+ fallback.push(path);
156
+ });
157
+ pi.on("tool_result", (event) => {
158
+ if (event.toolName !== "edit" && event.toolName !== "write") return;
159
+ const id = (event as { toolCallId?: unknown }).toolCallId;
160
+ let path: string | undefined;
161
+ if (typeof id === "string" && id) {
162
+ path = pending.get(id);
163
+ pending.delete(id);
164
+ if (path) {
165
+ const index = fallback.indexOf(path);
166
+ if (index >= 0) fallback.splice(index, 1);
167
+ }
168
+ }
169
+ if (!path) path = fallback.shift();
170
+ if (path) tracker()?.markDirty(path);
171
+ changed();
149
172
  });
150
- pi.on("tool_result", (event) => { if (event.toolName === "edit" || event.toolName === "write") changed(); });
151
173
  const open = async (ctx: ExtensionContext) => {
152
174
  const current = tracker();
153
175
  if (!current) return;
package/src/mascot.ts CHANGED
@@ -79,6 +79,20 @@ export class Mascot {
79
79
  return tips[((frame % tips.length) + tips.length) % tips.length]!;
80
80
  }
81
81
 
82
+ /**
83
+ * Milliseconds until an idle mascot can visibly change again. Active phases are driven by
84
+ * the composer's low-frequency animation clock; idle uses this deadline instead of polling.
85
+ */
86
+ nextTransitionDelay(now = Date.now()): number | undefined {
87
+ const current = this.mood(now);
88
+ if (this.flashMood && now < this.flashMood.until) return Math.max(16, this.flashMood.until - now);
89
+ if (this.phase !== "idle") return undefined;
90
+ const sleepyAt = this.lastActive + SLEEPY_AFTER_MS;
91
+ if (now >= sleepyAt) return undefined;
92
+ const next = current === "blink" ? Math.min(this.nextBlink + BLINK_MS, sleepyAt) : Math.min(this.nextBlink, sleepyAt);
93
+ return Math.max(16, next - now);
94
+ }
95
+
82
96
  /** Visual identity for change detection (avoids needless renders). */
83
97
  key(now = Date.now(), frame = 0): string { return this.mood(now) + ":" + this.tip(now, frame); }
84
98
  }