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.
@@ -2,7 +2,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
3
  import { FOOTER_FIELDS, type FooterField } from "./footer-settings.ts";
4
4
  import { ICON_SETS } from "./icons.ts";
5
- import { GOAL_ROUND_CHOICES, type JarAccent, type JarVisualSettings } from "./settings.ts";
5
+ import { GOAL_ROUND_CHOICES, MAX_SUBAGENT_CHOICES, type JarAccent, type JarVisualSettings } from "./settings.ts";
6
6
 
7
7
  const LABELS: Record<FooterField, string> = {
8
8
  model: "Model", effort: "Model effort", sessionName: "Session name", cwd: "Working directory",
@@ -35,25 +35,37 @@ export async function openJarSettings(
35
35
  let width = 64;
36
36
  const accents: JarAccent[] = ["follow", ...availableAccents.filter((name): name is JarAccent =>
37
37
  name === "default" || ["gray", "pink", "teal", "azure", "violet", "amber"].includes(name))];
38
- const fields = () => page === "footer" ? FOOTER_FIELDS.length : page === "pi" ? 5 : 7;
38
+ let choosing: "goalRounds" | "maxSubagents" | undefined;
39
+ const choices = () => choosing === "maxSubagents" ? MAX_SUBAGENT_CHOICES : GOAL_ROUND_CHOICES;
40
+ const fields = () => choosing ? choices().length : page === "footer" ? FOOTER_FIELDS.length : page === "pi" ? 6 : 7;
41
+ const finishChoice = () => { selected = choosing === "maxSubagents" ? 3 : 2; choosing = undefined; };
39
42
  const piError = (error: unknown) => ctx.ui.notify("Could not change Pi setting: " + (error as Error).message, "error");
40
- const pageSize = () => Math.min(fields(), Math.max(4, (process.stdout.rows ?? 24) - 9));
43
+ const explainPool = () => choosing === "maxSubagents" || (page === "pi" && !choosing && selected === 3);
44
+ const pageSize = () => Math.min(fields(), Math.max(4, (process.stdout.rows ?? 24) - (explainPool() ? 10 : 9)));
41
45
  const firstVisible = () => Math.min(Math.max(0, selected - pageSize() + 1), Math.max(0, fields() - pageSize()));
42
46
  const visibleCount = () => Math.min(pageSize(), fields());
43
47
  const apply = (index: number) => {
44
48
  const state = current();
45
- if (page === "footer") {
49
+ if (choosing) {
50
+ if (choosing === "maxSubagents") update({ ...state, maxSubagents: MAX_SUBAGENT_CHOICES[index]! });
51
+ else update({ ...state, goalRounds: GOAL_ROUND_CHOICES[index]! });
52
+ finishChoice();
53
+ } else if (page === "footer") {
46
54
  const field = FOOTER_FIELDS[index];
47
55
  if (field) update({ ...state, footer: { ...state.footer, [field]: !state.footer[field] } });
48
56
  } else if (page === "pi") {
49
57
  if (index === 0 && pi) { try { pi.setFullscreen(!pi.get().fullscreen); ctx.ui.notify("Pi TUI mode saved; restart Pi to apply", "info"); } catch (error) { piError(error); } }
50
58
  else if (index === 1 && pi) { try { pi.setCopyOnSelect(!pi.get().copyOnSelect); } catch (error) { piError(error); } }
51
59
  else if (index === 2) {
52
- const at = GOAL_ROUND_CHOICES.indexOf(state.goalRounds as never);
53
- update({ ...state, goalRounds: GOAL_ROUND_CHOICES[(at + 1) % GOAL_ROUND_CHOICES.length]! });
60
+ choosing = "goalRounds";
61
+ selected = Math.max(0, GOAL_ROUND_CHOICES.indexOf(state.goalRounds as never));
54
62
  }
55
- else if (index === 3) update({ ...state, advisor: !state.advisor });
56
- else if (index === 4) update({ ...state, advisorGates: !state.advisorGates });
63
+ else if (index === 3) {
64
+ choosing = "maxSubagents";
65
+ selected = Math.max(0, MAX_SUBAGENT_CHOICES.indexOf(state.maxSubagents));
66
+ }
67
+ else if (index === 4) update({ ...state, advisor: !state.advisor });
68
+ else if (index === 5) update({ ...state, advisorGates: !state.advisorGates });
57
69
  } else if (index === 0) {
58
70
  const at = Math.max(0, accents.indexOf(state.accent));
59
71
  update({ ...state, accent: accents[(at + 1) % accents.length]! });
@@ -77,7 +89,12 @@ export async function openJarSettings(
77
89
  return {
78
90
  invalidate() {},
79
91
  handleInput(data: string) {
80
- if (matchesKey(data, Key.escape) || data === "q") { done(); return; }
92
+ if (matchesKey(data, Key.escape) || data === "q") {
93
+ if (choosing) { finishChoice(); tui.requestRender(); } else done();
94
+ return;
95
+ }
96
+ if (choosing && (matchesKey(data, Key.tab) || matchesKey(data, Key.right)
97
+ || matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left))) return;
81
98
  if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
82
99
  page = PAGES[(PAGES.indexOf(page) + 1) % PAGES.length]!;
83
100
  selected = 0;
@@ -97,6 +114,7 @@ export async function openJarSettings(
97
114
  if (event.type !== "click" || event.button !== "left") return;
98
115
  if (event.y === 0 && event.x >= width - 4) { done(); return { handled: true }; }
99
116
  if (event.y === 1) {
117
+ choosing = undefined;
100
118
  if (event.x >= 2 && event.x < Math.min(width - 2, 19)) { page = "appearance"; selected = 0; }
101
119
  else if (event.x >= 19 && event.x < Math.min(width - 2, 32)) { page = "footer"; selected = 0; }
102
120
  else if (event.x >= 32 && event.x < width - 2) { page = "pi"; selected = 0; }
@@ -111,12 +129,8 @@ export async function openJarSettings(
111
129
  render(available: number): string[] {
112
130
  width = Math.max(8, available);
113
131
  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
- };
132
+ // Rows are assembled from exact-width parts, so one truncate-and-pad pass per row is enough.
133
+ const line = (text: string) => theme.fg("dim", "│ ") + truncateToWidth(text, Math.max(0, width - 5), "...", true) + theme.fg("dim", " │");
120
134
  const title = " pi-jar · settings ";
121
135
  const top = theme.fg("accent", "╭─" + truncateToWidth(title, Math.max(0, width - 6))
122
136
  + "─".repeat(Math.max(0, width - 4 - visibleWidth(title))) + "×╮");
@@ -125,14 +139,17 @@ export async function openJarSettings(
125
139
  + " " + theme.fg(page === "pi" ? "accent" : "muted", "[ Pi ]"));
126
140
  const prefs = pi?.get();
127
141
  const divider = theme.fg("dim", "├" + "─".repeat(Math.max(0, width - 2)) + "┤");
128
- const allRows = page === "footer"
142
+ const allRows = choosing
143
+ ? choices().map((choice, index) => row(index, String(choice), state[choosing!] === choice ? "CURRENT" : "", true))
144
+ : page === "footer"
129
145
  ? FOOTER_FIELDS.map((field, index) => row(index, LABELS[field], state.footer[field] ? "ON" : "OFF", state.footer[field]))
130
146
  : page === "pi" ? [
131
147
  row(0, "Mouse clicks (Pi fullscreen, restart)", !prefs ? "N/A" : prefs.fullscreen ? "ON" : "OFF", !!prefs?.fullscreen),
132
148
  row(1, "Copy on select", !prefs ? "N/A" : prefs.copyOnSelect ? "ON" : "OFF", !!prefs?.copyOnSelect),
133
149
  row(2, "Goal auto rounds", String(state.goalRounds), true),
134
- row(3, "Advisor (jar_advisor, /advisor)", state.advisor ? "ON" : "OFF", state.advisor),
135
- row(4, "Advisor gates (loops, repeated failures)", state.advisorGates ? "ON" : "OFF", state.advisorGates)
150
+ row(3, "Max subagents (retained live pool)", String(state.maxSubagents), true),
151
+ row(4, "Advisor (jar_advisor, /advisor)", state.advisor ? "ON" : "OFF", state.advisor),
152
+ row(5, "Advisor gates (loops, repeated failures)", state.advisorGates ? "ON" : "OFF", state.advisorGates)
136
153
  ] : [
137
154
  row(0, "Accent", state.accent === "follow" ? "FOLLOW PI" : state.accent.toUpperCase(), true),
138
155
  row(1, "Motion", state.animations ? "ON" : "OFF", state.animations),
@@ -144,13 +161,18 @@ export async function openJarSettings(
144
161
  ];
145
162
  const start = firstVisible();
146
163
  const rows = allRows.slice(start, start + pageSize());
147
- return [fit(top), tabs, divider,
148
- line(theme.fg("accent", page === "footer" ? " FOOTER VISIBILITY" : page === "pi" ? " PI & WORKFLOWS" : " APPEARANCE & MOTION")),
149
- line(theme.fg("dim", page === "footer" ? ` Footer fields ${start + 1}–${start + rows.length}/${fields()}`
164
+ return [top, tabs, divider,
165
+ line(theme.fg("accent", choosing === "maxSubagents" ? " MAX SUBAGENTS · RETAINED LIVE POOL"
166
+ : choosing === "goalRounds" ? " GOAL AUTO ROUNDS" : page === "footer" ? " FOOTER VISIBILITY" : page === "pi" ? " PI & WORKFLOWS" : " APPEARANCE & MOTION")),
167
+ line(theme.fg("dim", explainPool() ? " Live pool includes idle/paused subagents"
168
+ : choosing ? " Select a value; Enter/click to save, Esc to cancel"
169
+ : page === "footer" ? ` Footer fields ${start + 1}–${start + rows.length}/${fields()}`
150
170
  : page === "pi" ? " Fullscreen mode enables clicks and copy-on-select" : " Accent cycles through loaded themes")),
151
- ...rows.map(fit),
152
- line(theme.fg("dim", " Tab: section · ↑↓: choose · Enter/click: change · Esc")),
153
- fit(theme.fg("dim", "╰" + "─".repeat(Math.max(0, width - 2)) + "╯"))];
171
+ ...rows,
172
+ line(theme.fg("dim", choosing ? " ↑↓: choose · Enter/click: save · Esc: cancel"
173
+ : " Tab: section · ↑↓: choose · Enter/click: change · Esc")),
174
+ ...(explainPool() ? [line(theme.fg("dim", " Lowering does not terminate existing subagents"))] : []),
175
+ theme.fg("dim", "╰" + "─".repeat(Math.max(0, width - 2)) + "╯")];
154
176
  }
155
177
  };
156
178
  });
package/src/settings.ts CHANGED
@@ -17,6 +17,8 @@ export interface JarVisualSettings {
17
17
  suggestions: boolean;
18
18
  /** Automatic implement/audit rounds per user message while a goal is active. */
19
19
  goalRounds: number;
20
+ /** Retained live subagent pool, including idle/paused agents; lowering does not terminate them. */
21
+ maxSubagents: MaxSubagents;
20
22
  /** The jar_advisor second-opinion tool and /advisor. */
21
23
  advisor: boolean;
22
24
  /** Consult the advisor automatically when the agent repeats a tool call or keeps failing. */
@@ -27,10 +29,15 @@ export interface JarVisualSettings {
27
29
  }
28
30
 
29
31
  export const GOAL_ROUND_CHOICES = [4, 8, 12, 20] as const;
32
+ export const MAX_SUBAGENT_CHOICES = [2, 4, 6, 8, 16] as const;
33
+ export type MaxSubagents = (typeof MAX_SUBAGENT_CHOICES)[number];
34
+ export function isMaxSubagents(value: unknown): value is MaxSubagents {
35
+ return MAX_SUBAGENT_CHOICES.some((choice) => choice === value);
36
+ }
30
37
 
31
38
  export const SETTINGS_FILE = "pi-jar-settings.json";
32
39
  export function defaultVisualSettings(footer: FooterSettings = DEFAULT_FOOTER_SETTINGS): JarVisualSettings {
33
- return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, advisor: true, advisorGates: true, icons: "unicode", footer: { ...footer } };
40
+ return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, maxSubagents: 4, advisor: true, advisorGates: true, icons: "unicode", footer: { ...footer } };
34
41
  }
35
42
 
36
43
  /** Legacy footer choices are imported only while the new file is absent. Never modify the old file. */
@@ -60,6 +67,7 @@ export function loadVisualSettings(directory: string): JarVisualSettings {
60
67
  suggestions: typeof value.suggestions === "boolean" ? value.suggestions : fallback.suggestions,
61
68
  goalRounds: typeof value.goalRounds === "number" && Number.isInteger(value.goalRounds) && value.goalRounds >= 1 && value.goalRounds <= 50
62
69
  ? value.goalRounds : fallback.goalRounds,
70
+ maxSubagents: isMaxSubagents(value.maxSubagents) ? value.maxSubagents : fallback.maxSubagents,
63
71
  advisor: typeof value.advisor === "boolean" ? value.advisor : fallback.advisor,
64
72
  advisorGates: typeof value.advisorGates === "boolean" ? value.advisorGates : fallback.advisorGates,
65
73
  icons: ICON_SETS.some((set) => set === value.icons) ? value.icons as IconSet : fallback.icons,
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] }; }
package/src/side-model.ts CHANGED
@@ -28,33 +28,53 @@ export interface SideAnswer { text: string; model: string }
28
28
 
29
29
  /**
30
30
  * Ask the model assigned to `role` (or the current model) a one-shot question outside the
31
- * conversation. Throws on a missing model, provider error or empty answer.
31
+ * conversation. Tries configured fallbacks in order on failure; cancellation never retries.
32
32
  */
33
33
  export async function askRole(ctx: ExtensionContext, roles: ModelRoleManager, usage: SideUsage | undefined, role: string,
34
34
  systemPrompt: string, prompt: string, signal?: AbortSignal): Promise<SideAnswer> {
35
- const assigned = roles.resolve(role);
36
- const model = assigned ? ctx.modelRegistry.find(assigned.provider, assigned.model) : ctx.model;
37
- if (!model) throw new Error(assigned ? `model not found for role ${role}: ${assigned.provider}/${assigned.model}` : "no model selected");
38
- const thinking: ThinkingLevel | undefined = assigned?.thinking;
39
- const reasoning = thinking && thinking !== "off" ? thinking : undefined;
40
- const result = await ctx.modelRegistry.streamSimple(model,
41
- { systemPrompt, messages: [{ role: "user", content: prompt, timestamp: Date.now() }] },
42
- { ...(reasoning ? { reasoning } : {}), ...(signal ? { signal } : {}) }).result();
43
- const name = `${model.provider}/${model.id}`;
44
- const cost = result.usage?.cost?.total;
45
- usage?.add({ role, model: name, input: result.usage?.input ?? 0, output: result.usage?.output ?? 0, cacheRead: result.usage?.cacheRead ?? 0,
46
- cacheWrite: result.usage?.cacheWrite ?? 0, cost: Number.isFinite(cost) ? cost! : 0, at: Date.now() });
47
- if (result.stopReason === "error" || result.stopReason === "aborted") throw new Error(result.errorMessage || `${name} ${result.stopReason}`);
48
- const MAX_ANSWER_CHARS = 32 * 1024;
49
- let text = "";
50
- for (const part of result.content) {
51
- if (part.type !== "text") continue;
52
- const value = (part as { text: string }).text;
53
- const room = MAX_ANSWER_CHARS - text.length;
54
- if (room <= 0) break;
55
- text += value.slice(0, room);
35
+ const fallbacks = roles.fallbackSpecs?.(role) ?? [];
36
+ const failures: string[] = [];
37
+ const tried = new Set<string>();
38
+ for (const spec of [undefined, ...fallbacks]) {
39
+ signal?.throwIfAborted();
40
+ let name = spec ?? role;
41
+ let aborted = false;
42
+ try {
43
+ const assigned = spec === undefined ? roles.resolve(role) : roles.resolveSpec(spec);
44
+ const model = assigned ? ctx.modelRegistry.find(assigned.provider, assigned.model) : ctx.model;
45
+ if (!model) throw new Error(assigned ? `model not found for role ${role}: ${assigned.provider}/${assigned.model}` : "no model selected");
46
+ name = `${model.provider}/${model.id}`;
47
+ const key = name + ":" + (assigned?.thinking ?? "off");
48
+ if (tried.has(key)) continue;
49
+ tried.add(key);
50
+ const thinking: ThinkingLevel | undefined = assigned?.thinking;
51
+ const reasoning = thinking && thinking !== "off" ? thinking : undefined;
52
+ const result = await ctx.modelRegistry.streamSimple(model,
53
+ { systemPrompt, messages: [{ role: "user", content: prompt, timestamp: Date.now() }] },
54
+ { ...(reasoning ? { reasoning } : {}), ...(signal ? { signal } : {}) }).result();
55
+ const cost = result.usage?.cost?.total;
56
+ usage?.add({ role, model: name, input: result.usage?.input ?? 0, output: result.usage?.output ?? 0, cacheRead: result.usage?.cacheRead ?? 0,
57
+ cacheWrite: result.usage?.cacheWrite ?? 0, cost: Number.isFinite(cost) ? cost! : 0, at: Date.now() });
58
+ signal?.throwIfAborted();
59
+ aborted = result.stopReason === "aborted";
60
+ if (result.stopReason === "error" || aborted) throw new Error(result.errorMessage || `${name} ${result.stopReason}`);
61
+ const MAX_ANSWER_CHARS = 32 * 1024;
62
+ let text = "";
63
+ for (const part of result.content) {
64
+ if (part.type !== "text") continue;
65
+ const value = (part as { text: string }).text;
66
+ const room = MAX_ANSWER_CHARS - text.length;
67
+ if (room <= 0) break;
68
+ text += value.slice(0, room);
69
+ }
70
+ text = text.trim();
71
+ if (!text) throw new Error(`${name} returned an empty answer`);
72
+ return { text, model: name };
73
+ } catch (error) {
74
+ signal?.throwIfAborted();
75
+ if (aborted || (error instanceof Error && error.name === "AbortError") || !fallbacks.length) throw error;
76
+ failures.push(`${name}: ${error instanceof Error ? error.message : String(error)}`);
77
+ }
56
78
  }
57
- text = text.trim();
58
- if (!text) throw new Error(`${name} returned an empty answer`);
59
- return { text, model: name };
79
+ throw new Error(`All models for role ${role} failed:\n${failures.join("\n")}`);
60
80
  }
package/src/split-view.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { RowCache } from "./row-cache.ts";
2
3
 
3
4
  export interface SplitTheme { fg(color: string, text: string): string }
4
5
 
@@ -15,9 +16,14 @@ export interface SplitLayout {
15
16
  footerTop: number;
16
17
  }
17
18
 
18
- const pad = (text: string, width: number) => {
19
- const value = truncateToWidth(text, Math.max(0, width));
20
- return value + " ".repeat(Math.max(0, width - visibleWidth(value)));
19
+ /** Padded pane rows: scrolling or one live row re-pads only what changed. */
20
+ const padded = { left: new RowCache(), right: new RowCache(), footer: new RowCache() };
21
+
22
+ /** The last frame: overlays repaint on every Pi render (including each streamed token) with unchanged rows. */
23
+ let previous: { key: string; left: string[]; right: string[]; footer: string[]; lines: string[]; layout: SplitLayout } | undefined;
24
+ const sameRows = (a: readonly string[], b: readonly string[], rows: number) => {
25
+ for (let row = 0; row < rows; row++) if ((a[row] ?? "") !== (b[row] ?? "")) return false;
26
+ return true;
21
27
  };
22
28
 
23
29
  /** Sidebar width for a split view, or 0 when the terminal is too narrow for two panes. */
@@ -34,25 +40,36 @@ export function splitFrame(theme: SplitTheme, width: number, title: string, left
34
40
  footer: readonly string[], rows: number, leftWidth = sidebarWidth(width)): { lines: string[]; layout: SplitLayout } {
35
41
  const w = Math.max(12, width);
36
42
  const dim = (text: string) => theme.fg("dim", text);
37
- const heading = " " + title + " ";
38
- const top = theme.fg("accent", "╭─" + truncateToWidth(heading, Math.max(0, w - 6)) + "─".repeat(Math.max(0, w - 4 - visibleWidth(truncateToWidth(heading, Math.max(0, w - 6))))) + "×╮");
43
+ const key = `${w}\0${title}\0${rows}\0${leftWidth}\0${dim("│")}${theme.fg("accent", "╭")}`;
44
+ if (previous?.key === key && footer.length === previous.footer.length && sameRows(footer, previous.footer, footer.length)
45
+ && sameRows(left, previous.left, rows) && sameRows(right, previous.right, rows)) {
46
+ return { lines: previous.lines.slice(), layout: { ...previous.layout } };
47
+ }
48
+ const heading = truncateToWidth(" " + title + " ", Math.max(0, w - 6));
49
+ const top = theme.fg("accent", "╭─" + heading + "─".repeat(Math.max(0, w - 4 - visibleWidth(heading))) + "×╮");
39
50
  const lines = [top];
40
51
  const split = leftWidth > 0;
41
52
  const bodyWidth = split ? w - leftWidth - 6 : w - 4;
53
+ padded.left.begin(String(leftWidth));
54
+ padded.right.begin(String(bodyWidth));
55
+ padded.footer.begin(String(w - 4));
56
+ // truncateToWidth pads in the pass that measures, so each new row is scanned once.
57
+ const pad = (cache: RowCache, text: string, cells: number) => cache.get(text, (value) => truncateToWidth(value, Math.max(0, cells), "...", true));
42
58
  for (let row = 0; row < rows; row++) {
43
- const body = pad(right[row] ?? "", bodyWidth);
59
+ const body = pad(padded.right, right[row] ?? "", bodyWidth);
44
60
  lines.push(split
45
- ? dim("│ ") + pad(left[row] ?? "", leftWidth) + dim("│ ") + body + dim(" │")
61
+ ? dim("│ ") + pad(padded.left, left[row] ?? "", leftWidth) + dim("│ ") + body + dim(" │")
46
62
  : dim("│ ") + body + dim(" │"));
47
63
  }
48
64
  lines.push(dim("├" + (split ? "─".repeat(leftWidth + 1) + "┴" + "─".repeat(Math.max(0, w - leftWidth - 4)) : "─".repeat(w - 2)) + "┤"));
49
65
  const footerTop = lines.length;
50
- for (const line of footer) lines.push(dim("│ ") + pad(line, w - 4) + dim(" │"));
66
+ for (const line of footer) lines.push(dim("│ ") + pad(padded.footer, line, w - 4) + dim(" │"));
51
67
  lines.push(dim("╰" + "─".repeat(w - 2) + "╯"));
52
- return {
53
- lines: lines.map((line) => truncateToWidth(line, w)),
54
- layout: { top: 1, rows, leftWidth: split ? leftWidth : 0, bodyX: split ? leftWidth + 4 : 2, footerTop }
55
- };
68
+ // Every row is assembled from exact-width parts; only a sidebar too wide for the frame can overflow.
69
+ const fitted = bodyWidth < 0 ? lines.map((line) => truncateToWidth(line, w)) : lines;
70
+ const layout = { top: 1, rows, leftWidth: split ? leftWidth : 0, bodyX: split ? leftWidth + 4 : 2, footerTop };
71
+ previous = { key, left: left.slice(0, rows), right: right.slice(0, rows), footer: footer.slice(), lines: fitted, layout };
72
+ return { lines: fitted.slice(), layout: { ...layout } };
56
73
  }
57
74
 
58
75
  /** Available content rows for a full-height overlay with `chrome` fixed rows. */
package/src/status.ts CHANGED
@@ -73,7 +73,8 @@ export function collectStatuses(statuses: ReadonlyMap<string, string>, now: numb
73
73
  const id = rawKey.slice(ROLE_PREFIX.length);
74
74
  if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(id)) continue;
75
75
  const role = cachedRole(id, rawValue);
76
- if (!role || role.expiresAt == null || role.expiresAt <= now || role.expiresAt > now + ROLE_TTL_MS) {
76
+ const ttl = id.startsWith("delegate-") ? 24 * 60 * 60_000 : ROLE_TTL_MS;
77
+ if (!role || role.expiresAt == null || role.expiresAt <= now || role.expiresAt > now + ttl) {
77
78
  extras.push(`${id}: unavailable`);
78
79
  continue;
79
80
  }