pi-jar 0.1.3 → 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,7 +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
+ import { icon, iconSet, type IconKey, withIcon } from "./icons.ts";
7
7
  import type { RoleStatus } from "./roles.ts";
8
8
  import { ACTIVE_STATES, cleanText, type JarStatus } from "./status.ts";
9
9
  import type { Quota } from "./quota.ts";
@@ -53,6 +53,12 @@ const MAX_ACTIVITY_ROWS = 3;
53
53
  const THINKING_ICONS: Record<string, IconKey> = {
54
54
  minimal: "thinkingMinimal", low: "thinkingLow", medium: "thinkingMedium", high: "thinkingHigh", xhigh: "thinkingXhigh", max: "thinkingMax"
55
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"];
56
62
 
57
63
  /** `ctx 58%` → `◫ 58%`: the icon replaces the word, and ascii keeps the word. */
58
64
  const metric = (key: IconKey, word: string, text: string) => withIcon(key, text.startsWith(word + " ") ? text.slice(word.length + 1) : text);
@@ -70,10 +76,15 @@ function activityRow(item: FooterActivity, width: number, theme: FooterTheme): s
70
76
  const head = theme.fg(color, glyph) + " " + theme.fg(item.state === "running" ? "accent" : "muted",
71
77
  withIcon(item.kind === "subagent" ? "agent" : "shell", cleanText(item.name, 28)));
72
78
  const stats = item.stats ? theme.fg("dim", cleanText(item.stats, 40)) : "";
73
- const room = width - visibleWidth(head) - visibleWidth(stats) - 4;
79
+ const headWidth = visibleWidth(head);
80
+ const statsWidth = visibleWidth(stats);
81
+ const room = width - headWidth - statsWidth - 4;
74
82
  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);
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);
77
88
  }
78
89
 
79
90
  function fitCwd(cwd: string, width: number): string {
@@ -94,11 +105,18 @@ export function renderFooter(view: FooterView, width: number, theme: FooterTheme
94
105
  return renderFooterLayout(view, width, theme).lines;
95
106
  }
96
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
+
97
111
  /** Footer lines plus the clickable regions (activity rows, the session name) they contain. */
98
112
  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 };
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() };
102
120
  }
103
121
 
104
122
  function footerLines(view: FooterView, width: number, theme: FooterTheme, hits: FooterHit[]): string[] {
@@ -123,11 +141,7 @@ function footerLines(view: FooterView, width: number, theme: FooterTheme, hits:
123
141
  const cost = view.cost ? metric("cost", "cost", cleanText(view.cost, 20)) : undefined;
124
142
  const memory = view.memory ? metric("memory", "ram", view.memory) : undefined;
125
143
  const prefix = view.demo ? theme.fg("warning", "DEMO") : "";
126
- const effortColors = {
127
- off: "thinkingOff", minimal: "thinkingMinimal", low: "thinkingLow", medium: "thinkingMedium",
128
- high: "thinkingHigh", xhigh: "thinkingXhigh", max: "thinkingMax"
129
- } as const;
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) : "";
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) : "";
131
145
  const firstRole = primary ? `${primary.label} ${roleFrame(primary.state, view.frame, view.animations)}` : undefined;
132
146
  const attention = firstRole ?? (show.extras ? view.extras[0] : undefined);
133
147
  // At narrow widths reserve the right-hand slot for critical/active status, then context.
@@ -168,12 +182,16 @@ function footerLines(view: FooterView, width: number, theme: FooterTheme, hits:
168
182
  namedLeft += theme.fg("dim", " · ") + theme.fg("muted", label);
169
183
  hits.push({ y: offsetY, x0: offsetX + start, x1: offsetX + start + visibleWidth(label), target: { kind: "session" } });
170
184
  }
171
- const gap = " ".repeat(Math.max(1, contentWidth - visibleWidth(namedLeft) - visibleWidth(right)));
172
- const lines = [truncateToWidth(namedLeft + gap + right, contentWidth)];
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)];
173
190
  if (goal) {
174
191
  const goalPrefix = theme.fg("accent", "◎ goal") + theme.fg("dim", " · ");
175
- const room = Math.max(0, contentWidth - visibleWidth(goalPrefix));
176
- 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));
177
195
  }
178
196
  if (contentWidth < 52) {
179
197
  const small = [...(show.memory && memory ? [memory] : []), ...(show.quota ? windows : [])];
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).
@@ -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; }
package/src/panel.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
+ import { RowCache } from "./row-cache.ts";
3
4
 
4
5
  export type Paint = (color: string, text: string) => string;
