pi-jar 0.1.3 → 0.1.5

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/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).
@@ -11,7 +11,11 @@ export const BUILTIN_ROLES = [
11
11
  { role: "plan", label: "Architect", usedBy: "plan mode" },
12
12
  { role: "implement", label: "Builder", usedBy: "goal implement rounds and approved plans" },
13
13
  { role: "advisor", label: "Advisor", usedBy: "second opinions, stuck-work gates and the goal audit" },
14
- { role: "task", label: "Subtask", usedBy: "delegated tasks" },
14
+ { role: "moderator", label: "Moderator", usedBy: "main-agent multi-agent coordination" },
15
+ { role: "scout", label: "Scout", usedBy: "cheap delegated discovery and codebase scouting" },
16
+ { role: "worker", label: "Worker", usedBy: "sandboxed delegated implementation" },
17
+ { role: "reviewer", label: "Reviewer", usedBy: "forked delegated review and verification" },
18
+ { role: "task", label: "Subtask", usedBy: "legacy or explicitly generic delegated tasks" },
15
19
  { role: "commit", label: "Commit", usedBy: "/jar commit messages" }
16
20
  ] as const;
17
21
  export const MODEL_ROLES = BUILTIN_ROLES.map((item) => item.role);
@@ -21,10 +25,10 @@ export type RoleScope = "global" | "project";
21
25
  export interface RoleAssignment { provider: string; model: string; thinking?: ThinkingLevel }
22
26
  export interface ResolvedRole extends RoleAssignment { via: string[] }
23
27
  export interface RoleTag { name?: string; color?: string }
24
- export interface RoleConfig { version: 2; roles: Record<string, string>; cycleOrder?: string[]; tags?: Record<string, RoleTag> }
28
+ export interface RoleConfig { version: 2; roles: Record<string, string>; cycleOrder?: string[]; tags?: Record<string, RoleTag>; fallbacks?: Record<string, string[]> }
25
29
  export interface RoleRow {
26
30
  role: string; label: string; usedBy?: string; custom: boolean;
27
- spec?: string; scope?: RoleScope; resolved?: ResolvedRole; error?: string;
31
+ spec?: string; scope?: RoleScope; resolved?: ResolvedRole; error?: string; fallbacks: string[];
28
32
  }
29
33
 
30
34
  export const ROLE_FILE = "pi-jar-roles.json";
@@ -67,6 +71,17 @@ export function parseRoleConfig(raw: unknown): RoleConfig {
67
71
  }
68
72
  if (spec) config.roles[role] = spec;
69
73
  }
