pi-jar 0.1.2 → 0.1.4

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/dialogs.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Input, Key, matchesKey, truncateToWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
- import { todoMark, type Todo } from "./tasks.ts";
3
+ import { todoMark, todoProgress, todoTotals, type Todo } from "./tasks.ts";
4
4
  import { cleanText } from "./status.ts";
5
5
 
6
6
  const fit = (line: string, width: number) => truncateToWidth(line, Math.max(0, width));
@@ -111,13 +111,17 @@ export async function todoView(ctx: ExtensionContext, todos: () => Todo[], filte
111
111
  render(width: number): string[] {
112
112
  const items = filtered();
113
113
  selected = Math.min(selected, Math.max(0, items.length - 1));
114
- const count = todos().filter((item) => !item.done).length;
114
+ const all = todos();
115
+ const totals = todoTotals(all);
115
116
  const start = Math.max(0, selected - 11);
116
117
  firstVisible = start;
117
118
  return [
118
- fit(theme.fg("accent", `╭─ pi-jar to-do · ${count} open · ${filter.value} ─`), width),
119
- ...(items.length ? items.slice(start, start + 12).map((item, index) =>
120
- fit(theme.fg(start + index === selected ? "accent" : item.done ? "dim" : item.status === "in_progress" ? "warning" : "muted", `${start + index === selected ? "❯" : " "} ${todoMark(item)} ${item.title}`), width))
119
+ fit(theme.fg("accent", `╭─ pi-jar to-do · ${totals.total - totals.done} open · ${filter.value} ─`), width),
120
+ ...(items.length ? items.slice(start, start + 12).map((item, index) => {
121
+ const progress = todoProgress(all, item.id);
122
+ const row = `${start + index === selected ? "❯" : " "} ${item.parentId ? " " : ""}${todoMark(item)} ${item.title}${progress.total ? ` (${progress.done}/${progress.total})` : ""}`;
123
+ return fit(theme.fg(start + index === selected ? "accent" : item.done ? "dim" : item.status === "in_progress" ? "warning" : "muted", row), width);
124
+ })
121
125
  : [fit(theme.fg("dim", " No items in this view"), width)]),
122
126
  fit(theme.fg("dim", "╰─ ↑↓: select · Space: check · a: add · e: edit · d: delete · f: filter · Esc: close"), width)
123
127
  ];
package/src/diff-view.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Key, matchesKey, truncateToWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
3
  import { diffRows, lineDiff, type ChangeTracker, type DiffRow, type FileChange } from "./changes.ts";
4
+ import { CHILD_BASELINE_ENV, writeChildBaseline } from "./child-baselines.ts";
4
5
  import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
5
6
 
6
7
  const ACTIONS = [
@@ -146,10 +147,21 @@ export function registerChangeReview(pi: ExtensionAPI, tracker: () => ChangeTrac
146
147
  // that file. Older Pi builds may omit toolCallId here, hence the small FIFO fallback.
147
148
  const pending = new Map<string, string>();
148
149
  const fallback: string[] = [];
150
+ // Subagent side: hand each first-edit baseline to the parent so its /diff covers our edits.
151
+ const reported = new Set<string>();
149
152
  pi.on("tool_call", (event) => {
150
153
  if (event.toolName !== "edit" && event.toolName !== "write") return;
151
154
  const path = (event.input as { path?: unknown }).path;
152
- if (typeof path !== "string" || !path.trim() || !tracker()?.capture(path)) return;
155
+ const current = tracker();
156
+ if (typeof path !== "string" || !path.trim() || !current?.capture(path)) return;
157
+ const dir = process.env[CHILD_BASELINE_ENV];
158
+ const abs = current.absolute(path);
159
+ const before = current.baseline(abs);
160
+ // A failed write is reported once and not retried: a later retry would share a newer baseline.
161
+ if (dir && before !== undefined && !reported.has(abs)) {
162
+ try { writeChildBaseline(dir, abs, before); }
163
+ catch (error) { reported.add(abs); console.error(`pi-jar: cannot share baseline for ${abs}: ${String(error)}`); }
164
+ }
153
165
  const id = (event as { toolCallId?: unknown }).toolCallId;
154
166
  if (typeof id === "string" && id) pending.set(id, path);
155
167
  fallback.push(path);
package/src/flame.ts CHANGED
@@ -46,9 +46,17 @@ function hexRgb(hex: string): [number, number, number] | undefined {
46
46
  return [0, 2, 4].map((at) => Number.parseInt(value.slice(at, at + 2), 16)) as [number, number, number];
47
47
  }
48
48
 
49
+ /** 24-bit foreground prefix per hex color: the flame and Ember repaint the same ramp colors every frame. */
50
+ const foregrounds = new Map<string, string>();
51
+
49
52
  export function rgb(hex: string, text: string): string {
50
- const color = hexRgb(hex);
51
- return color ? `\x1b[38;2;${color[0]};${color[1]};${color[2]}m${text}\x1b[39m` : text;
53
+ let prefix = foregrounds.get(hex);
54
+ if (prefix === undefined) {
55
+ const color = hexRgb(hex);
56
+ prefix = color ? `\x1b[38;2;${color[0]};${color[1]};${color[2]}m` : "";
57
+ if (foregrounds.size < 64) foregrounds.set(hex, prefix);
58
+ }
59
+ return prefix ? prefix + text + "\x1b[39m" : text;
52
60
  }
53
61
 
54
62
  function cell(top: number, bottom: number): string {
@@ -59,6 +67,8 @@ function cell(top: number, bottom: number): string {
59
67
  if (down) return `\x1b[38;2;${down[0]};${down[1]};${down[2]}m▄\x1b[39m`;
60
68
  return " ";
61
69
  }
70
+ /** Every truecolor half-block pair, built once instead of parsing two hex colors per cell per frame. */
71
+ const CELLS = Array.from({ length: FLAME_RAMP.length ** 2 }, (_, index) => cell(Math.floor(index / FLAME_RAMP.length), index % FLAME_RAMP.length));
62
72
 
63
73
  /** Terminals without 24-bit color get shaded blocks in theme colors instead. */
64
74
  function shadedCell(top: number, bottom: number, paint: FlamePaint): string {
@@ -217,7 +227,7 @@ export class FlameSim {
217
227
  const rows: string[][] = Array.from({ length: this.rows / 2 }, (_, row) => Array.from({ length: this.width }, (_, x) => {
218
228
  const top = this.heat[row * 2 * this.width + x]!;
219
229
  const bottom = this.heat[(row * 2 + 1) * this.width + x]!;
220
- return truecolor ? cell(top, bottom) : shadedCell(top, bottom, paint);
230
+ return truecolor ? CELLS[top * FLAME_RAMP.length + bottom] ?? cell(top, bottom) : shadedCell(top, bottom, paint);
221
231
  }));
222
232
  for (const particle of this.particles) {
223
233
  const x = Math.round(particle.x);
@@ -234,12 +244,15 @@ export class FlameSim {
234
244
  }
235
245
  }
236
246
 
237
- let cached: { seed: number; sim: FlameSim } | undefined;
247
+ let cached: { seed: number; sim: FlameSim; frame?: number; rows?: string[] } | undefined;
238
248
 
239
249
  /** Rendered flame rows for an absolute frame; replays deterministically for a seed. */
240
250
  export function flameFrame(frame: number, seed = 1, paint?: FlamePaint, truecolor = supportsTruecolor()): string[] {
241
251
  const target = Math.max(0, Math.floor(Number.isFinite(frame) ? frame : 0));
242
252
  if (!cached || cached.seed !== seed || cached.sim.frame > target) cached = { seed, sim: new FlameSim(seed) };
243
253
  while (cached.sim.frame < target) cached.sim.step();
244
- return cached.sim.render(paint, truecolor);
254
+ if (!truecolor) return cached.sim.render(paint, truecolor);
255
+ // Truecolor rows never use `paint`, so repaints between flame ticks (typing, streaming) reuse the frame.
256
+ if (cached.frame !== target || !cached.rows) { cached.rows = cached.sim.render(paint, true); cached.frame = target; }
257
+ return cached.rows.slice();
245
258
  }
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, iconSet, 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,60 @@ 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
+ const EFFORT_COLORS = {
57
+ off: "thinkingOff", minimal: "thinkingMinimal", low: "thinkingLow", medium: "thinkingMedium",
58
+ high: "thinkingHigh", xhigh: "thinkingXhigh", max: "thinkingMax"
59
+ } as const;
60
+ /** Every other color the footer paints; one sample of each keys the render cache to the live theme. */
61
+ const FOOTER_COLORS: readonly Color[] = ["accent", "warning", "success", "error", "dim", "muted"];
62
+
63
+ /** `ctx 58%` → `◫ 58%`: the icon replaces the word, and ascii keeps the word. */
64
+ const metric = (key: IconKey, word: string, text: string) => withIcon(key, text.startsWith(word + " ") ? text.slice(word.length + 1) : text);
65
+
66
+ function activityGlyph(state: ActivityState): { glyph: string; color: Color } {
67
+ if (state === "running") return { glyph: icon("running"), color: "accent" };
68
+ if (state === "queued") return { glyph: icon("pending"), color: "dim" };
69
+ if (state === "done") return { glyph: icon("success"), color: "success" };
70
+ if (state === "failed") return { glyph: icon("error"), color: "error" };
71
+ return { glyph: icon("stopped"), color: "dim" };
72
+ }
73
+
74
+ function activityRow(item: FooterActivity, width: number, theme: FooterTheme): string {
75
+ const { glyph, color } = activityGlyph(item.state);
76
+ const head = theme.fg(color, glyph) + " " + theme.fg(item.state === "running" ? "accent" : "muted",
77
+ withIcon(item.kind === "subagent" ? "agent" : "shell", cleanText(item.name, 28)));
78
+ const stats = item.stats ? theme.fg("dim", cleanText(item.stats, 40)) : "";
79
+ const headWidth = visibleWidth(head);
80
+ const statsWidth = visibleWidth(stats);
81
+ const room = width - headWidth - statsWidth - 4;
82
+ const detail = item.detail && room >= 6 ? " " + theme.fg("dim", truncateToWidth(cleanText(item.detail, 200), room, "…")) : "";
83
+ const detailWidth = visibleWidth(detail);
84
+ const gap = Math.max(1, width - headWidth - detailWidth - statsWidth);
85
+ const row = head + detail + (stats ? " ".repeat(gap) + stats : "");
86
+ // The parts are already measured; only a head and stats too wide on their own need cutting.
87
+ return headWidth + detailWidth + (stats ? gap + statsWidth : 0) <= width ? row : truncateToWidth(row, width);
88
+ }
89
+
33
90
  function fitCwd(cwd: string, width: number): string {
34
91
  if (visibleWidth(cwd) <= width) return cwd;
35
92
  return "…" + sliceByColumn(cwd, visibleWidth(cwd) - width + 1, width - 1);
@@ -45,33 +102,53 @@ function roleColor(role: RoleStatus): Color {
45
102
 
46
103
  /** Every line is bounded in terminal cells; critical role failure takes precedence. */
47
104
  export function renderFooter(view: FooterView, width: number, theme: FooterTheme): string[] {
105
+ return renderFooterLayout(view, width, theme).lines;
106
+ }
107
+
108
+ /** Pi repaints the footer on every render (each streamed token) while its inputs change far less often. */
109
+ let lastFooter: { key: string; lines: string[]; hits: FooterHit[] } | undefined;
110
+
111
+ /** Footer lines plus the clickable regions (activity rows, the session name) they contain. */
112
+ export function renderFooterLayout(view: FooterView, width: number, theme: FooterTheme): { lines: string[]; hits: FooterHit[] } {
113
+ const colors = FOOTER_COLORS.map((color) => theme.fg(color, "x")).join("") + (view.effort ? theme.fg(EFFORT_COLORS[view.effort], "x") : "");
114
+ const key = `${width}\0${iconSet()}\0${homedir()}\0${colors}\0${JSON.stringify(view)}`;
115
+ if (lastFooter?.key !== key) {
116
+ const hits: FooterHit[] = [];
117
+ lastFooter = { key, lines: footerLines(view, width, theme, hits), hits };
118
+ }
119
+ return { lines: lastFooter.lines.slice(), hits: lastFooter.hits.slice() };
120
+ }
121
+
122
+ function footerLines(view: FooterView, width: number, theme: FooterTheme, hits: FooterHit[]): string[] {
48
123
  if (width <= 0) return [];
49
124
  const show = view.settings ?? DEFAULT_FOOTER_SETTINGS;
50
- if (!view.goal && !Object.entries(show).some(([field, enabled]) => enabled && (field !== "memory" || !!view.memory))) return [];
125
+ const activity = view.activity ?? [];
126
+ if (!view.goal && !activity.length && !Object.entries(show).some(([field, enabled]) => enabled && (field !== "memory" || !!view.memory))) return [];
51
127
  // Keep tiny terminals unframed so context and urgent role status stay readable.
52
128
  const framed = width >= 32;
53
129
  const contentWidth = framed ? width - 4 : width;
130
+ // Local cell offsets of content inside the frame, for click targets.
131
+ const offsetX = framed ? 2 : 0;
132
+ const offsetY = framed ? 1 : 0;
54
133
  const roles = show.roles ? [...view.roles].sort((a, b) => Number(b.state === "failed") - Number(a.state === "failed")) : [];
55
134
  const primary = roles.find((role) => role.state === "failed") ?? roles.find((role) => ACTIVE_STATES.has(role.state));
56
135
  const model = cleanText(view.model, 24) || "no-model";
57
- const context = cleanText(view.context, 12) || "ctx ?";
136
+ const context = metric("context", "ctx", cleanText(view.context, 12) || "ctx ?");
58
137
  const goal = view.goal ? cleanText(view.goal, 240) : "";
59
138
  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;
139
+ const windowList = [quota?.fiveHour && `5h ${Math.round(quota.fiveHour.used)}%`, quota?.week && `week ${Math.round(quota.week.used)}%`].filter((value): value is string => !!value);
140
+ const windows = windowList.map((value, index) => index ? value : withIcon("quota", value));
141
+ const cost = view.cost ? metric("cost", "cost", cleanText(view.cost, 20)) : undefined;
142
+ const memory = view.memory ? metric("memory", "ram", view.memory) : undefined;
62
143
  const prefix = view.demo ? theme.fg("warning", "DEMO") : "";
63
- const effortColors = {
64
- off: "thinkingOff", minimal: "thinkingMinimal", low: "thinkingLow", medium: "thinkingMedium",
65
- high: "thinkingHigh", xhigh: "thinkingXhigh", max: "thinkingMax"
66
- } as const;
67
- const effort = show.effort && view.effort ? theme.fg(effortColors[view.effort], view.effort) : "";
144
+ const effort = show.effort && view.effort ? theme.fg(EFFORT_COLORS[view.effort], THINKING_ICONS[view.effort] ? withIcon(THINKING_ICONS[view.effort]!, view.effort) : view.effort) : "";
68
145
  const firstRole = primary ? `${primary.label} ${roleFrame(primary.state, view.frame, view.animations)}` : undefined;
69
146
  const attention = firstRole ?? (show.extras ? view.extras[0] : undefined);
70
147
  // At narrow widths reserve the right-hand slot for critical/active status, then context.
71
- const modelLabel = show.model ? theme.fg("muted", model) : "";
148
+ const modelLabel = show.model ? theme.fg("muted", withIcon("model", model)) : "";
72
149
  const left = [prefix, modelLabel, effort].filter(Boolean).join(theme.fg("dim", " · "));
73
150
  // 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 : [])];
151
+ const metrics = [...(show.context ? [context] : []), ...(show.memory && contentWidth >= 90 && memory ? [memory] : []), ...(show.cost && contentWidth >= 52 && cost ? [cost] : []), ...(show.quota && contentWidth >= 90 ? windows : [])];
75
152
  let right = theme.fg("dim", metrics.join(" · "));
76
153
  if (contentWidth < 52 && attention && contentWidth >= 26) {
77
154
  const room = Math.max(0, contentWidth - Math.min(visibleWidth(left), 12) - visibleWidth(right) - 4);
@@ -87,28 +164,46 @@ export function renderFooter(view: FooterView, width: number, theme: FooterTheme
87
164
  if (room >= 3 && beforeEffort) return truncateToWidth(beforeEffort, room) + theme.fg("dim", " · ") + effort;
88
165
  return truncateToWidth(beforeEffort || effort, availableLeft);
89
166
  })();
90
- const name = show.sessionName && view.sessionName ? cleanText(view.sessionName, 128) : "";
167
+ const name = show.sessionName ? cleanText(view.sessionName || "sessions", 128) : "";
91
168
  const rawCwd = show.cwd && view.cwd ? cleanText(view.cwd, 256) : "";
92
169
  const home = homedir();
93
170
  const cwd = home && rawCwd.startsWith(home + sep) ? `~${rawCwd.slice(home.length)}` : rawCwd;
94
171
  let namedLeft = fittedLeft;
95
172
  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));
173
+ const folder = icon("folder") ? icon("folder") + " " : "";
174
+ if (cwd && space >= 7 + folder.length) {
175
+ const budget = name ? Math.min(24, Math.max(4, Math.floor((space - 6) / 2))) : Math.min(32, space - 3 - folder.length);
176
+ namedLeft += theme.fg("dim", " · ") + theme.fg("muted", folder + fitCwd(cwd, budget));
99
177
  space = availableLeft - visibleWidth(namedLeft);
100
178
  }
101
- if (name && space >= 7) namedLeft += theme.fg("dim", " · ") + theme.fg("muted", truncateToWidth(name, space - 3, "…"));
102
- const gap = " ".repeat(Math.max(1, contentWidth - visibleWidth(namedLeft) - visibleWidth(right)));
103
- const lines = [truncateToWidth(namedLeft + gap + right, contentWidth)];
179
+ if (name && space >= 7) {
180
+ const start = visibleWidth(namedLeft) + 3;
181
+ const label = truncateToWidth(withIcon("session", name), space - 3, "…");
182
+ namedLeft += theme.fg("dim", " · ") + theme.fg("muted", label);
183
+ hits.push({ y: offsetY, x0: offsetX + start, x1: offsetX + start + visibleWidth(label), target: { kind: "session" } });
184
+ }
185
+ const namedWidth = visibleWidth(namedLeft);
186
+ const rightWidth = visibleWidth(right);
187
+ const gap = Math.max(1, contentWidth - namedWidth - rightWidth);
188
+ const first = namedLeft + " ".repeat(gap) + right;
189
+ const lines = [namedWidth + gap + rightWidth <= contentWidth ? first : truncateToWidth(first, contentWidth)];
104
190
  if (goal) {
105
191
  const goalPrefix = theme.fg("accent", "◎ goal") + theme.fg("dim", " · ");
106
- const room = Math.max(0, contentWidth - visibleWidth(goalPrefix));
107
- lines.push(truncateToWidth(goalPrefix + theme.fg("muted", truncateToWidth(goal, room, "…")), contentWidth));
192
+ const prefixWidth = visibleWidth(goalPrefix);
193
+ const line = goalPrefix + theme.fg("muted", truncateToWidth(goal, Math.max(0, contentWidth - prefixWidth), "…"));
194
+ lines.push(prefixWidth <= contentWidth ? line : truncateToWidth(line, contentWidth));
108
195
  }
109
196
  if (contentWidth < 52) {
110
- const small = [...(show.memory && view.memory ? [view.memory] : []), ...(show.quota ? windows : [])];
197
+ const small = [...(show.memory && memory ? [memory] : []), ...(show.quota ? windows : [])];
111
198
  if (small.length && contentWidth >= 26) lines.push(truncateToWidth(theme.fg("dim", small.join(" · ")), contentWidth));
199
+ // Too narrow for rows: one clickable count of live work.
200
+ const agents = activity.filter((item) => item.kind === "subagent" && (item.state === "running" || item.state === "queued")).length;
201
+ const shells = activity.filter((item) => item.kind === "shell" && item.state === "running").length;
202
+ const counts = [agents ? withIcon("subagents", `${agents}`) : "", shells ? withIcon("shell", `${shells}`) : ""].filter(Boolean).join(" ");
203
+ if (counts) {
204
+ hits.push({ y: offsetY + lines.length, x0: offsetX, x1: offsetX + contentWidth, target: { kind: "activity" } });
205
+ lines.push(truncateToWidth(theme.fg("accent", counts), contentWidth));
206
+ }
112
207
  return framed ? frameFooter(lines, width, view, theme) : lines;
113
208
  }
114
209
 
@@ -123,19 +218,29 @@ export function renderFooter(view: FooterView, width: number, theme: FooterTheme
123
218
  const remaining = Math.max(0, view.extras.length - (contentWidth >= 100 ? 2 : 1));
124
219
  const extras = show.extras ? view.extras.slice(0, contentWidth >= 100 ? 2 : 1).map((status) => theme.fg("muted", status)) : [];
125
220
  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)}`));
221
+ if (show.branch && contentWidth >= 52 && view.branch) extras.push(theme.fg("dim", withIcon("branch", cleanText(view.branch, 28))));
127
222
  const chips = (view.chips ?? []).map((chip) => theme.fg("warning", cleanText(chip, 32)));
128
223
  const joined = [...segments, ...chips, ...extras].join(theme.fg("dim", " · "));
129
224
  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(" · ");
225
+ if (contentWidth < 90 && ((show.quota && windows.length) || (show.memory && memory))) {
226
+ const summary = [...(show.memory && memory ? [memory] : []), ...(show.quota ? windows : [])].join(" · ");
132
227
  if (summary) lines.push(truncateToWidth(theme.fg("dim", summary), contentWidth));
133
228
  }
229
+ const shown = activity.length > MAX_ACTIVITY_ROWS ? activity.slice(0, MAX_ACTIVITY_ROWS - 1) : activity;
230
+ for (const item of shown) {
231
+ hits.push({ y: offsetY + lines.length, x0: offsetX, x1: offsetX + contentWidth, target: { kind: item.kind, id: item.id } });
232
+ lines.push(activityRow(item, contentWidth, theme));
233
+ }
234
+ if (shown.length < activity.length) {
235
+ hits.push({ y: offsetY + lines.length, x0: offsetX, x1: offsetX + contentWidth, target: { kind: "activity" } });
236
+ lines.push(truncateToWidth(theme.fg("dim", ` +${activity.length - shown.length} more · ctrl+alt+a to manage`), contentWidth));
237
+ }
134
238
  return framed ? frameFooter(lines, width, view, theme) : lines;
135
239
  }
136
240
 
137
241
  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));
242
+ const active = ((view.settings?.roles ?? true) && view.roles.some((role) => ACTIVE_STATES.has(role.state)))
243
+ || !!view.activity?.some((item) => item.state === "running");
139
244
  const pulse = active && view.animations ? ["◇", "◈", "◆", "◈"][view.frame % 4] : "◇";
140
245
  const top = theme.fg("dim", "╭─") + theme.fg(active ? "accent" : "dim", pulse)
141
246
  + theme.fg("dim", "─".repeat(width - 4) + "╮");
package/src/history-ui.ts CHANGED
@@ -1,6 +1,7 @@
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
- import { createHistorySnapshot, historyChunk, historyPage, safeHistoryText, type HistoryItem } from "./history.ts";
3
+ import { createHistorySnapshot, historyChunk, historyPage, safeHistoryText, type HistoryChunk, type HistoryItem } from "./history.ts";
4
+ import { RowCache } from "./row-cache.ts";
4
5
  import { sessionBranch } from "./session-branch.ts";
5
6
 
6
7
  /** Separate, read-only view. Pi's native transcript and session file are never modified. */
@@ -52,7 +53,15 @@ export async function openJarHistory(ctx: ExtensionContext): Promise<void> {
52
53
  tui.requestRender();
53
54
  };
54
55
  input.onEscape = () => { searching = false; input.focused = false; tui.requestRender(); };
55
- const chunks = () => selectedItem() ? historyChunk(selectedItem()!, offsets.at(-1)!) : undefined;
56
+ // Every render (each streamed token while this is open) needs the selected chunk; re-slice only on change.
57
+ let loaded: { item: HistoryItem; offset: number; chunk: HistoryChunk } | undefined;
58
+ const chunks = () => {
59
+ const item = selectedItem();
60
+ if (!item) return undefined;
61
+ const offset = offsets.at(-1)!;
62
+ if (loaded?.item !== item || loaded.offset !== offset) loaded = { item, offset, chunk: historyChunk(item, offset) };
63
+ return loaded.chunk;
64
+ };
56
65
  const moveChunk = (direction: number) => {
57
66
  const chunk = chunks();
58
67
  if (direction > 0 && chunk?.more && chunk.nextOffset > offsets.at(-1)!) offsets.push(chunk.nextOffset);
@@ -60,13 +69,10 @@ export async function openJarHistory(ctx: ExtensionContext): Promise<void> {
60
69
  detailScroll = 0;
61
70
  expanded = true;
62
71
  };
63
- const fit = (text: string) => truncateToWidth(text, Math.max(0, width));
64
- const line = (content: string) => {
65
- const inside = Math.max(0, width - 4);
66
- const clipped = truncateToWidth(content, inside);
67
- return fit(theme.fg("dim", "│ ") + clipped + " ".repeat(Math.max(0, inside - visibleWidth(clipped))) + theme.fg("dim", " │"));
68
- };
69
- const border = (left: string, middle: string, right: string) => fit(theme.fg("dim", left + middle.repeat(Math.max(0, width - 2)) + right));
72
+ // Rows are built from exact-width parts: one truncate-and-pad pass per new row, reused while it stays on screen.
73
+ const framed = new RowCache();
74
+ const line = (content: string) => framed.get(content, (row) => theme.fg("dim", "│ ") + truncateToWidth(row, Math.max(0, width - 4), "...", true) + theme.fg("dim", " │"));
75
+ const border = (left: string, middle: string, right: string) => theme.fg("dim", left + middle.repeat(Math.max(0, width - 2)) + right);
70
76
  return {
71
77
  invalidate() { input.invalidate(); },
72
78
  handleInput(data: string) {
@@ -103,11 +109,12 @@ export async function openJarHistory(ctx: ExtensionContext): Promise<void> {
103
109
  },
104
110
  render(available: number): string[] {
105
111
  width = Math.max(8, available);
112
+ framed.begin(`${width}\0${theme.fg("dim", "│")}`);
106
113
  const start = first();
107
114
  const items = current.items.slice(start, start + listRows());
108
115
  const heading = ` pi-jar · history · ${snapshot.visible.length} turns`;
109
- const top = fit(theme.fg("accent", "╭─" + truncateToWidth(heading, Math.max(0, width - 6))
110
- + "─".repeat(Math.max(0, width - 4 - visibleWidth(heading))) + "×╮"));
116
+ const top = theme.fg("accent", "╭─" + truncateToWidth(heading, Math.max(0, width - 6))
117
+ + "─".repeat(Math.max(0, width - 4 - visibleWidth(heading))) + "×╮");
111
118
  const subtitle = ` Active branch · page ${current.page + 1}/${current.totalPages} · snapshot at open`;
112
119
  const rows = items.length ? items.map((item, position) => {
113
120
  const index = start + position;
@@ -130,7 +137,7 @@ export async function openJarHistory(ctx: ExtensionContext): Promise<void> {
130
137
  const help = searching ? line(input.render(Math.max(1, width - 4))[0] ?? "/")
131
138
  : line(theme.fg("dim", notice || (width >= 66 ? "↑↓/Pg: move · /: search · n/N: next · e: expand · d/u: detail · [ ]: chunks · p/o: pages · Esc" : "↑↓ move · / search · e expand · p/o pages · Esc")));
132
139
  return [top, line(theme.fg("muted", subtitle)), border("├", "─", "┤"), ...rows,
133
- border("├", "─", "┤"), line(theme.fg("accent", headingDetail)), ...body, help, border("╰", "─", "╯")].map(fit);
140
+ border("├", "─", "┤"), line(theme.fg("accent", headingDetail)), ...body, help, border("╰", "─", "╯")];
134
141
  }
135
142
  };
136
143
  }, { overlay: true, overlayOptions: { anchor: "center", width: 92, maxHeight: "80%" } });
package/src/history.ts CHANGED
@@ -139,8 +139,10 @@ export function historyPage(snapshot: HistorySnapshot, page = 0, size = HISTORY_
139
139
  return { items, page: at, totalPages };
140
140
  }
141
141
 
142
+ export interface HistoryChunk { lines: string[]; more: boolean; nextOffset: number; trailingNewline: boolean }
143
+
142
144
  /** Fetch only the requested 2 KiB / 30-line segment; nextOffset never skips hidden lines. */
143
- export function historyChunk(item: HistoryItem, offset = 0): { lines: string[]; more: boolean; nextOffset: number; trailingNewline: boolean } {
145
+ export function historyChunk(item: HistoryItem, offset = 0): HistoryChunk {
144
146
  const at = Math.max(0, Math.min(200_000_000, Math.floor(offset) || 0));
145
147
  const { raw, more } = excerpt(item.entry, HISTORY_CHUNK_SIZE, at);
146
148
  // Limit UTF-8 bytes as well as UTF-16 code units (CJK/emoji can exceed 2 KiB).
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
+ }
@@ -21,7 +21,7 @@ export type RoleScope = "global" | "project";
21
21
  export interface RoleAssignment { provider: string; model: string; thinking?: ThinkingLevel }
22
22
  export interface ResolvedRole extends RoleAssignment { via: string[] }
23
23
  export interface RoleTag { name?: string; color?: string }
24
- export interface RoleConfig { version: 2; roles: Record<string, string>; cycleOrder?: string[]; tags?: Record<string, RoleTag> }
24
+ export interface RoleConfig { version: 2; roles: Record<string, string>; cycleOrder?: string[]; tags?: Record<string, RoleTag>; fallbacks?: Record<string, string[]> }
25
25
  export interface RoleRow {
26
26
  role: string; label: string; usedBy?: string; custom: boolean;
27
27
  spec?: string; scope?: RoleScope; resolved?: ResolvedRole; error?: string;
@@ -67,6 +67,17 @@ export function parseRoleConfig(raw: unknown): RoleConfig {
67
67
  }
68
68
  if (spec) config.roles[role] = spec;
69
69
  }
70
+ if (value.fallbacks && typeof value.fallbacks === "object") {
71
+ const fallbacks: Record<string, string[]> = {};
72
+ for (const [role, entries] of Object.entries(value.fallbacks)) {
73
+ if (!isRoleName(role) || !Array.isArray(entries)) continue;
74
+ fallbacks[role] = [...new Set(entries.flatMap((entry) => {
75
+ const spec = typeof entry === "string" ? normalizeSpec(entry) : undefined;
76
+ return spec ? [spec] : [];
77
+ }))].slice(0, 8);
78
+ }
79
+ if (Object.keys(fallbacks).length) config.fallbacks = fallbacks;
80
+ }
70
81
  if (Array.isArray(value.cycleOrder)) {
71
82
  const order = value.cycleOrder.filter((item): item is string => typeof item === "string" && isRoleName(item));
72
83
  if (order.length) config.cycleOrder = [...new Set(order)];
@@ -143,6 +154,8 @@ export class ModelRoleManager {
143
154
  private applying = false;
144
155
  /** Bumped whenever the user picks a model or effort themselves. */
145
156
  private manualEpoch = 0;
157
+ /** Last `summary()`, valid while the same (replaced-on-write) configs are loaded. */
158
+ private summaryMemo: { global: RoleConfig; project: RoleConfig; limit: number; text: string } | undefined;
146
159
 
147
160
  constructor(pi: ExtensionAPI) {
148
161
  this.pi = pi;
@@ -188,12 +201,16 @@ export class ModelRoleManager {
188
201
  });
189
202
  }
190
203
 
191
- /** Summary for compact surfaces such as the welcome card. */
204
+ /** Summary for compact surfaces such as the welcome card, which repaints every frame. */
192
205
  summary(limit = 3): string {
206
+ const memo = this.summaryMemo;
207
+ if (memo?.global === this.global && memo.project === this.project && memo.limit === limit) return memo.text;
193
208
  const rows = this.list().filter((row) => row.resolved || row.spec);
194
- if (!rows.length) return "all roles follow the current model";
195
- return rows.slice(0, limit).map((row) => row.role + "→" + (row.spec?.startsWith("@") ? row.spec.split(":")[0] : row.resolved?.model ?? "?")).join(" · ")
196
- + (rows.length > limit ? ` · +${rows.length - limit}` : "");
209
+ const text = !rows.length ? "all roles follow the current model"
210
+ : rows.slice(0, limit).map((row) => row.role + "→" + (row.spec?.startsWith("@") ? row.spec.split(":")[0] : row.resolved?.model ?? "?")).join(" · ")
211
+ + (rows.length > limit ? ` · +${rows.length - limit}` : "");
212
+ this.summaryMemo = { global: this.global, project: this.project, limit, text };
213
+ return text;
197
214
  }
198
215
 
199
216
  private status(ctx: ExtensionContext): void {
@@ -279,6 +296,32 @@ export class ModelRoleManager {
279
296
  if (scope === "project") this.project = next; else this.global = next;
280
297
  }
281
298
 
299
+ /** Project lists replace global lists; an empty project list disables inherited fallbacks. */
300
+ fallbackSpecs(role: string): string[] {
301
+ return [...(this.project.fallbacks?.[role] ?? this.global.fallbacks?.[role] ?? [])];
302
+ }
303
+
304
+ resolveSpec(spec: string): ResolvedRole {
305
+ const result = resolveRole({ ...this.merged(), __fallback__: spec }, "__fallback__");
306
+ if (!result || "error" in result) throw new Error(result?.error ?? "Invalid fallback model");
307
+ return result;
308
+ }
309
+
310
+ updateFallbacks(role: string, specs: string[], scope: RoleScope = "global"): void {
311
+ if (!isRoleName(role)) throw new Error("Invalid role name: " + role);
312
+ if (specs.length > 8) throw new Error("At most 8 fallback models are allowed");
313
+ const normalized = specs.map((spec) => {
314
+ const target = normalizeSpec(spec);
315
+ if (!target) throw new Error("Invalid fallback target: " + spec);
316
+ return target;
317
+ });
318
+ if (scope === "project" && !this.cwd) throw new Error("Project roles need a working directory");
319
+ const target = scope === "project" ? this.project : this.global;
320
+ const next: RoleConfig = { ...target, fallbacks: { ...target.fallbacks, [role]: [...new Set(normalized)] } };
321
+ writeConfig(scope === "project" ? projectRoleFile(this.cwd!) : join(getAgentDir(), ROLE_FILE), next);
322
+ if (scope === "project") this.project = next; else this.global = next;
323
+ }
324
+
282
325
  register(openUi?: (ctx: ExtensionContext) => Promise<void>): void {
283
326
  const manual = (_event: unknown, ctx: ExtensionContext) => {
284
327
  if (this.applying) return;
@@ -294,13 +337,25 @@ export class ModelRoleManager {
294
337
  });
295
338
 
296
339
  this.pi.registerCommand("roles", {
297
- description: "Configure model roles (default/smol/slow/plan/advisor/task/commit or custom): /roles, /roles set ROLE provider/model[:effort]|@role, /roles <role>",
340
+ description: "Configure model roles: /roles, /roles set ROLE provider/model[:effort]|@role, /roles fallback ROLE MODEL...|clear, /roles <role>",
298
341
  handler: async (args, ctx) => {
299
342
  const parts = args.trim().split(/\s+/).filter(Boolean);
300
343
  const verb = (parts[0] ?? "").toLowerCase();
301
344
  if (!verb || verb === "list") {
302
345
  if (!verb && ctx.hasUI && ctx.mode === "tui" && openUi) { await openUi(ctx); return; }
303
- ctx.ui.notify(this.list().map(describeRole).join("\n"), "info");
346
+ ctx.ui.notify(this.list().map((row) => describeRole(row) + (this.fallbackSpecs(row.role).length
347
+ ? " · fallbacks: " + this.fallbackSpecs(row.role).join(" → ") : "")).join("\n"), "info");
348
+ return;
349
+ }
350
+ if (verb === "fallback") {
351
+ const role = parts[1] ?? "";
352
+ const scope: RoleScope = parts.includes("--project") ? "project" : "global";
353
+ const targets = parts.slice(2).filter((part) => part !== "--project");
354
+ if (!isRoleName(role)) { ctx.ui.notify("Usage: /roles fallback ROLE [PROVIDER/MODEL[:effort]|@ROLE ...|clear] [--project]", "error"); return; }
355
+ if (!targets.length) { ctx.ui.notify(role + " fallbacks: " + (this.fallbackSpecs(role).join(" → ") || "none"), "info"); return; }
356
+ try { this.updateFallbacks(role, targets.length === 1 && targets[0] === "clear" ? [] : targets, scope); }
357
+ catch (error) { ctx.ui.notify("Could not save fallbacks: " + (error as Error).message, "error"); return; }
358
+ ctx.ui.notify("Saved " + role + " fallbacks (" + scope + ")", "info");
304
359
  return;
305
360
  }
306
361
  if (verb === "cycle") { await this.cycle(ctx); return; }