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.
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;
@@ -279,7 +280,8 @@ export class ComposerStyle {
279
280
  tip: (width) => {
280
281
  if (!this.mascotOn || width < 40) return undefined;
281
282
  const mood = this.animations ? this.mascot.mood() : "idle";
282
- 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);
283
285
  return " ".repeat(3) + paintTip(tip, mood, this.colors ? (color, text) => this.colors!.fg(color, text) : undefined);
284
286
  },
285
287
  session: () => this.session,
@@ -291,7 +293,7 @@ export class ComposerStyle {
291
293
  return text;
292
294
  },
293
295
  typed: () => this.suggestions?.clear(),
294
- poke: () => { this.mascot.flash("poke", 1500); this.tui?.requestRender(); },
296
+ poke: () => { this.mascot.flash("poke", 1500); this.restartTimer(); this.tui?.requestRender(); },
295
297
  dim: (text) => this.colors?.fg("dim", text) ?? `\x1b[2m${text}\x1b[22m`,
296
298
  attachments: (text) => {
297
299
  if (!/\.(png|jpe?g|gif|webp)\b/i.test(text)) return undefined;
@@ -318,36 +320,49 @@ export class ComposerStyle {
318
320
  /** Short-lived expression, e.g. on errors or a completed goal. */
319
321
  flash(mood: "error" | "complete" | "poke", ms = 3000): void {
320
322
  this.mascot.flash(mood, ms);
323
+ this.restartTimer();
321
324
  if (this.enabled) this.tui?.requestRender();
322
325
  }
323
326
 
324
327
  setActivity(phase: WorkingPhase, animations: boolean): void {
325
328
  const changed = this.phase !== phase || this.animations !== animations;
326
329
  this.mascot.setPhase(phase);
327
- if (!changed && (this.timer || !this.enabled || !animations)) return;
330
+ if (!changed) return;
328
331
  this.phase = phase;
329
332
  this.animations = animations;
330
- if (changed) this.frame = 0;
333
+ this.frame = 0;
331
334
  this.restartTimer();
332
- if (changed && this.enabled) this.tui?.requestRender();
335
+ if (this.enabled) this.tui?.requestRender();
333
336
  }
334
337
 
335
- /** One unref'd timer drives flicker, blinks and sleepiness; it renders only when the sprite changes. */
338
+ /**
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.
342
+ */
336
343
  private restartTimer(): void {
337
344
  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(() => {
345
+ if (!this.enabled || !this.animations || !this.mascotOn) return;
346
+ this.lastKey = this.mascot.key(Date.now(), this.frame);
347
+ this.scheduleTimer();
348
+ }
349
+ private scheduleTimer(): void {
350
+ if (!this.enabled || !this.animations || !this.mascotOn || this.timer) return;
351
+ const delay = this.mascot.nextTransitionDelay(Date.now());
352
+ if (delay === undefined) return;
353
+ this.timer = setTimeout(() => {
354
+ this.timer = undefined;
342
355
  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);
356
+ const key = this.mascot.key(Date.now(), this.frame);
357
+ if (key !== this.lastKey) {
358
+ this.lastKey = key;
359
+ this.tui?.requestRender();
360
+ }
361
+ this.scheduleTimer();
362
+ }, delay);
348
363
  this.timer.unref?.();
349
364
  }
350
- private stopTimer(): void { if (this.timer) clearInterval(this.timer); this.timer = undefined; }
365
+ private stopTimer(): void { if (this.timer) clearTimeout(this.timer); this.timer = undefined; }
351
366
  refreshSession(ctx: ExtensionContext): void {
352
367
  const manager = ctx.sessionManager as { getSessionId?: () => string; getSessionName?: () => string | undefined } | undefined;
353
368
  const next = sessionDisplayName(manager?.getSessionName?.(), manager?.getSessionId?.());
package/src/delegate.ts CHANGED
@@ -15,7 +15,9 @@ export const MAX_DELEGATES = 4;
15
15
  export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls"] as const;
16
16
  const MAX_OUTPUT_CHARS = 12_000;
17
17
  const TIMEOUT_MS = 20 * 60_000;
18
- const STATUS_REFRESH_MS = 10_000;
18
+ const STATUS_REFRESH_MS = 20_000;
19
+ const LIVE_UPDATE_MS = 1000;
20
+ const MAX_EVENT_TEXT = 16_000;
19
21
 
20
22
  export type DelegateState = "queued" | "working" | "done" | "failed";
21
23
  export interface DelegateRun {
@@ -56,9 +58,20 @@ export function delegateArgs(task: string, model: string | undefined, thinking:
56
58
  return args;
57
59
  }
58
60
 
59
- const textOf = (message: { content?: unknown }) => Array.isArray(message.content)
60
- ? message.content.filter((part: { type?: string }) => part?.type === "text").map((part: { text?: string }) => part.text ?? "").join("\n")
61
- : typeof message.content === "string" ? message.content : "";
61
+ const textOf = (message: { content?: unknown }, limit = MAX_EVENT_TEXT): string => {
62
+ if (typeof message.content === "string") return message.content.slice(0, limit);
63
+ if (!Array.isArray(message.content)) return "";
64
+ let out = "";
65
+ for (const part of message.content as Array<{ type?: string; text?: string }>) {
66
+ if (part?.type !== "text" || typeof part.text !== "string" || !part.text) continue;
67
+ const separator = out ? "\n" : "";
68
+ const room = limit - out.length - separator.length;
69
+ if (room <= 0) break;
70
+ out += separator + part.text.slice(0, room);
71
+ if (part.text.length > room) break;
72
+ }
73
+ return out;
74
+ };
62
75
 
63
76
  /** Run one child Pi, feeding progress into `run` and calling `update` on every change. */
64
77
  export function runDelegate(run: DelegateRun, args: string[], cwd: string, signal: AbortSignal | undefined, update: () => void, spawnProcess: Spawn = spawn as Spawn): Promise<void> {
@@ -171,20 +184,36 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, spaw
171
184
  } catch { /* status is decoration */ }
172
185
  }
173
186
  };
174
- const details = () => ({ runs: runs.map((run) => ({ ...run })), write });
175
- const update = () => {
187
+ const details = (includeOutput = true) => ({ runs: runs.map((run) => includeOutput ? { ...run } : { ...run, output: "" }), write });
188
+ let updateTimer: ReturnType<typeof setTimeout> | undefined;
189
+ let lastUpdateAt = 0;
190
+ const emitUpdate = () => {
191
+ updateTimer = undefined;
192
+ lastUpdateAt = Date.now();
176
193
  publish();
177
- onUpdate?.({ content: [{ type: "text", text: runs.map((run) => `${run.name}: ${run.state}`).join("\n") }], details: details() });
194
+ onUpdate?.({ content: [{ type: "text", text: runs.map((run) => `${run.name}: ${run.state}`).join("\n") }], details: details(false) });
195
+ };
196
+ const update = () => {
197
+ if (!onUpdate) { publish(); return; }
198
+ const wait = Math.max(0, LIVE_UPDATE_MS - (Date.now() - lastUpdateAt));
199
+ if (wait === 0) { if (updateTimer) { clearTimeout(updateTimer); updateTimer = undefined; } emitUpdate(); return; }
200
+ if (!updateTimer) {
201
+ updateTimer = setTimeout(emitUpdate, wait);
202
+ updateTimer.unref?.();
203
+ }
204
+ };
205
+ const flushUpdate = () => {
206
+ if (updateTimer) { clearTimeout(updateTimer); updateTimer = undefined; }
207
+ emitUpdate();
178
208
  };
179
209
  const refresh = setInterval(publish, STATUS_REFRESH_MS);
180
210
  refresh.unref?.();
181
211
  try {
182
- update();
183
212
  await Promise.all(runs.map((run) => runDelegate(run, delegateArgs(run.task, run.model, thinking(run.role), write), ctx.cwd, signal, update, spawnProcess)));
184
213
  } finally {
185
214
  clearInterval(refresh);
186
215
  for (const run of runs) if (run.state !== "done") run.state = "failed";
187
- publish();
216
+ flushUpdate();
188
217
  }
189
218
  const report = runs.map((run) => `## [${run.index}] ${run.name} (${run.role}${run.model ? " · " + run.model : ""}) — ${run.state}${run.error ? ": " + run.error : ""}\n${run.output || "(no report)"}`).join("\n\n");
190
219
  return { content: [{ type: "text", text: report }], details: details() };
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
+ const failed = (event as { isError?: unknown }).isError === true;
171
+ if (path && !failed && tracker()?.markDirty(path)) 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/goal-loop.ts CHANGED
@@ -45,6 +45,7 @@ export class GoalLoop {
45
45
  private readonly pi: ExtensionAPI;
46
46
  private readonly options: GoalLoopOptions;
47
47
  private prompting = 0;
48
+ private contextDirty = true;
48
49
  private restoreRole: (() => Promise<void>) | undefined;
49
50
  private roleInUse: "implement" | "advisor" | undefined;
50
51
 
@@ -63,6 +64,7 @@ export class GoalLoop {
63
64
  const store = this.options.goals();
64
65
  if (!store?.isActive()) return;
65
66
  store.setStatus("paused", { reason });
67
+ this.contextDirty = true;
66
68
  if (ctx) { ctx.ui.notify("◎ Goal paused · " + reason + " · /goal resume", "info"); this.options.changed?.(ctx); }
67
69
  }
68
70
 
@@ -127,6 +129,7 @@ export class GoalLoop {
127
129
  if (goal.phase !== "audit") return { ok: false, message: "Cannot complete yet: finish your turn; pi-jar runs an audit pass before a goal can be completed." };
128
130
  if (!evidence.trim()) return { ok: false, message: "Provide concrete evidence (commands run, results, files) to complete the goal." };
129
131
  if (!store.setStatus("complete", { evidence })) return { ok: false, message: "Could not record goal completion." };
132
+ this.contextDirty = true;
130
133
  ctx.ui.notify("◎ Goal complete · " + goal.text, "info");
131
134
  this.options.changed?.(ctx);
132
135
  this.options.completed?.(ctx, { ...goal, status: "complete", evidence });
@@ -138,6 +141,7 @@ export class GoalLoop {
138
141
  const store = this.options.goals();
139
142
  if (!store) { ctx.ui.notify("Goal state is unavailable before the session starts", "warning"); return; }
140
143
  if (!store.set(text)) { ctx.ui.notify("Could not set goal; keep it concise and plain-text", "error"); return; }
144
+ this.contextDirty = true;
141
145
  this.options.changed?.(ctx);
142
146
  ctx.ui.notify("◎ Goal active · " + store.current()!.text, "info");
143
147
  if (this.options.planActive()) { this.pause(ctx, "plan mode is on"); return; }
@@ -174,6 +178,7 @@ export class GoalLoop {
174
178
  if (!goal || goal.status !== "active") return { content: [{ type: "text", text: "No active goal." }], details: { action: "block", ok: false } };
175
179
  const reason = (params.reason ?? "").trim() || "needs user input";
176
180
  this.options.goals()?.setStatus("paused", { reason });
181
+ this.contextDirty = true;
177
182
  ctx.ui.notify("◎ Goal blocked · " + reason, "warning");
178
183
  this.options.changed?.(ctx);
179
184
  return { content: [{ type: "text", text: "Goal paused for the user: " + reason }], details: { action: "block", ok: true }, terminate: true };
@@ -198,7 +203,7 @@ export class GoalLoop {
198
203
  const verb = raw.toLowerCase();
199
204
  if (/^(?:clear|off|none|drop)$/.test(verb)) {
200
205
  await this.restore();
201
- if (store.clear()) { this.options.changed?.(ctx); ctx.ui.notify("Goal cleared", "info"); }
206
+ if (store.clear()) { this.contextDirty = true; this.options.changed?.(ctx); ctx.ui.notify("Goal cleared", "info"); }
202
207
  return;
203
208
  }
204
209
  if (verb === "pause") { this.pause(ctx, "paused by you"); return; }
@@ -208,6 +213,7 @@ export class GoalLoop {
208
213
  if (this.options.planActive()) { ctx.ui.notify("Leave plan mode before resuming the goal", "warning"); return; }
209
214
  store.setStatus("active");
210
215
  store.setRound(0, goal.phase);
216
+ this.contextDirty = true;
211
217
  this.options.changed?.(ctx);
212
218
  this.pi.sendUserMessage("Resume work on the active goal: " + goal.text, { deliverAs: "followUp" });
213
219
  return;
@@ -257,21 +263,37 @@ export class GoalLoop {
257
263
  const goal = this.goal();
258
264
  if (!goal || goal.status === "complete") return;
259
265
  if (goal.status === "active" && !this.options.planActive()) await this.useRole(goal.phase === "audit" ? "advisor" : "implement", ctx);
266
+ this.contextDirty = true;
260
267
  return { message: { customType: CONTEXT_TYPE, content: this.context(goal), display: false } };
261
268
  });
262
269
 
263
- // Keep only the newest goal context and continuation so repeated rounds do not bloat the prompt.
270
+ // Keep only the newest goal context and continuation. Scan in place first and allocate a new
271
+ // message array only when stale pi-jar messages actually need pruning.
264
272
  this.pi.on("context", async (event) => {
273
+ if (!this.contextDirty) return;
265
274
  const goal = this.goal();
266
- const seen = new Set<string>();
267
- const messages = [...event.messages].reverse().filter((raw) => {
275
+ const active = !!goal && goal.status !== "complete";
276
+ let latestContext = -1;
277
+ let latestContinuation = -1;
278
+ let needsPrune = false;
279
+ for (let index = event.messages.length - 1; index >= 0; index--) {
280
+ const type = (event.messages[index] as { customType?: string }).customType;
281
+ if (type === CONTEXT_TYPE) {
282
+ if (!active || latestContext >= 0) needsPrune = true;
283
+ else latestContext = index;
284
+ } else if (type === CONTINUATION_TYPE) {
285
+ if (!active || latestContinuation >= 0) needsPrune = true;
286
+ else latestContinuation = index;
287
+ }
288
+ }
289
+ this.contextDirty = false;
290
+ if (!needsPrune) return;
291
+ return { messages: event.messages.filter((raw, index) => {
268
292
  const type = (raw as { customType?: string }).customType;
269
- if (type !== CONTEXT_TYPE && type !== CONTINUATION_TYPE) return true;
270
- if (!goal || goal.status === "complete" || seen.has(type)) return false;
271
- seen.add(type);
293
+ if (type === CONTEXT_TYPE) return active && index === latestContext;
294
+ if (type === CONTINUATION_TYPE) return active && index === latestContinuation;
272
295
  return true;
273
- }).reverse();
274
- return { messages };
296
+ }) };
275
297
  });
276
298
 
277
299
  this.pi.on("agent_before_settle", async (event, ctx) => {
@@ -295,6 +317,7 @@ export class GoalLoop {
295
317
  store.setRound(round, audit ? "audit" : "implement");
296
318
  await this.useRole(audit ? "advisor" : "implement", ctx);
297
319
  this.options.changed?.(ctx);
320
+ this.contextDirty = true;
298
321
  return {
299
322
  entries: [{ type: "custom_message", customType: CONTINUATION_TYPE, display: false,
300
323
  content: audit ? this.auditMessage(goal, round) : this.implementMessage(goal, round) }],
@@ -308,6 +331,8 @@ export class GoalLoop {
308
331
  this.options.changed?.(ctx);
309
332
  });
310
333
 
334
+ this.pi.on("session_start", () => { this.contextDirty = true; });
335
+ this.pi.on("session_tree", () => { this.contextDirty = true; });
311
336
  this.pi.on("session_shutdown", async () => { await this.restore(); });
312
337
  }
313
338
  }
package/src/goals.ts CHANGED
@@ -72,11 +72,22 @@ export class GoalStore {
72
72
 
73
73
  restore(branch: readonly unknown[]): void {
74
74
  this.value = undefined;
75
- for (const raw of branch) {
75
+ // Collect only goal events while walking backward; stop at the newest valid set/clear
76
+ // checkpoint, then replay that tiny list forward.
77
+ const replay: unknown[] = [];
78
+ for (let index = branch.length - 1; index >= 0; index--) {
79
+ const raw = branch[index];
76
80
  if (!raw || typeof raw !== "object") continue;
77
81
  const entry = raw as Record<string, unknown>;
78
- if (entry.type === "custom" && entry.customType === GOAL_ENTRY) this.apply(entry.data);
82
+ if (entry.type !== "custom" || entry.customType !== GOAL_ENTRY || !entry.data || typeof entry.data !== "object") continue;
83
+ const data = entry.data as Record<string, unknown>;
84
+ replay.push(data);
85
+ const validCheckpoint = data.op === "clear" && data.v === 1
86
+ || data.op === "set" && ((data.v === 1 && validGoal(data.text))
87
+ || (data.v === 2 && validId(data.id) && validGoal(data.text)));
88
+ if (validCheckpoint) break;
79
89
  }
90
+ for (let index = replay.length - 1; index >= 0; index--) this.apply(replay[index]);
80
91
  }
81
92
 
82
93
  private commit(event: GoalEvent): boolean {
package/src/history-ui.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Input, Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
3
  import { createHistorySnapshot, historyChunk, historyPage, safeHistoryText, type HistoryItem } from "./history.ts";
4
+ import { sessionBranch } from "./session-branch.ts";
4
5
 
5
6
  /** Separate, read-only view. Pi's native transcript and session file are never modified. */
6
7
  export async function openJarHistory(ctx: ExtensionContext): Promise<void> {
7
8
  if (!ctx.hasUI || ctx.mode !== "tui") return;
8
9
  // Capture the active branch ONCE on open. Paging and search operate on this snapshot.
9
- const snapshot = createHistorySnapshot(ctx.sessionManager.getBranch());
10
+ const snapshot = createHistorySnapshot(sessionBranch(ctx) as ReturnType<typeof ctx.sessionManager.getBranch>);
10
11
  await ctx.ui.custom<void>((tui, theme, _keys, done) => {
11
12
  let current = historyPage(snapshot);
12
13
  let selected = Math.max(0, current.items.length - 1);
@@ -4,6 +4,7 @@ import { openPanel, type PanelTab } from "./panel.ts";
4
4
  import type { Quota } from "./quota.ts";
5
5
  import type { SideUsage } from "./side-model.ts";
6
6
  import { collectUsage, usageLines } from "./usage-view.ts";
7
+ import { sessionBranch } from "./session-branch.ts";
7
8
 
8
9
  /** Pi's default compaction reserve, used when its settings cannot be read. */
9
10
  const DEFAULT_RESERVE = 16_384;
@@ -52,14 +53,25 @@ export function registerInfoPanels(pi: ExtensionAPI, deps: InfoPanelDeps): void
52
53
  });
53
54
  pi.on("session_start", () => { parts = undefined; });
54
55
  const tabs = (ctx: ExtensionContext): PanelTab[] => {
55
- const context = contextFor(pi, ctx, parts);
56
+ // These panels can scan a large branch/projection. Snapshot each expensive view once per
57
+ // panel open, lazily on first render, so scrolling/resizing does not repeat O(history) work.
58
+ let usage: ReturnType<typeof collectUsage> | undefined;
59
+ let context: ReturnType<typeof contextFor> | undefined;
56
60
  return [
57
- { name: "Usage", render: (width, fg) => usageLines({
58
- stats: collectUsage(ctx.sessionManager.getBranch() as never, deps.side.all()),
59
- ...(ctx.model?.provider ? { provider: ctx.model.provider } : {}),
60
- ...(deps.quota(ctx) ? { quota: deps.quota(ctx)! } : {}),
61
- quotaEnabled: deps.quotaEnabled(), now: Date.now() }, width, fg) },
62
- { name: "Context", render: (width, fg) => contextLines(context, width, fg) }
61
+ { name: "Usage", render: (width, fg) => {
62
+ usage ??= collectUsage(sessionBranch(ctx) as never, deps.side.all());
63
+ const quota = deps.quota(ctx);
64
+ return usageLines({
65
+ stats: usage,
66
+ ...(ctx.model?.provider ? { provider: ctx.model.provider } : {}),
67
+ ...(quota ? { quota } : {}),
68
+ quotaEnabled: deps.quotaEnabled(), now: Date.now()
69
+ }, width, fg);
70
+ } },
71
+ { name: "Context", render: (width, fg) => {
72
+ context ??= contextFor(pi, ctx, parts);
73
+ return contextLines(context, width, fg);
74
+ } }
63
75
  ];
64
76
  };
65
77
  const plain = (_color: string, text: string) => text;
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
  }
package/src/plan.ts CHANGED
@@ -9,6 +9,7 @@ import { type ModelRoleManager } from "./model-roles.ts";
9
9
  import { openPlanView } from "./plan-view.ts";
10
10
  import { extractApproachSteps, isSafePlanCommand, PLAN_TEMPLATE, planTextFromSteps, validatePlanDocument } from "./plan-utils.ts";
11
11
  import { cleanText } from "./status.ts";
12
+ import { sessionBranch } from "./session-branch.ts";
12
13
  import { type TodoStore } from "./tasks.ts";
13
14
 
14
15
  export const PLAN_ENTRY = "pi-jar.plan";
@@ -135,6 +136,7 @@ export class PlanMode {
135
136
  try { this.ensureDirectory(ctx); }
136
137
  catch (error) { ctx.ui.notify("Plan mode unavailable: cannot create plan directory: " + (error as Error).message, "error"); return; }
137
138
  this.enabled = true;
139
+ this.contextDirty = true;
138
140
  this.pendingReview = false;
139
141
  this.reminders = 0;
140
142
  this.toolsBefore = this.pi.getActiveTools();
@@ -149,6 +151,7 @@ export class PlanMode {
149
151
  private async leave(ctx: ExtensionContext, persist = true): Promise<void> {
150
152
  const wasEnabled = this.enabled;
151
153
  this.enabled = false;
154
+ this.contextDirty = true;
152
155
  this.compacting = false;
153
156
  this.pendingReview = false;
154
157
  this.compactGeneration++;
@@ -293,14 +296,16 @@ export class PlanMode {
293
296
  }
294
297
 
295
298
  private restoreFrom(branch: readonly unknown[]): PlanState | undefined {
296
- let state: PlanState | undefined;
297
- for (const raw of branch) {
299
+ // Only the newest persisted plan state matters. Walk backward so resuming a long session
300
+ // does not inspect every historical transcript entry.
301
+ for (let index = branch.length - 1; index >= 0; index--) {
302
+ const raw = branch[index];
298
303
  if (!raw || typeof raw !== "object") continue;
299
304
  const entry = raw as Record<string, unknown>;
300
305
  if (entry.type !== "custom" || entry.customType !== PLAN_ENTRY || !entry.data || typeof entry.data !== "object") continue;
301
306
  const data = entry.data as Record<string, unknown>;
302
307
  if ((data.v !== 1 && data.v !== 2) || typeof data.enabled !== "boolean" || !Array.isArray(data.steps)) continue;
303
- state = {
308
+ return {
304
309
  v: 2, enabled: data.enabled,
305
310
  steps: data.steps.filter((item): item is string => typeof item === "string").map((item) => cleanText(item, 240)).filter(Boolean).slice(0, 50),
306
311
  text: typeof data.text === "string" ? safePlanText(data.text) : "",
@@ -308,7 +313,7 @@ export class PlanMode {
308
313
  ...(typeof data.title === "string" ? { title: cleanText(data.title, 120) } : {})
309
314
  };
310
315
  }
311
- return state;
316
+ return undefined;
312
317
  }
313
318
 
314
319
  private async restore(branch: readonly unknown[], ctx: ExtensionContext): Promise<void> {
@@ -411,14 +416,34 @@ export class PlanMode {
411
416
 
412
417
  this.pi.on("before_agent_start", async () => {
413
418
  if (!this.enabled) return;
419
+ this.contextDirty = true;
414
420
  return { message: { customType: "pi-jar.plan-context", content: this.context(), display: false } };
415
421
  });
416
422
 
423
+ // Keep plan-only prompt messages bounded. When plan mode is active, retain only the newest
424
+ // context and newest reminder; when it is off, remove all stale plan prompt messages.
417
425
  this.pi.on("context", async (event) => {
418
- if (this.enabled) return;
419
- return { messages: event.messages.filter((raw) => {
420
- const message = raw as { customType?: string };
421
- return message.customType !== "pi-jar.plan-context" && message.customType !== "pi-jar.plan-reminder";
426
+ if (!this.contextDirty) return;
427
+ let latestContext = -1;
428
+ let latestReminder = -1;
429
+ let needsPrune = false;
430
+ for (let index = event.messages.length - 1; index >= 0; index--) {
431
+ const type = (event.messages[index] as { customType?: string }).customType;
432
+ if (type === "pi-jar.plan-context") {
433
+ if (!this.enabled || latestContext >= 0) needsPrune = true;
434
+ else latestContext = index;
435
+ } else if (type === "pi-jar.plan-reminder") {
436
+ if (!this.enabled || latestReminder >= 0) needsPrune = true;
437
+ else latestReminder = index;
438
+ }
439
+ }
440
+ this.contextDirty = false;
441
+ if (!needsPrune) return;
442
+ return { messages: event.messages.filter((raw, index) => {
443
+ const type = (raw as { customType?: string }).customType;
444
+ if (type === "pi-jar.plan-context") return this.enabled && index === latestContext;
445
+ if (type === "pi-jar.plan-reminder") return this.enabled && index === latestReminder;
446
+ return true;
422
447
  }) };
423
448
  });
424
449
 
@@ -434,6 +459,7 @@ export class PlanMode {
434
459
  return;
435
460
  }
436
461
  this.reminders++;
462
+ this.contextDirty = true;
437
463
  return {
438
464
  entries: [{ type: "custom_message", customType: "pi-jar.plan-reminder", display: false,
439
465
  content: `[PI-JAR PLAN MODE] You ended the turn without ${PLAN_SUBMIT_TOOL}. If the plan is ready, write it to the plan directory and call ${PLAN_SUBMIT_TOOL}. If you need a decision from the user, use jar_ask. Do not ask for approval in chat.` }],
@@ -453,8 +479,8 @@ export class PlanMode {
453
479
  if (this.enabled && this.pendingReview) await this.review(ctx);
454
480
  });
455
481
 
456
- this.pi.on("session_start", async (_event, ctx) => { await this.restore(ctx.sessionManager.getBranch(), ctx); });
457
- this.pi.on("session_tree", async (_event, ctx) => { await this.restore(ctx.sessionManager.getBranch(), ctx); });
482
+ this.pi.on("session_start", async (_event, ctx) => { this.contextDirty = true; await this.restore(sessionBranch(ctx), ctx); });
483
+ this.pi.on("session_tree", async (_event, ctx) => { this.contextDirty = true; await this.restore(sessionBranch(ctx), ctx); });
458
484
  this.pi.on("session_shutdown", async (_event, ctx) => { if (this.enabled) await this.leave(ctx, false); });
459
485
  }
460
486
  }
@@ -4,8 +4,21 @@ import { cleanText } from "./status.ts";
4
4
 
5
5
  export interface PastPrompt { text: string; source: "this session" | "earlier session"; at?: number }
6
6
 
7
- const userText = (message: { content?: unknown }) => typeof message.content === "string" ? message.content
8
- : Array.isArray(message.content) ? message.content.filter((part: { type?: string }) => part?.type === "text").map((part: { text?: string }) => part.text ?? "").join("\n") : "";
7
+ const MAX_PROMPT_CHARS = 8000;
8
+ const userText = (message: { content?: unknown }): string => {
9
+ if (typeof message.content === "string") return message.content.slice(0, MAX_PROMPT_CHARS + 1);
10
+ if (!Array.isArray(message.content)) return "";
11
+ let text = "";
12
+ for (const part of message.content as Array<{ type?: string; text?: string }>) {
13
+ if (part?.type !== "text" || typeof part.text !== "string") continue;
14
+ const separator = text ? "\n" : "";
15
+ const room = MAX_PROMPT_CHARS + 1 - text.length - separator.length;
16
+ if (room <= 0) break;
17
+ text += separator + part.text.slice(0, room);
18
+ if (part.text.length > room) break;
19
+ }
20
+ return text;
21
+ };
9
22
 
10
23
  /**
11
24
  * Prompts newest first and deduplicated: every user message in this session file (all branches),
@@ -17,13 +30,17 @@ export function collectPrompts(entries: readonly unknown[], earlier: readonly {
17
30
  const add = (text: string, source: PastPrompt["source"], at?: number) => {
18
31
  const value = text.trim();
19
32
  const key = value.replace(/\s+/g, " ");
20
- if (!value || value.length > 8000 || seen.has(key)) return;
33
+ if (!value || value.length > MAX_PROMPT_CHARS || seen.has(key)) return;
21
34
  seen.add(key);
22
35
  result.push({ text: value, source, ...(at ? { at } : {}) });
23
36
  };
24
- const own = entries.filter((raw): raw is { type: string; message: { role: string; content?: unknown }; timestamp?: string } =>
25
- !!raw && typeof raw === "object" && (raw as { type?: string }).type === "message" && (raw as { message?: { role?: string } }).message?.role === "user");
26
- for (const entry of [...own].reverse()) add(userText(entry.message), "this session", entry.timestamp ? Date.parse(entry.timestamp) : undefined);
37
+ for (let index = entries.length - 1; index >= 0; index--) {
38
+ const raw = entries[index];
39
+ if (!raw || typeof raw !== "object") continue;
40
+ const entry = raw as { type?: string; message?: { role?: string; content?: unknown }; timestamp?: string };
41
+ if (entry.type !== "message" || entry.message?.role !== "user") continue;
42
+ add(userText(entry.message), "this session", entry.timestamp ? Date.parse(entry.timestamp) : undefined);
43
+ }
27
44
  for (const session of earlier) if (session.firstMessage) add(session.firstMessage, "earlier session", session.modified?.getTime());
28
45
  return result;
29
46
  }
@@ -36,9 +53,9 @@ export async function openPromptSearch(ctx: ExtensionContext, prompts: readonly
36
53
  let query = "";
37
54
  let selected = 0;
38
55
  let first = 0;
39
- let visible: PastPrompt[] = [...prompts];
56
+ let visible: PastPrompt[] = prompts.slice();
40
57
  const rows = 10;
41
- const filter = () => { visible = query ? fuzzyFilter([...prompts], query, (item) => item.text) : [...prompts]; selected = 0; first = 0; };
58
+ const filter = () => { visible = query ? fuzzyFilter(prompts, query, (item) => item.text) : prompts.slice(); selected = 0; first = 0; };
42
59
  return {
43
60
  invalidate() {},
44
61
  handleInput(data: string) {