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/README.md +57 -15
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/WORKFLOWS.md +32 -7
- package/extensions/index.ts +127 -48
- package/package.json +1 -1
- package/src/activity-view.ts +324 -44
- package/src/advisor.ts +9 -3
- package/src/ask-tool.ts +12 -7
- package/src/changes.ts +35 -11
- package/src/child-baselines.ts +42 -0
- package/src/compact-tools.ts +58 -24
- package/src/composer.ts +39 -12
- package/src/delegate-worktree.ts +360 -0
- package/src/delegate.ts +1137 -129
- package/src/democracy.ts +192 -0
- package/src/dialogs.ts +9 -5
- package/src/diff-view.ts +13 -1
- package/src/discussion.ts +153 -0
- package/src/flame.ts +18 -5
- package/src/footer.ts +38 -19
- package/src/goal-loop.ts +8 -1
- package/src/history-ui.ts +19 -12
- package/src/history.ts +3 -1
- package/src/model-roles.ts +120 -36
- package/src/panel.ts +5 -1
- package/src/prompt-search.ts +16 -8
- package/src/roles-ui.ts +18 -10
- package/src/row-cache.ts +26 -0
- package/src/session-ui.ts +9 -4
- package/src/settings-ui.ts +46 -24
- package/src/settings.ts +9 -1
- package/src/shells.ts +2 -1
- package/src/side-model.ts +45 -25
- package/src/split-view.ts +29 -12
- package/src/status.ts +2 -1
- package/src/task-tool.ts +156 -54
- package/src/tasks-ui.ts +15 -5
- package/src/tasks.ts +224 -38
- package/src/welcome.ts +26 -13
package/src/settings-ui.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
53
|
-
|
|
60
|
+
choosing = "goalRounds";
|
|
61
|
+
selected = Math.max(0, GOAL_ROUND_CHOICES.indexOf(state.goalRounds as never));
|
|
54
62
|
}
|
|
55
|
-
else if (index === 3)
|
|
56
|
-
|
|
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") {
|
|
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
|
-
|
|
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 =
|
|
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, "
|
|
135
|
-
row(4, "Advisor
|
|
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 [
|
|
148
|
-
line(theme.fg("accent",
|
|
149
|
-
|
|
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
|
|
152
|
-
line(theme.fg("dim",
|
|
153
|
-
|
|
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
|
|
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.
|
|
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
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
38
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
}
|