pi-jar 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/footer.ts CHANGED
@@ -3,6 +3,7 @@ import { sep } from "node:path";
3
3
  import { sliceByColumn, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
4
  import { DEFAULT_FOOTER_SETTINGS, type FooterSettings } from "./footer-settings.ts";
5
5
  import { roleFrame } from "./animations.ts";
6
+ import { icon, type IconKey, withIcon } from "./icons.ts";
6
7
  import type { RoleStatus } from "./roles.ts";
7
8
  import { ACTIVE_STATES, cleanText, type JarStatus } from "./status.ts";
8
9
  import type { Quota } from "./quota.ts";
@@ -18,8 +19,10 @@ export interface FooterView extends JarStatus {
18
19
  settings?: FooterSettings;
19
20
  context: string;
20
21
  goal?: string;
21
- /** pi-jar's own short indicators (changes to review, running shells), shown before extras. */
22
+ /** pi-jar's own short indicators (changes to review), shown before extras. */
22
23
  chips?: readonly string[];
24
+ /** Live subagents and background shells, one clickable row each. */
25
+ activity?: readonly FooterActivity[];
23
26
  memory?: string;
24
27
  cost?: string;
25
28
  quota?: Quota;
@@ -30,6 +33,49 @@ export interface FooterView extends JarStatus {
30
33
  motionBudget?: number;
31
34
  }
32
35
 
36
+ export type ActivityState = "queued" | "running" | "done" | "failed" | "stopped";
37
+ export interface FooterActivity {
38
+ kind: "subagent" | "shell";
39
+ id: string;
40
+ name: string;
41
+ state: ActivityState;
42
+ /** What it is doing now: the current tool, or the shell command. */
43
+ detail?: string;
44
+ /** Right-aligned counters, e.g. `3 tools · 12s`. */
45
+ stats?: string;
46
+ }
47
+
48
+ /** A clickable footer region, in the footer component's local cells. */
49
+ export type FooterTarget = { kind: "subagent" | "shell"; id: string } | { kind: "activity" } | { kind: "session" };
50
+ export interface FooterHit { y: number; x0: number; x1: number; target: FooterTarget }
51
+
52
+ const MAX_ACTIVITY_ROWS = 3;
53
+ const THINKING_ICONS: Record<string, IconKey> = {
54
+ minimal: "thinkingMinimal", low: "thinkingLow", medium: "thinkingMedium", high: "thinkingHigh", xhigh: "thinkingXhigh", max: "thinkingMax"
55
+ };
56
+
57
+ /** `ctx 58%` → `◫ 58%`: the icon replaces the word, and ascii keeps the word. */
58
+ const metric = (key: IconKey, word: string, text: string) => withIcon(key, text.startsWith(word + " ") ? text.slice(word.length + 1) : text);
59
+
60
+ function activityGlyph(state: ActivityState): { glyph: string; color: Color } {
61
+ if (state === "running") return { glyph: icon("running"), color: "accent" };
62
+ if (state === "queued") return { glyph: icon("pending"), color: "dim" };
63
+ if (state === "done") return { glyph: icon("success"), color: "success" };
64
+ if (state === "failed") return { glyph: icon("error"), color: "error" };
65
+ return { glyph: icon("stopped"), color: "dim" };
66
+ }
67
+
68
+ function activityRow(item: FooterActivity, width: number, theme: FooterTheme): string {
69
+ const { glyph, color } = activityGlyph(item.state);
70
+ const head = theme.fg(color, glyph) + " " + theme.fg(item.state === "running" ? "accent" : "muted",
71
+ withIcon(item.kind === "subagent" ? "agent" : "shell", cleanText(item.name, 28)));
72
+ const stats = item.stats ? theme.fg("dim", cleanText(item.stats, 40)) : "";
73
+ const room = width - visibleWidth(head) - visibleWidth(stats) - 4;
74
+ const detail = item.detail && room >= 6 ? " " + theme.fg("dim", truncateToWidth(cleanText(item.detail, 200), room, "…")) : "";
75
+ const gap = " ".repeat(Math.max(1, width - visibleWidth(head) - visibleWidth(detail) - visibleWidth(stats)));
76
+ return truncateToWidth(head + detail + (stats ? gap + stats : ""), width);
77
+ }
78
+
33
79
  function fitCwd(cwd: string, width: number): string {
34
80
  if (visibleWidth(cwd) <= width) return cwd;
35
81
  return "…" + sliceByColumn(cwd, visibleWidth(cwd) - width + 1, width - 1);
@@ -45,33 +91,50 @@ function roleColor(role: RoleStatus): Color {
45
91
 
46
92
  /** Every line is bounded in terminal cells; critical role failure takes precedence. */
47
93
  export function renderFooter(view: FooterView, width: number, theme: FooterTheme): string[] {
94
+ return renderFooterLayout(view, width, theme).lines;
95
+ }
96
+
97
+ /** Footer lines plus the clickable regions (activity rows, the session name) they contain. */
98
+ export function renderFooterLayout(view: FooterView, width: number, theme: FooterTheme): { lines: string[]; hits: FooterHit[] } {
99
+ const hits: FooterHit[] = [];
100
+ const lines = footerLines(view, width, theme, hits);
101
+ return { lines, hits };
102
+ }
103
+
104
+ function footerLines(view: FooterView, width: number, theme: FooterTheme, hits: FooterHit[]): string[] {
48
105
  if (width <= 0) return [];
49
106
  const show = view.settings ?? DEFAULT_FOOTER_SETTINGS;
50
- if (!view.goal && !Object.entries(show).some(([field, enabled]) => enabled && (field !== "memory" || !!view.memory))) return [];
107
+ const activity = view.activity ?? [];
108
+ if (!view.goal && !activity.length && !Object.entries(show).some(([field, enabled]) => enabled && (field !== "memory" || !!view.memory))) return [];
51
109
  // Keep tiny terminals unframed so context and urgent role status stay readable.
52
110
  const framed = width >= 32;
53
111
  const contentWidth = framed ? width - 4 : width;
112
+ // Local cell offsets of content inside the frame, for click targets.
113
+ const offsetX = framed ? 2 : 0;
114
+ const offsetY = framed ? 1 : 0;
54
115
  const roles = show.roles ? [...view.roles].sort((a, b) => Number(b.state === "failed") - Number(a.state === "failed")) : [];
55
116
  const primary = roles.find((role) => role.state === "failed") ?? roles.find((role) => ACTIVE_STATES.has(role.state));
56
117
  const model = cleanText(view.model, 24) || "no-model";
57
- const context = cleanText(view.context, 12) || "ctx ?";
118
+ const context = metric("context", "ctx", cleanText(view.context, 12) || "ctx ?");
58
119
  const goal = view.goal ? cleanText(view.goal, 240) : "";
59
120
  const quota = view.quota;
60
- const windows = [quota?.fiveHour && `5h ${Math.round(quota.fiveHour.used)}%`, quota?.week && `week ${Math.round(quota.week.used)}%`].filter((value): value is string => !!value);
61
- const cost = view.cost ? cleanText(view.cost, 20) : undefined;
121
+ const windowList = [quota?.fiveHour && `5h ${Math.round(quota.fiveHour.used)}%`, quota?.week && `week ${Math.round(quota.week.used)}%`].filter((value): value is string => !!value);
122
+ const windows = windowList.map((value, index) => index ? value : withIcon("quota", value));
123
+ const cost = view.cost ? metric("cost", "cost", cleanText(view.cost, 20)) : undefined;
124
+ const memory = view.memory ? metric("memory", "ram", view.memory) : undefined;
62
125
  const prefix = view.demo ? theme.fg("warning", "DEMO") : "";
63
126
  const effortColors = {
64
127
  off: "thinkingOff", minimal: "thinkingMinimal", low: "thinkingLow", medium: "thinkingMedium",
65
128
  high: "thinkingHigh", xhigh: "thinkingXhigh", max: "thinkingMax"
66
129
  } as const;
67
- const effort = show.effort && view.effort ? theme.fg(effortColors[view.effort], view.effort) : "";
130
+ const effort = show.effort && view.effort ? theme.fg(effortColors[view.effort], THINKING_ICONS[view.effort] ? withIcon(THINKING_ICONS[view.effort]!, view.effort) : view.effort) : "";
68
131
  const firstRole = primary ? `${primary.label} ${roleFrame(primary.state, view.frame, view.animations)}` : undefined;
69
132
  const attention = firstRole ?? (show.extras ? view.extras[0] : undefined);
70
133
  // At narrow widths reserve the right-hand slot for critical/active status, then context.
71
- const modelLabel = show.model ? theme.fg("muted", model) : "";
134
+ const modelLabel = show.model ? theme.fg("muted", withIcon("model", model)) : "";
72
135
  const left = [prefix, modelLabel, effort].filter(Boolean).join(theme.fg("dim", " · "));
73
136
  // Context is never dropped; optional metrics move to a second line on medium widths.
74
- const metrics = [...(show.context ? [context] : []), ...(show.memory && contentWidth >= 90 && view.memory ? [view.memory] : []), ...(show.cost && contentWidth >= 52 && cost ? [cost] : []), ...(show.quota && contentWidth >= 90 ? windows : [])];
137
+ const metrics = [...(show.context ? [context] : []), ...(show.memory && contentWidth >= 90 && memory ? [memory] : []), ...(show.cost && contentWidth >= 52 && cost ? [cost] : []), ...(show.quota && contentWidth >= 90 ? windows : [])];
75
138
  let right = theme.fg("dim", metrics.join(" · "));
76
139
  if (contentWidth < 52 && attention && contentWidth >= 26) {
77
140
  const room = Math.max(0, contentWidth - Math.min(visibleWidth(left), 12) - visibleWidth(right) - 4);
@@ -87,18 +150,24 @@ export function renderFooter(view: FooterView, width: number, theme: FooterTheme
87
150
  if (room >= 3 && beforeEffort) return truncateToWidth(beforeEffort, room) + theme.fg("dim", " · ") + effort;
88
151
  return truncateToWidth(beforeEffort || effort, availableLeft);
89
152
  })();
90
- const name = show.sessionName && view.sessionName ? cleanText(view.sessionName, 128) : "";
153
+ const name = show.sessionName ? cleanText(view.sessionName || "sessions", 128) : "";
91
154
  const rawCwd = show.cwd && view.cwd ? cleanText(view.cwd, 256) : "";
92
155
  const home = homedir();
93
156
  const cwd = home && rawCwd.startsWith(home + sep) ? `~${rawCwd.slice(home.length)}` : rawCwd;
94
157
  let namedLeft = fittedLeft;
95
158
  let space = availableLeft - visibleWidth(namedLeft);
96
- if (cwd && space >= 7) {
97
- const budget = name ? Math.min(24, Math.max(4, Math.floor((space - 6) / 2))) : Math.min(32, space - 3);
98
- namedLeft += theme.fg("dim", " · ") + theme.fg("muted", fitCwd(cwd, budget));
159
+ const folder = icon("folder") ? icon("folder") + " " : "";
160
+ if (cwd && space >= 7 + folder.length) {
161
+ const budget = name ? Math.min(24, Math.max(4, Math.floor((space - 6) / 2))) : Math.min(32, space - 3 - folder.length);
162
+ namedLeft += theme.fg("dim", " · ") + theme.fg("muted", folder + fitCwd(cwd, budget));
99
163
  space = availableLeft - visibleWidth(namedLeft);
100
164
  }
101
- if (name && space >= 7) namedLeft += theme.fg("dim", " · ") + theme.fg("muted", truncateToWidth(name, space - 3, "…"));
165
+ if (name && space >= 7) {
166
+ const start = visibleWidth(namedLeft) + 3;
167
+ const label = truncateToWidth(withIcon("session", name), space - 3, "…");
168
+ namedLeft += theme.fg("dim", " · ") + theme.fg("muted", label);
169
+ hits.push({ y: offsetY, x0: offsetX + start, x1: offsetX + start + visibleWidth(label), target: { kind: "session" } });
170
+ }
102
171
  const gap = " ".repeat(Math.max(1, contentWidth - visibleWidth(namedLeft) - visibleWidth(right)));
103
172
  const lines = [truncateToWidth(namedLeft + gap + right, contentWidth)];
104
173
  if (goal) {
@@ -107,8 +176,16 @@ export function renderFooter(view: FooterView, width: number, theme: FooterTheme
107
176
  lines.push(truncateToWidth(goalPrefix + theme.fg("muted", truncateToWidth(goal, room, "…")), contentWidth));
108
177
  }
109
178
  if (contentWidth < 52) {
110
- const small = [...(show.memory && view.memory ? [view.memory] : []), ...(show.quota ? windows : [])];
179
+ const small = [...(show.memory && memory ? [memory] : []), ...(show.quota ? windows : [])];
111
180
  if (small.length && contentWidth >= 26) lines.push(truncateToWidth(theme.fg("dim", small.join(" · ")), contentWidth));
181
+ // Too narrow for rows: one clickable count of live work.
182
+ const agents = activity.filter((item) => item.kind === "subagent" && (item.state === "running" || item.state === "queued")).length;
183
+ const shells = activity.filter((item) => item.kind === "shell" && item.state === "running").length;
184
+ const counts = [agents ? withIcon("subagents", `${agents}`) : "", shells ? withIcon("shell", `${shells}`) : ""].filter(Boolean).join(" ");
185
+ if (counts) {
186
+ hits.push({ y: offsetY + lines.length, x0: offsetX, x1: offsetX + contentWidth, target: { kind: "activity" } });
187
+ lines.push(truncateToWidth(theme.fg("accent", counts), contentWidth));
188
+ }
112
189
  return framed ? frameFooter(lines, width, view, theme) : lines;
113
190
  }
114
191
 
@@ -123,19 +200,29 @@ export function renderFooter(view: FooterView, width: number, theme: FooterTheme
123
200
  const remaining = Math.max(0, view.extras.length - (contentWidth >= 100 ? 2 : 1));
124
201
  const extras = show.extras ? view.extras.slice(0, contentWidth >= 100 ? 2 : 1).map((status) => theme.fg("muted", status)) : [];
125
202
  if (show.extras && remaining) extras.push(theme.fg("dim", `+${remaining}`));
126
- if (show.branch && contentWidth >= 52 && view.branch) extras.push(theme.fg("dim", `git ${cleanText(view.branch, 28)}`));
203
+ if (show.branch && contentWidth >= 52 && view.branch) extras.push(theme.fg("dim", withIcon("branch", cleanText(view.branch, 28))));
127
204
  const chips = (view.chips ?? []).map((chip) => theme.fg("warning", cleanText(chip, 32)));
128
205
  const joined = [...segments, ...chips, ...extras].join(theme.fg("dim", " · "));
129
206
  if (joined) lines.push(truncateToWidth(joined, contentWidth));
130
- if (contentWidth < 90 && ((show.quota && windows.length) || (show.memory && view.memory))) {
131
- const summary = [...(show.memory && view.memory ? [view.memory] : []), ...(show.quota ? windows : [])].join(" · ");
207
+ if (contentWidth < 90 && ((show.quota && windows.length) || (show.memory && memory))) {
208
+ const summary = [...(show.memory && memory ? [memory] : []), ...(show.quota ? windows : [])].join(" · ");
132
209
  if (summary) lines.push(truncateToWidth(theme.fg("dim", summary), contentWidth));
133
210
  }
211
+ const shown = activity.length > MAX_ACTIVITY_ROWS ? activity.slice(0, MAX_ACTIVITY_ROWS - 1) : activity;
212
+ for (const item of shown) {
213
+ hits.push({ y: offsetY + lines.length, x0: offsetX, x1: offsetX + contentWidth, target: { kind: item.kind, id: item.id } });
214
+ lines.push(activityRow(item, contentWidth, theme));
215
+ }
216
+ if (shown.length < activity.length) {
217
+ hits.push({ y: offsetY + lines.length, x0: offsetX, x1: offsetX + contentWidth, target: { kind: "activity" } });
218
+ lines.push(truncateToWidth(theme.fg("dim", ` +${activity.length - shown.length} more · ctrl+alt+a to manage`), contentWidth));
219
+ }
134
220
  return framed ? frameFooter(lines, width, view, theme) : lines;
135
221
  }
136
222
 
137
223
  function frameFooter(lines: string[], width: number, view: FooterView, theme: FooterTheme): string[] {
138
- const active = (view.settings?.roles ?? true) && view.roles.some((role) => ACTIVE_STATES.has(role.state));
224
+ const active = ((view.settings?.roles ?? true) && view.roles.some((role) => ACTIVE_STATES.has(role.state)))
225
+ || !!view.activity?.some((item) => item.state === "running");
139
226
  const pulse = active && view.animations ? ["◇", "◈", "◆", "◈"][view.frame % 4] : "◇";
140
227
  const top = theme.fg("dim", "╭─") + theme.fg(active ? "accent" : "dim", pulse)
141
228
  + theme.fg("dim", "─".repeat(width - 4) + "╮");
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);
package/src/icons.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Glyph sets for pi-jar chrome, modelled on omp's symbol presets:
3
+ * - `unicode`: symbols every monospace font ships (default);
4
+ * - `nerd`: Nerd Font icons (install a Nerd Font, e.g. via `brew install --cask font-jetbrains-mono-nerd-font`);
5
+ * - `ascii`: no decorative glyphs; key hints spelled out.
6
+ * Decorative keys may map to "" in `ascii`; always render through `withIcon`.
7
+ */
8
+ export const ICON_SETS = ["unicode", "nerd", "ascii"] as const;
9
+ export type IconSet = (typeof ICON_SETS)[number];
10
+
11
+ export type IconKey =
12
+ | "pi" | "model" | "folder" | "branch" | "context" | "memory" | "cost" | "quota" | "session"
13
+ | "subagents" | "agent" | "shell" | "changes" | "goal" | "plan" | "tasks" | "time" | "tools" | "roles"
14
+ | "esc" | "enter" | "tab" | "shiftEnter"
15
+ | "success" | "error" | "running" | "pending" | "stopped" | "warning"
16
+ | "thinkingMinimal" | "thinkingLow" | "thinkingMedium" | "thinkingHigh" | "thinkingXhigh" | "thinkingMax";
17
+
18
+ const UNICODE: Record<IconKey, string> = {
19
+ pi: "π", model: "⬢", folder: "⌂", branch: "⎇", context: "◫", memory: "▤", cost: "", quota: "◔", session: "◈",
20
+ subagents: "⧉", agent: "◉", shell: "⚙", changes: "±", goal: "◎", plan: "◆", tasks: "☰", time: "◷", tools: "⚒", roles: "⇄",
21
+ esc: "⎋", enter: "⏎", tab: "⇥", shiftEnter: "⇧⏎",
22
+ success: "✔", error: "✖", running: "●", pending: "○", stopped: "■", warning: "⚠",
23
+ thinkingMinimal: "○", thinkingLow: "◔", thinkingMedium: "◑", thinkingHigh: "◒", thinkingXhigh: "◕", thinkingMax: "◉"
24
+ };
25
+
26
+ // Code points follow omp's nerd preset (Font Awesome / Codicons / Material Design ranges).
27
+ const NERD: Record<IconKey, string> = {
28
+ pi: "\u{f0d57}", model: "\uec19", folder: "\uf115", branch: "\uf126", context: "\ue70f", memory: "\u{f035b}",
29
+ cost: "\uf0d6", quota: "\uf0e4", session: "\u{f02da}",
30
+ subagents: "\uf0c0", agent: "\uf007", shell: "\uf120", changes: "\uf044", goal: "\uf140", plan: "\uf2d2",
31
+ tasks: "\uf0ae", time: "\uf017", tools: "\uf0ad", roles: "\u{f04e1}",
32
+ esc: "\u{f12b7}", enter: "\u{f0311}", tab: "\u{f0312}", shiftEnter: "\u{f0636}\u{f0311}",
33
+ success: "\uf00c", error: "\uf00d", running: "\uf111", pending: "\uf10c", stopped: "\uf04d", warning: "\uf071",
34
+ thinkingMinimal: "\u{f0a9e}", thinkingLow: "\u{f0a9f}", thinkingMedium: "\u{f0aa1}", thinkingHigh: "\u{f0aa3}",
35
+ thinkingXhigh: "\u{f0aa5}", thinkingMax: "\uf06d"
36
+ };
37
+
38
+ const ASCII: Record<IconKey, string> = {
39
+ pi: "", model: "", folder: "", branch: "git", context: "ctx", memory: "ram", cost: "cost", quota: "quota", session: "",
40
+ subagents: "", agent: "", shell: "$", changes: "+/-", goal: "goal", plan: "plan", tasks: "", time: "", tools: "", roles: "",
41
+ esc: "esc", enter: "enter", tab: "tab", shiftEnter: "shift+enter",
42
+ success: "ok", error: "x", running: "*", pending: "-", stopped: "#", warning: "!",
43
+ thinkingMinimal: "", thinkingLow: "", thinkingMedium: "", thinkingHigh: "", thinkingXhigh: "", thinkingMax: ""
44
+ };
45
+
46
+ const SETS: Record<IconSet, Record<IconKey, string>> = { unicode: UNICODE, nerd: NERD, ascii: ASCII };
47
+ let active: IconSet = "unicode";
48
+
49
+ /** Process-wide: renderers read the active set on every paint, so a settings change applies on the next frame. */
50
+ export function setIconSet(set: IconSet): void { active = set; }
51
+ export function iconSet(): IconSet { return active; }
52
+ export function icon(key: IconKey): string { return SETS[active][key]; }
53
+ /** `glyph text`, or just `text` when the active set has no glyph for `key`. */
54
+ export function withIcon(key: IconKey, text: string): string {
55
+ const glyph = SETS[active][key];
56
+ return glyph ? (text ? glyph + " " + text : glyph) : text;
57
+ }
@@ -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/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";
@@ -74,6 +75,7 @@ export class PlanMode {
74
75
  private reminders = 0;
75
76
  private reviewing = false;
76
77
  private compacting = false;
78
+ private contextDirty = true;
77
79
  private compactGeneration = 0;
78
80
  private executionRestore: (() => Promise<void>) | undefined;
79
81
  private executionRunning = false;
@@ -135,6 +137,7 @@ export class PlanMode {
135
137
  try { this.ensureDirectory(ctx); }
136
138
  catch (error) { ctx.ui.notify("Plan mode unavailable: cannot create plan directory: " + (error as Error).message, "error"); return; }
137
139
  this.enabled = true;
140
+ this.contextDirty = true;
138
141
  this.pendingReview = false;
139
142
  this.reminders = 0;
140
143
  this.toolsBefore = this.pi.getActiveTools();
@@ -149,6 +152,7 @@ export class PlanMode {
149
152
  private async leave(ctx: ExtensionContext, persist = true): Promise<void> {
150
153
  const wasEnabled = this.enabled;
151
154
  this.enabled = false;
155
+ this.contextDirty = true;
152
156
  this.compacting = false;
153
157
  this.pendingReview = false;
154
158
  this.compactGeneration++;
@@ -293,14 +297,16 @@ export class PlanMode {
293
297
  }
294
298
 
295
299
  private restoreFrom(branch: readonly unknown[]): PlanState | undefined {
296
- let state: PlanState | undefined;
297
- for (const raw of branch) {
300
+ // Only the newest persisted plan state matters. Walk backward so resuming a long session
301
+ // does not inspect every historical transcript entry.
302
+ for (let index = branch.length - 1; index >= 0; index--) {
303
+ const raw = branch[index];
298
304
  if (!raw || typeof raw !== "object") continue;
299
305
  const entry = raw as Record<string, unknown>;
300
306
  if (entry.type !== "custom" || entry.customType !== PLAN_ENTRY || !entry.data || typeof entry.data !== "object") continue;
301
307
  const data = entry.data as Record<string, unknown>;
302
308
  if ((data.v !== 1 && data.v !== 2) || typeof data.enabled !== "boolean" || !Array.isArray(data.steps)) continue;
303
- state = {
309
+ return {
304
310
  v: 2, enabled: data.enabled,
305
311
  steps: data.steps.filter((item): item is string => typeof item === "string").map((item) => cleanText(item, 240)).filter(Boolean).slice(0, 50),
306
312
  text: typeof data.text === "string" ? safePlanText(data.text) : "",
@@ -308,7 +314,7 @@ export class PlanMode {
308
314
  ...(typeof data.title === "string" ? { title: cleanText(data.title, 120) } : {})
309
315
  };
310
316
  }
311
- return state;
317
+ return undefined;
312
318
  }
313
319
 
314
320
  private async restore(branch: readonly unknown[], ctx: ExtensionContext): Promise<void> {
@@ -411,14 +417,34 @@ export class PlanMode {
411
417
 
412
418
  this.pi.on("before_agent_start", async () => {
413
419
  if (!this.enabled) return;
420
+ this.contextDirty = true;
414
421
  return { message: { customType: "pi-jar.plan-context", content: this.context(), display: false } };
415
422
  });
416
423
 
424
+ // Keep plan-only prompt messages bounded. When plan mode is active, retain only the newest
425
+ // context and newest reminder; when it is off, remove all stale plan prompt messages.
417
426
  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";
427
+ if (!this.contextDirty) return;
428
+ let latestContext = -1;
429
+ let latestReminder = -1;
430
+ let needsPrune = false;
431
+ for (let index = event.messages.length - 1; index >= 0; index--) {
432
+ const type = (event.messages[index] as { customType?: string }).customType;
433
+ if (type === "pi-jar.plan-context") {
434
+ if (!this.enabled || latestContext >= 0) needsPrune = true;
435
+ else latestContext = index;
436
+ } else if (type === "pi-jar.plan-reminder") {
437
+ if (!this.enabled || latestReminder >= 0) needsPrune = true;
438
+ else latestReminder = index;
439
+ }
440
+ }
441
+ this.contextDirty = false;
442
+ if (!needsPrune) return;
443
+ return { messages: event.messages.filter((raw, index) => {
444
+ const type = (raw as { customType?: string }).customType;
445
+ if (type === "pi-jar.plan-context") return this.enabled && index === latestContext;
446
+ if (type === "pi-jar.plan-reminder") return this.enabled && index === latestReminder;
447
+ return true;
422
448
  }) };
423
449
  });
424
450
 
@@ -434,6 +460,7 @@ export class PlanMode {
434
460
  return;
435
461
  }
436
462
  this.reminders++;
463
+ this.contextDirty = true;
437
464
  return {
438
465
  entries: [{ type: "custom_message", customType: "pi-jar.plan-reminder", display: false,
439
466
  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 +480,8 @@ export class PlanMode {
453
480
  if (this.enabled && this.pendingReview) await this.review(ctx);
454
481
  });
455
482
 
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); });
483
+ this.pi.on("session_start", async (_event, ctx) => { this.contextDirty = true; await this.restore(sessionBranch(ctx), ctx); });
484
+ this.pi.on("session_tree", async (_event, ctx) => { this.contextDirty = true; await this.restore(sessionBranch(ctx), ctx); });
458
485
  this.pi.on("session_shutdown", async (_event, ctx) => { if (this.enabled) await this.leave(ctx, false); });
459
486
  }
460
487
  }