5
6
  export interface PanelTab { name: string; render(width: number, fg: Paint): string[] }
@@ -13,6 +14,8 @@ export async function openPanel(ctx: ExtensionContext, tabs: readonly PanelTab[]
13
14
  let body: string[] = [];
14
15
  let rows = 10;
15
16
  let tabHits: { start: number; end: number }[] = [];
17
+ // The panel repaints on every Pi render while open; its body rows rarely change between frames.
18
+ const framed = new RowCache();
16
19
  const fg: Paint = (color, text) => theme.fg(color as never, text);
17
20
  const move = (delta: number) => { scroll = Math.max(0, Math.min(Math.max(0, body.length - rows), scroll + delta)); };
18
21
  const select = (index: number) => { tab = (index + tabs.length) % tabs.length; scroll = 0; };
@@ -40,7 +43,8 @@ export async function openPanel(ctx: ExtensionContext, tabs: readonly PanelTab[]
40
43
  rows = Math.max(4, (process.stdout.rows ?? 24) - 6);
41
44
  body = tabs[tab]!.render(inner, fg);
42
45
  scroll = Math.min(scroll, Math.max(0, body.length - rows));
43
- const line = (text: string) => fg("dim", "│ ") + truncateToWidth(text, inner) + " ".repeat(Math.max(0, inner - visibleWidth(truncateToWidth(text, inner)))) + fg("dim", " │");
46
+ framed.begin(`${inner}\0${fg("dim", "│")}`);
47
+ const line = (text: string) => framed.get(text, (row) => fg("dim", "│ ") + truncateToWidth(row, inner, "...", true) + fg("dim", " │"));
44
48
  tabHits = [];
45
49
  // Every tab label is name + 2 cells wide ("[name]" or " name "), separated by one space.
46
50
  let x = 2;
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { fuzzyFilter, Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
2
+ import { fuzzyFilter, Key, matchesKey, truncateToWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
3
  import { cleanText } from "./status.ts";
4
4
 
5
5
  export interface PastPrompt { text: string; source: "this session" | "earlier session"; at?: number }
@@ -58,6 +58,13 @@ export async function openPromptSearch(ctx: ExtensionContext, prompts: readonly
58
58
  let visible: PastPrompt[] = pool;
59
59
  const rows = 10;
60
60
  const filter = () => { visible = query ? fuzzyFilter(pool, query, (item) => item.text) : pool; selected = 0; first = 0; };
61
+ // Prompts can be several KiB; sanitize each once, not on every repaint.
62
+ const cleaned = new Map<PastPrompt, string>();
63
+ const preview = (item: PastPrompt) => {
64
+ let text = cleaned.get(item);
65
+ if (text === undefined) cleaned.set(item, text = cleanText(item.text, 400));
66
+ return text;
67
+ };
61
68
  return {
62
69
  invalidate() {},
63
70
  handleInput(data: string) {
@@ -81,21 +88,22 @@ export async function openPromptSearch(ctx: ExtensionContext, prompts: readonly
81
88
  const inner = w - 4;
82
89
  if (selected < first) first = selected;
83
90
  if (selected >= first + rows) first = selected - rows + 1;
84
- const fit = (text: string) => { const value = truncateToWidth(text, inner); return value + " ".repeat(Math.max(0, inner - visibleWidth(value))); };
85
91
  const dim = (text: string) => theme.fg("dim", text);
92
+ // Rows are exactly `w` wide; only the two borders can overflow a narrow overlay.
86
93
  const lines = [
87
- theme.fg("accent", "╭─ ⌕ PROMPT HISTORY ") + dim("─".repeat(Math.max(0, w - 21)) + "╮"),
88
- dim("│ ") + fit(theme.fg("accent", "› ") + query + "\x1b[7m \x1b[0m" + dim(` ${visible.length}/${prompts.length}`)) + dim(" │")
94
+ truncateToWidth(theme.fg("accent", "╭─ ⌕ PROMPT HISTORY ") + dim("─".repeat(Math.max(0, w - 21)) + "╮"), w),
95
+ dim("│ ") + truncateToWidth(theme.fg("accent", "› ") + query + "\x1b[7m \x1b[0m" + dim(` ${visible.length}/${prompts.length}`), inner, "...", true) + dim(" │")
89
96
  ];
90
97
  for (let row = 0; row < rows; row++) {
91
98
  const item = visible[first + row];
92
99
  const active = first + row === selected;
93
- const text = item ? (active ? "❯ " : " ") + cleanText(item.text, 400) : "";
100
+ const text = item ? (active ? "❯ " : " ") + preview(item) : "";
94
101
  const tag = item?.source === "earlier session" ? dim(" · earlier") : "";
95
- lines.push(dim("│ ") + fit(item ? theme.fg(active ? "accent" : "muted", truncateToWidth(text, Math.max(4, inner - 10))) + tag : "") + dim(" │"));
102
+ const content = item ? theme.fg(active ? "accent" : "muted", truncateToWidth(text, Math.max(4, inner - 10))) + tag : "";
103
+ lines.push(dim("│ ") + truncateToWidth(content, inner, "...", true) + dim(" │"));
96
104
  }
97
- lines.push(dim("╰─ type to filter · ↑↓ select · Enter edit · Esc close " + "─".repeat(Math.max(0, w - 57)) + "╯"));
98
- return lines.map((line) => truncateToWidth(line, w));
105
+ lines.push(truncateToWidth(dim("╰─ type to filter · ↑↓ select · Enter edit · Esc close " + "─".repeat(Math.max(0, w - 57)) + "╯"), w));
106
+ return lines;
99
107
  }
100
108
  };
101
109
  }, { overlay: true, overlayOptions: { width: "80%", maxHeight: "60%" } });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Row memo across consecutive renders. pi-tui re-segments every grapheme on each truncate, and pi-jar's
3
+ * chrome and overlays repaint on every Pi render (each streamed token) with mostly the same rows, or one
4
+ * changed row while typing or scrolling. Rows transformed by the previous render are reused while the
5
+ * scope (width, painter sample) holds, so memory stays bounded by two renders' rows.
6
+ */
7
+ export class RowCache {
8
+ private previous = new Map<string, string>();
9
+ private current = new Map<string, string>();
10
+ private scope: string | undefined;
11
+
12
+ /** Start a render. A different scope drops every cached row. */
13
+ begin(scope: string): void {
14
+ this.previous = scope === this.scope ? this.current : new Map();
15
+ this.current = new Map();
16
+ this.scope = scope;
17
+ }
18
+
19
+ /** `transform(row)` for this render; `transform` may depend only on the row and the scope. */
20
+ get(row: string, transform: (row: string) => string): string {
21
+ let value = this.current.get(row) ?? this.previous.get(row);
22
+ if (value === undefined) value = transform(row);
23
+ this.current.set(row, value);
24
+ return value;
25
+ }
26
+ }
package/src/session-ui.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionContext, SessionInfo } from "@earendil-works/pi-coding-agent";
2
2
  import { Key, matchesKey, truncateToWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
3
  import { sessionDisplayName } from "./composer.ts";
4
- import { withIcon } from "./icons.ts";
4
+ import { iconSet, withIcon } from "./icons.ts";
5
5
  import { contentRows, sidebarWidth, splitFrame } from "./split-view.ts";
6
6
  import { cleanText } from "./status.ts";
7
7
 
@@ -68,8 +68,13 @@ export async function pickSession(ctx: ExtensionContext, sessions: readonly Sess
68
68
  const settle = (value: SessionDetails) => { if (closed) return; cache.set(session.path, value); tui.requestRender(); };
69
69
  Promise.resolve().then(() => details(session)).then((value) => settle(value ?? {}), () => settle({}));
70
70
  };
71
+ // Wrapping a 4 KiB first prompt is the costliest part of a frame; redo it only when its inputs change.
72
+ let described: { session: SessionInfo; inputs: string; loaded: SessionDetails | null | undefined; lines: string[] } | undefined;
71
73
  const describe = (session: SessionInfo, bodyWidth: number, height: number): string[] => {
72
74
  const date = session.modified;
75
+ const inputs = `${bodyWidth}\0${height}\0${age(date)}\0${iconSet()}`;
76
+ const loaded = details ? cache.get(session.path) : undefined;
77
+ if (described?.session === session && described.inputs === inputs && described.loaded === loaded) return described.lines;
73
78
  const when = Number.isNaN(date.getTime()) ? "unknown"
74
79
  : `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())} ${two(date.getHours())}:${two(date.getMinutes())}` + (age(date) ? ` · ${age(date)} ago` : "");
75
80
  const lines = [
@@ -80,7 +85,6 @@ export async function pickSession(ctx: ExtensionContext, sessions: readonly Sess
80
85
  ];
81
86
  if (session.cwd) lines.push(fg("muted", withIcon("folder", cleanText(session.cwd, 400))));
82
87
  if (details) {
83
- const loaded = cache.get(session.path);
84
88
  if (!loaded) lines.push(fg("dim", "Loading details…"));
85
89
  else {
86
90
  for (const [key, text] of [["goal", loaded.goal], ["plan", loaded.plan]] as const) {
@@ -91,10 +95,11 @@ export async function pickSession(ctx: ExtensionContext, sessions: readonly Sess
91
95
  lines.push("", fg("dim", "First prompt"));
92
96
  const prompt = cleanText(session.firstMessage, 4096);
93
97
  lines.push(...(prompt ? wrapTextWithAnsi(prompt, Math.max(1, bodyWidth)).map((line) => fg("muted", line)) : [fg("dim", "No first prompt")]));
94
- return lines.slice(0, height).map((line) => truncateToWidth(line, Math.max(0, bodyWidth)));
98
+ described = { session, inputs, loaded, lines: lines.slice(0, height).map((line) => truncateToWidth(line, Math.max(0, bodyWidth))) };
99
+ return described.lines;
95
100
  };
96
101
  return {
97
- invalidate() {},
102
+ invalidate() { described = undefined; },
98
103
  handleInput(data: string) {
99
104
  if (matchesKey(data, Key.escape)) return finish(undefined);
100
105
  if (matchesKey(data, Key.enter)) return finish(rows[selected]?.path);
@@ -111,12 +111,8 @@ export async function openJarSettings(
111
111
  render(available: number): string[] {
112
112
  width = Math.max(8, available);
113
113
  const state = current();
114
- const fit = (text: string) => truncateToWidth(text, width);
115
- const line = (text: string) => {
116
- const inner = Math.max(0, width - 5);
117
- const content = truncateToWidth(text, inner);
118
- return fit(theme.fg("dim", "│ ") + content + " ".repeat(Math.max(0, inner - visibleWidth(content))) + theme.fg("dim", " │"));
119
- };
114
+ // Rows are assembled from exact-width parts, so one truncate-and-pad pass per row is enough.
115
+ const line = (text: string) => theme.fg("dim", "│ ") + truncateToWidth(text, Math.max(0, width - 5), "...", true) + theme.fg("dim", " │");
120
116
  const title = " pi-jar · settings ";
121
117
  const top = theme.fg("accent", "╭─" + truncateToWidth(title, Math.max(0, width - 6))
122
118
  + "─".repeat(Math.max(0, width - 4 - visibleWidth(title))) + "×╮");
@@ -144,13 +140,13 @@ export async function openJarSettings(
144
140
  ];
145
141
  const start = firstVisible();
146
142
  const rows = allRows.slice(start, start + pageSize());
147
- return [fit(top), tabs, divider,
143
+ return [top, tabs, divider,
148
144
  line(theme.fg("accent", page === "footer" ? " FOOTER VISIBILITY" : page === "pi" ? " PI & WORKFLOWS" : " APPEARANCE & MOTION")),
149
145
  line(theme.fg("dim", page === "footer" ? ` Footer fields ${start + 1}–${start + rows.length}/${fields()}`
150
146
  : page === "pi" ? " Fullscreen mode enables clicks and copy-on-select" : " Accent cycles through loaded themes")),
151
- ...rows.map(fit),
147
+ ...rows,
152
148
  line(theme.fg("dim", " Tab: section · ↑↓: choose · Enter/click: change · Esc")),
153
- fit(theme.fg("dim", "╰" + "─".repeat(Math.max(0, width - 2)) + "╯"))];
149
+ theme.fg("dim", "╰" + "─".repeat(Math.max(0, width - 2)) + "╯")];
154
150
  }
155
151
  };
156
152
  });
package/src/shells.ts CHANGED
@@ -53,10 +53,11 @@ export class ShellManager {
53
53
  constructor(onEvent: (event: ShellEvent) => void, spawnShell: typeof spawn = spawn) { this.onEvent = onEvent; this.spawnShell = spawnShell; }
54
54
 
55
55
  list(): ShellJob[] { return [...this.jobs.values()].map(({ job }) => ({ ...job, lines: [...job.lines] })); }
56
+ /** Read by the footer on every render: one shallow copy per job, never the output lines. */
56
57
  summaries(): Array<Omit<ShellJob, "lines">> {
57
58
  return [...this.jobs.values()].map(({ job }) => {
58
59
  const { lines: _lines, ...summary } = job;
59
- return { ...summary };
60
+ return summary;
60
61
  });
61
62
  }
62
63
  get(id: string): ShellJob | undefined { const entry = this.jobs.get(id); return entry && { ...entry.job, lines: [...entry.job.lines] }; }