74
+ if (value.fallbacks && typeof value.fallbacks === "object") {
75
+ const fallbacks: Record<string, string[]> = {};
76
+ for (const [role, entries] of Object.entries(value.fallbacks)) {
77
+ if (!isRoleName(role) || !Array.isArray(entries)) continue;
78
+ fallbacks[role] = [...new Set(entries.flatMap((entry) => {
79
+ const spec = typeof entry === "string" ? normalizeSpec(entry) : undefined;
80
+ return spec ? [spec] : [];
81
+ }))].slice(0, 8);
82
+ }
83
+ if (Object.keys(fallbacks).length) config.fallbacks = fallbacks;
84
+ }
70
85
  if (Array.isArray(value.cycleOrder)) {
71
86
  const order = value.cycleOrder.filter((item): item is string => typeof item === "string" && isRoleName(item));
72
87
  if (order.length) config.cycleOrder = [...new Set(order)];
@@ -143,6 +158,8 @@ export class ModelRoleManager {
143
158
  private applying = false;
144
159
  /** Bumped whenever the user picks a model or effort themselves. */
145
160
  private manualEpoch = 0;
161
+ /** Last `summary()`, valid while the same (replaced-on-write) configs are loaded. */
162
+ private summaryMemo: { global: RoleConfig; project: RoleConfig; limit: number; text: string } | undefined;
146
163
 
147
164
  constructor(pi: ExtensionAPI) {
148
165
  this.pi = pi;
@@ -167,6 +184,23 @@ export class ModelRoleManager {
167
184
  return result && !("error" in result) ? result : undefined;
168
185
  }
169
186
 
187
+ /** Primary role target followed by its configured fallbacks, de-duplicated in priority order. */
188
+ resolveCandidates(role: string): ResolvedRole[] {
189
+ const candidates: ResolvedRole[] = [];
190
+ const primary = this.resolve(role);
191
+ if (primary) candidates.push(primary);
192
+ for (const spec of this.fallbackSpecs(role)) {
193
+ try { candidates.push(this.resolveSpec(spec)); } catch { /* malformed alias targets are skipped */ }
194
+ }
195
+ const seen = new Set<string>();
196
+ return candidates.filter((item) => {
197
+ const key = item.provider + "/" + item.model + ":" + (item.thinking ?? "");
198
+ if (seen.has(key)) return false;
199
+ seen.add(key);
200
+ return true;
201
+ });
202
+ }
203
+
170
204
  get(role: string): RoleAssignment | undefined {
171
205
  const resolved = this.resolve(role);
172
206
  if (!resolved) return undefined;
@@ -183,17 +217,21 @@ export class ModelRoleManager {
183
217
  ];
184
218
  return rows.map((row) => {
185
219
  const result = resolveRole(roles, row.role);
186
- return { ...row, spec: roles[row.role], scope: this.scopeOf(row.role),
220
+ return { ...row, spec: roles[row.role], scope: this.scopeOf(row.role), fallbacks: this.fallbackSpecs(row.role),
187
221
  ...(result && "error" in result ? { error: result.error } : result ? { resolved: result } : {}) };
188
222
  });
189
223
  }
190
224
 
191
- /** Summary for compact surfaces such as the welcome card. */
225
+ /** Summary for compact surfaces such as the welcome card, which repaints every frame. */
192
226
  summary(limit = 3): string {
227
+ const memo = this.summaryMemo;
228
+ if (memo?.global === this.global && memo.project === this.project && memo.limit === limit) return memo.text;
193
229
  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}` : "");
230
+ const text = !rows.length ? "all roles follow the current model"
231
+ : rows.slice(0, limit).map((row) => row.role + "→" + (row.spec?.startsWith("@") ? row.spec.split(":")[0] : row.resolved?.model ?? "?")).join(" · ")
232
+ + (rows.length > limit ? ` · +${rows.length - limit}` : "");
233
+ this.summaryMemo = { global: this.global, project: this.project, limit, text };
234
+ return text;
197
235
  }
198
236
 
199
237
  private status(ctx: ExtensionContext): void {
@@ -202,39 +240,46 @@ export class ModelRoleManager {
202
240
  }
203
241
 
204
242
  async activate(role: string, ctx: ExtensionContext, quiet = false): Promise<boolean> {
205
- const result = resolveRole(this.merged(), role);
206
- if (result && "error" in result) {
207
- if (!quiet) ctx.ui.notify("Role " + role + ": " + result.error, "warning");
208
- return false;
209
- }
210
- if (!result) {
243
+ const primary = resolveRole(this.merged(), role);
244
+ const primaryError = primary && "error" in primary ? primary.error : undefined;
245
+ const candidates = this.resolveCandidates(role);
246
+ if (!candidates.length) {
247
+ if (primaryError) {
248
+ if (!quiet) ctx.ui.notify("Role " + role + ": " + primaryError, "warning");
249
+ return false;
250
+ }
211
251
  if (!quiet) ctx.ui.notify("Role " + role + " follows the current model; assign one with /roles", "info");
212
252
  this.active = role;
213
253
  this.status(ctx);
214
254
  return true;
215
255
  }
216
- const model = ctx.modelRegistry.find(result.provider, result.model);
217
- if (!model) {
218
- if (!quiet) ctx.ui.notify("Model not found for role " + role + ": " + result.provider + "/" + result.model, "warning");
219
- return false;
220
- }
221
- let changed: boolean;
222
- this.applying = true;
223
- try { changed = await this.pi.setModel(model); } finally { this.applying = false; }
224
- if (!changed) {
225
- if (!quiet) ctx.ui.notify("No configured authentication for " + result.provider + "/" + result.model, "warning");
226
- return false;
256
+
257
+ const failures: string[] = [];
258
+ for (let index = 0; index < candidates.length; index++) {
259
+ const candidate = candidates[index]!;
260
+ const model = ctx.modelRegistry.find(candidate.provider, candidate.model);
261
+ if (!model) { failures.push(candidate.provider + "/" + candidate.model + " not found"); continue; }
262
+ let changed: boolean;
263
+ this.applying = true;
264
+ try { changed = await this.pi.setModel(model); } finally { this.applying = false; }
265
+ if (!changed) { failures.push(candidate.provider + "/" + candidate.model + " unavailable"); continue; }
266
+ if (candidate.thinking) this.withApplying(() => this.pi.setThinkingLevel(candidate.thinking!));
267
+ this.active = role;
268
+ this.status(ctx);
269
+ if (!quiet) {
270
+ ctx.ui.notify("Role " + role + " · " + candidate.provider + "/" + candidate.model
271
+ + (candidate.thinking ? " · " + candidate.thinking : "") + (index ? " · fallback " + index : ""), "info");
272
+ }
273
+ return true;
227
274
  }
228
- if (result.thinking) this.withApplying(() => this.pi.setThinkingLevel(result.thinking!));
229
- this.active = role;
230
- this.status(ctx);
231
- if (!quiet) ctx.ui.notify("Role " + role + " · " + result.provider + "/" + result.model + (result.thinking ? " · " + result.thinking : ""), "info");
232
- return true;
275
+
276
+ if (!quiet) ctx.ui.notify("No usable model for role " + role + ": " + failures.join("; "), "warning");
277
+ return false;
233
278
  }
234
279
 
235
280
  /** Switch to a role for a bounded workflow; the returned function restores the previous model. */
236
281
  async activateTemporary(role: string, ctx: ExtensionContext): Promise<() => Promise<void>> {
237
- if (!this.resolve(role)) return async () => {};
282
+ if (!this.resolveCandidates(role).length) return async () => {};
238
283
  const previousModel = ctx.model;
239
284
  const previousThinking = this.pi.getThinkingLevel();
240
285
  const previousActive = this.active;
@@ -259,7 +304,7 @@ export class ModelRoleManager {
259
304
 
260
305
  /** Activate the next assigned role in the cycle order. */
261
306
  async cycle(ctx: ExtensionContext): Promise<string | undefined> {
262
- const order = this.cycleOrder().filter((role) => this.resolve(role));
307
+ const order = this.cycleOrder().filter((role) => this.resolveCandidates(role).length > 0);
263
308
  if (!order.length) { ctx.ui.notify("No roles in the cycle order are assigned; configure them with /roles", "info"); return undefined; }
264
309
  const next = order[(order.indexOf(this.active ?? "") + 1) % order.length]!;
265
310
  return await this.activate(next, ctx) ? next : undefined;
@@ -279,7 +324,33 @@ export class ModelRoleManager {
279
324
  if (scope === "project") this.project = next; else this.global = next;
280
325
  }
281
326
 
282
- register(openUi?: (ctx: ExtensionContext) => Promise<void>): void {
327
+ /** Project lists replace global lists; an empty project list disables inherited fallbacks. */
328
+ fallbackSpecs(role: string): string[] {
329
+ return [...(this.project.fallbacks?.[role] ?? this.global.fallbacks?.[role] ?? [])];
330
+ }
331
+
332
+ resolveSpec(spec: string): ResolvedRole {
333
+ const result = resolveRole({ ...this.merged(), __fallback__: spec }, "__fallback__");
334
+ if (!result || "error" in result) throw new Error(result?.error ?? "Invalid fallback model");
335
+ return result;
336
+ }
337
+
338
+ updateFallbacks(role: string, specs: string[], scope: RoleScope = "global"): void {
339
+ if (!isRoleName(role)) throw new Error("Invalid role name: " + role);
340
+ if (specs.length > 8) throw new Error("At most 8 fallback models are allowed");
341
+ const normalized = specs.map((spec) => {
342
+ const target = normalizeSpec(spec);
343
+ if (!target) throw new Error("Invalid fallback target: " + spec);
344
+ return target;
345
+ });
346
+ if (scope === "project" && !this.cwd) throw new Error("Project roles need a working directory");
347
+ const target = scope === "project" ? this.project : this.global;
348
+ const next: RoleConfig = { ...target, fallbacks: { ...target.fallbacks, [role]: [...new Set(normalized)] } };
349
+ writeConfig(scope === "project" ? projectRoleFile(this.cwd!) : join(getAgentDir(), ROLE_FILE), next);
350
+ if (scope === "project") this.project = next; else this.global = next;
351
+ }
352
+
353
+ register(openUi?: (ctx: ExtensionContext) => Promise<void>, options: { activateDefault?: () => boolean } = {}): void {
283
354
  const manual = (_event: unknown, ctx: ExtensionContext) => {
284
355
  if (this.applying) return;
285
356
  this.manualEpoch++;
@@ -290,17 +361,30 @@ export class ModelRoleManager {
290
361
  this.pi.on("session_start", async (_event, ctx) => {
291
362
  this.load(ctx.cwd);
292
363
  this.active = undefined;
293
- if (this.resolve("default")) await this.activate("default", ctx, true);
364
+ // Delegated children already received their role model/effort via CLI arguments.
365
+ if (options.activateDefault?.() !== false && this.resolveCandidates("default").length) await this.activate("default", ctx, true);
294
366
  });
295
367
 
296
368
  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>",
369
+ description: "Configure model roles: /roles, /roles set ROLE provider/model[:effort]|@role, /roles fallback ROLE MODEL...|clear, /roles <role>",
298
370
  handler: async (args, ctx) => {
299
371
  const parts = args.trim().split(/\s+/).filter(Boolean);
300
372
  const verb = (parts[0] ?? "").toLowerCase();
301
373
  if (!verb || verb === "list") {
302
374
  if (!verb && ctx.hasUI && ctx.mode === "tui" && openUi) { await openUi(ctx); return; }
303
- ctx.ui.notify(this.list().map(describeRole).join("\n"), "info");
375
+ ctx.ui.notify(this.list().map((row) => describeRole(row) + (this.fallbackSpecs(row.role).length
376
+ ? " · fallbacks: " + this.fallbackSpecs(row.role).join(" → ") : "")).join("\n"), "info");
377
+ return;
378
+ }
379
+ if (verb === "fallback") {
380
+ const role = parts[1] ?? "";
381
+ const scope: RoleScope = parts.includes("--project") ? "project" : "global";
382
+ const targets = parts.slice(2).filter((part) => part !== "--project");
383
+ if (!isRoleName(role)) { ctx.ui.notify("Usage: /roles fallback ROLE [PROVIDER/MODEL[:effort]|@ROLE ...|clear] [--project]", "error"); return; }
384
+ if (!targets.length) { ctx.ui.notify(role + " fallbacks: " + (this.fallbackSpecs(role).join(" → ") || "none"), "info"); return; }
385
+ try { this.updateFallbacks(role, targets.length === 1 && targets[0] === "clear" ? [] : targets, scope); }
386
+ catch (error) { ctx.ui.notify("Could not save fallbacks: " + (error as Error).message, "error"); return; }
387
+ ctx.ui.notify("Saved " + role + " fallbacks (" + scope + ")", "info");
304
388
  return;
305
389
  }
306
390
  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%" } });
package/src/roles-ui.ts CHANGED
@@ -4,10 +4,11 @@ import { promptText } from "./dialogs.ts";
4
4
  import { isRoleName, normalizeSpec, THINKING, type ModelRoleManager, type RoleRow } from "./model-roles.ts";
5
5
  import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
6
6
 
7
- type RoleAction = "model" | "alias" | "thinking" | "scope" | "activate" | "clear" | "new" | "delete";
7
+ type RoleAction = "model" | "fallback" | "alias" | "thinking" | "scope" | "activate" | "clear" | "new" | "delete";
8
8
  const ACTIONS: { action: RoleAction; key: string; hint: string; label: string }[] = [
9
9
  { action: "activate", key: "\r", hint: "⏎", label: "Activate this role now" },
10
10
  { action: "model", key: "m", hint: "m", label: "Assign a model" },
11
+ { action: "fallback", key: "f", hint: "f", label: "Set fallback model" },
11
12
  { action: "alias", key: "a", hint: "a", label: "Alias another role (@role)" },
12
13
  { action: "thinking", key: "t", hint: "t", label: "Set thinking effort" },
13
14
  { action: "scope", key: "s", hint: "s", label: "Move between global and project" },
@@ -28,6 +29,7 @@ function detail(row: RoleRow, active: string | undefined): [ThemeColor, string][
28
29
  [target[0], "Model " + target[1]],
29
30
  ["muted", "Alias " + (row.resolved && row.resolved.via.length > 1 ? row.resolved.via.join(" → ") : "—")],
30
31
  ["muted", "Effort " + (row.resolved?.thinking ?? "follow current")],
32
+ ["muted", "Fallback " + (row.fallbacks.length ? row.fallbacks.join(" → ") : "—")],
31
33
  ["muted", "Scope " + (row.scope ?? "unset") + (row.scope === "project" ? " (.pi/pi-jar-roles.json)" : row.scope === "global" ? " (agent dir)" : "")],
32
34
  ["dim", ""],
33
35
  ["dim", "Specs: provider/model[:effort] · @role[:effort] · *"],
@@ -41,7 +43,6 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
41
43
  let scroll = 0;
42
44
  let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
43
45
  let width = 80;
44
- let actionsTop = -1;
45
46
  const rows = () => roles.list();
46
47
  const finish = (action: RoleAction) => done({ action, index: selected });
47
48
  return {
@@ -67,9 +68,9 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
67
68
  if (event.type !== "click" || event.button !== "left") return;
68
69
  if (event.y === 0 && event.x >= width - 3) { done(undefined); return { handled: true }; }
69
70
  const row = event.y - layout.top;
70
- // Actions are listed in the detail pane, one per row.
71
- const actionIndex = row - actionsTop;
72
- if (row >= 0 && row < layout.rows && event.x >= layout.bodyX && actionIndex >= 0 && actionIndex < ACTIONS.length) {
71
+ // Actions stay in the footer so even short terminals can click every action.
72
+ const actionIndex = event.y - layout.footerTop;
73
+ if (event.x >= 2 && event.x < width - 2 && actionIndex >= 0 && actionIndex < ACTIONS.length) {
73
74
  finish(ACTIONS[actionIndex]!.action); return { handled: true };
74
75
  }
75
76
  if (row >= 0 && row < layout.rows && (layout.leftWidth === 0 || event.x < layout.leftWidth + 3)) {
@@ -81,7 +82,7 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
81
82
  width = Math.max(24, available);
82
83
  const list = rows();
83
84
  selected = Math.max(0, Math.min(list.length - 1, selected));
84
- const height = Math.min(contentRows(5, 8), Math.max(12 + ACTIONS.length + 2, list.length));
85
+ const height = Math.min(contentRows(ACTIONS.length + 4, 4), Math.max(13, list.length));
85
86
  if (selected < scroll) scroll = selected;
86
87
  if (selected >= scroll + height) scroll = selected - height + 1;
87
88
  const active = roles.activeRole();
@@ -97,11 +98,9 @@ async function roleScreen(ctx: ExtensionContext, roles: ModelRoleManager, initia
97
98
  const header = narrow ? [theme.fg("accent", `‹ ${selected + 1}/${list.length} ${row.role} ›`)] : [];
98
99
  const actions = optionList(theme, ACTIONS.map((item) => item.label), -1, ACTIONS.map((item) => item.hint))
99
100
  .map((line, at) => ACTIONS[at]!.action === "delete" && !row.custom ? theme.fg("dim", stripTerminalSequences(line)) : line);
100
- // Actions come right after the facts; the spec help (last three lines) goes last.
101
- const facts = info.slice(0, -3), help = info.slice(-3);
102
- const body = [...header, ...facts, theme.fg("accent", "Actions"), ...actions, ...help];
103
- actionsTop = header.length + facts.length + 1;
101
+ const body = [...header, ...info];
104
102
  const split = splitFrame(theme, width, "pi-jar · roles", narrow ? [] : left, body, [
103
+ ...actions,
105
104
  theme.fg("dim", "↑↓ choose role · press a key or click an action · Esc close")
106
105
  ], height);
107
106
  layout = split.layout;
@@ -149,6 +148,15 @@ async function applyAction(ctx: ExtensionContext, roles: ModelRoleManager, row:
149
148
  if (selected) roles.update(row.role, selected + effort(), scope);
150
149
  return;
151
150
  }
151
+ case "fallback": {
152
+ const models = ctx.modelRegistry.getAvailable().slice().sort((a, b) => (a.provider + "/" + a.id).localeCompare(b.provider + "/" + b.id));
153
+ if (!models.length) { ctx.ui.notify("No authenticated models are currently available", "warning"); return; }
154
+ const choices = ["Clear fallback", ...models.map((model) => model.provider + "/" + model.id)];
155
+ const selected = await ctx.ui.select("Fallback model for " + row.role, choices);
156
+ if (!selected) return;
157
+ roles.updateFallbacks(row.role, selected === "Clear fallback" ? [] : [selected], scope);
158
+ return;
159
+ }
152
160
  case "alias": {
153
161
  const targets = roles.list().filter((item) => item.role !== row.role).map((item) => "@" + item.role);
154
162
  const selected = await ctx.ui.select("Alias " + row.role + " to", targets);
@@ -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);