pi-cockpit 0.1.0

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/footer.ts ADDED
@@ -0,0 +1,354 @@
1
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import type { ExtensionStatusSegment } from "./extension-status.ts";
3
+ import type { IconGlyphs } from "./icons.ts";
4
+ import { fitSegmentsByPriority, type PrioritizedSegment, type WidthUtils } from "./layout.ts";
5
+
6
+ // Re-exported so existing importers keep working; the implementations now live in
7
+ // layout.ts because the widgets need the same priority-collapse behaviour.
8
+ export { fitSegmentsByPriority, type WidthUtils };
9
+
10
+ export type PaintTheme = Pick<Theme, "fg">;
11
+
12
+ export interface UsageTotals {
13
+ input: number;
14
+ output: number;
15
+ cacheRead: number;
16
+ cacheWrite: number;
17
+ cost: number;
18
+ latestCacheHitRate: number | undefined;
19
+ }
20
+
21
+ export interface FooterParts {
22
+ model: string;
23
+ provider?: string;
24
+ thinking?: string;
25
+ cwd?: string;
26
+ ctxPct: number;
27
+ ctxTokens: number;
28
+ ctxWindow: number;
29
+ totals: UsageTotals;
30
+ git?: string;
31
+ agentSummary?: string;
32
+ workflowStatus?: string;
33
+ extensionStatuses?: readonly ExtensionStatusSegment[];
34
+ width: number;
35
+ glyphs: IconGlyphs;
36
+ theme: PaintTheme;
37
+ utils: WidthUtils;
38
+ }
39
+
40
+ export function emptyTotals(): UsageTotals {
41
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, latestCacheHitRate: undefined };
42
+ }
43
+
44
+ let usageCache: { key: string; totals: UsageTotals } | undefined;
45
+
46
+ function entryIdentity(entry: unknown): string {
47
+ const e = entry as {
48
+ id?: string;
49
+ timestamp?: number;
50
+ type?: string;
51
+ message?: {
52
+ role?: string;
53
+ usage?: {
54
+ input?: number;
55
+ output?: number;
56
+ cacheRead?: number;
57
+ cacheWrite?: number;
58
+ cost?: { total?: number };
59
+ };
60
+ };
61
+ } | undefined;
62
+ if (!e) return "";
63
+ const usage = e.message?.usage;
64
+ const usageKey = usage
65
+ ? `${usage.input ?? 0}:${usage.output ?? 0}:${usage.cacheRead ?? 0}:${usage.cacheWrite ?? 0}:${usage.cost?.total ?? 0}`
66
+ : "";
67
+ return `${e.id ?? ""}|${e.timestamp ?? ""}|${e.type ?? ""}|${e.message?.role ?? ""}|${usageKey}`;
68
+ }
69
+
70
+ function entriesKey(entries: readonly unknown[]): string {
71
+ return `${entries.length}\0${entryIdentity(entries[0])}\0${entryIdentity(entries.at(-1))}`;
72
+ }
73
+
74
+ export function invalidateUsageCache(): void {
75
+ usageCache = undefined;
76
+ }
77
+
78
+ // Sum assistant-message usage across session entries (shape: entry.message.usage).
79
+ export function getUsageTotals(entries: readonly unknown[]): UsageTotals {
80
+ const key = entriesKey(entries);
81
+ if (usageCache && usageCache.key === key) return usageCache.totals;
82
+
83
+ const t = emptyTotals();
84
+ for (const e of entries) {
85
+ const entry = e as { type?: string; message?: { role?: string; usage?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; cost?: { total?: number } } } } | undefined;
86
+ if (entry?.type === "message" && entry.message?.role === "assistant") {
87
+ const u = entry.message.usage;
88
+ if (!u) continue;
89
+ t.input += u.input ?? 0;
90
+ t.output += u.output ?? 0;
91
+ t.cacheRead += u.cacheRead ?? 0;
92
+ t.cacheWrite += u.cacheWrite ?? 0;
93
+ t.cost += u.cost?.total ?? 0;
94
+ const promptTokens = (u.input ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0);
95
+ if (promptTokens > 0) {
96
+ t.latestCacheHitRate = ((u.cacheRead ?? 0) / promptTokens) * 100;
97
+ }
98
+ }
99
+ }
100
+ usageCache = { key, totals: t };
101
+ return t;
102
+ }
103
+
104
+ export function fmtTokens(n: number): string {
105
+ if (n >= 1_000_000) return trim(n / 1_000_000) + "m";
106
+ if (n >= 1_000) return trim(n / 1_000) + "k";
107
+ return String(n);
108
+ }
109
+
110
+ function trim(x: number): string {
111
+ return x.toFixed(1).replace(/\.0$/, "");
112
+ }
113
+
114
+ export function renderBar(pct: number, barWidth: number, glyphs: IconGlyphs, theme: PaintTheme): string {
115
+ const w = Math.max(0, barWidth);
116
+ const clamped = Math.max(0, Math.min(100, pct));
117
+ const filled = Math.max(0, Math.min(w, Math.round((clamped / 100) * w)));
118
+ const empty = w - filled;
119
+ return theme.fg("dim", "[") + theme.fg(contextColor(pct), glyphs.barDone.repeat(filled)) + theme.fg("dim", glyphs.barPending.repeat(empty)) + theme.fg("dim", "]");
120
+ }
121
+
122
+ const CONTEXT_WARN_PCT = 70;
123
+ const CONTEXT_CRITICAL_PCT = 90;
124
+
125
+ function contextColor(pct: number): ThemeColor {
126
+ if (pct >= CONTEXT_CRITICAL_PCT) return "error";
127
+ if (pct >= CONTEXT_WARN_PCT) return "warning";
128
+ return "accent";
129
+ }
130
+
131
+ // Crossing a context threshold used to be signalled by hue alone. The glyph makes
132
+ // the escalation readable without colour, per the non-colour-accessibility rule.
133
+ function contextMark(pct: number, glyphs: IconGlyphs): string {
134
+ return pct >= CONTEXT_WARN_PCT ? `${glyphs.blocked} ` : "";
135
+ }
136
+
137
+ // yolo / bypassPermissions disable approval prompts entirely. Red text alone is
138
+ // not enough for a safety-relevant state.
139
+ const UNSAFE_APPROVAL_MODES = new Set(["yolo", "bypasspermissions"]);
140
+
141
+ function approvalMode(status: ExtensionStatusSegment): string {
142
+ return status.text.replace(/^APPROVAL\s+/i, "").trim().toLowerCase();
143
+ }
144
+
145
+ function extensionStatusColor(status: ExtensionStatusSegment): ThemeColor {
146
+ if (status.key === "maestro-auto-compact-mode") {
147
+ return /\bON$/i.test(status.text.trim()) ? "success" : "muted";
148
+ }
149
+ if (status.key === "mode") {
150
+ const mode = status.text.trim().toUpperCase();
151
+ if (mode === "ACT") return "success";
152
+ if (mode === "PLAN") return "warning";
153
+ if (mode === "READY" || mode === "PLAN READY") return "accent";
154
+ return "muted";
155
+ }
156
+ if (status.key !== "approval-mode") return "muted";
157
+ const mode = approvalMode(status);
158
+ if (UNSAFE_APPROVAL_MODES.has(mode)) return "error";
159
+ if (mode === "dontask") return "warning";
160
+ if (mode === "acceptedits") return "success";
161
+ if (mode === "plan") return "accent";
162
+ return "text";
163
+ }
164
+
165
+ const HIDDEN_EXTENSION_STATUS_KEYS = new Set([
166
+ "maestro-auto-compact-mode",
167
+ "mcp",
168
+ "swarm-best",
169
+ "team-swarm",
170
+ ]);
171
+
172
+ function isVisibleExtensionStatus(status: ExtensionStatusSegment, thinking?: string): boolean {
173
+ if (status.text === "" || status.text === thinking) return false;
174
+ if (HIDDEN_EXTENSION_STATUS_KEYS.has(status.key)) return false;
175
+ return !/^(?:TEAM SWARM\b|BEST\b|COMPLETED$|ACT$)/i.test(status.text.trim());
176
+ }
177
+
178
+ function paintExtensionStatus(
179
+ status: ExtensionStatusSegment,
180
+ theme: PaintTheme,
181
+ ): string {
182
+ const text = status.key === "approval-mode" && UNSAFE_APPROVAL_MODES.has(approvalMode(status))
183
+ ? "YOLO"
184
+ : status.key === "maestro-auto-compact-mode"
185
+ ? status.text.replace(/^AUTO\s+/i, "AUTO COMPACT ")
186
+ : status.text;
187
+ return theme.fg(extensionStatusColor(status), text);
188
+ }
189
+
190
+ function alignRight(left: string, right: string, width: number, measure: WidthUtils["measure"]): string {
191
+ if (right === "") return left;
192
+ const rw = measure(right);
193
+ if (left === "") return " ".repeat(Math.max(0, width - rw)) + right;
194
+ const lw = measure(left);
195
+ if (lw + rw + 1 > width) return left;
196
+ return left + " ".repeat(width - lw - rw) + right;
197
+ }
198
+
199
+ export function renderFooter(p: FooterParts): string[] {
200
+ const { width, theme, glyphs: g, utils } = p;
201
+ if (width <= 0) return [""];
202
+ const ell = theme.fg("dim", g.ellipsis);
203
+ const sep = theme.fg("dim", g.separator.trim());
204
+ const identitySeparator = ` ${sep} `;
205
+ const visibleStatuses = (p.extensionStatuses ?? []).filter(
206
+ (status) => isVisibleExtensionStatus(status, p.thinking),
207
+ );
208
+ const controlStatuses = visibleStatuses.filter(
209
+ (status) => status.key === "approval-mode" || status.key === "maestro-auto-compact-mode",
210
+ );
211
+
212
+ // line 1: prioritized identity and control segments
213
+ // minWidth keeps a glyph-prefixed segment from decaying into "{icon}…", which
214
+ // costs columns and tells the user nothing about the path or branch.
215
+ const labelled = (glyph: string, value: string, priority: number, color: ThemeColor): PrioritizedSegment => ({
216
+ text: `${theme.fg(color, glyph)} ${theme.fg(color, value)}`,
217
+ priority,
218
+ minWidth: utils.measure(glyph) + 1 + utils.measure(g.ellipsis) + 3,
219
+ });
220
+ const leftParts: PrioritizedSegment[] = controlStatuses.map((status, index) => ({
221
+ text: paintExtensionStatus(status, theme),
222
+ priority: 8 - index,
223
+ clippable: false,
224
+ }));
225
+ leftParts.push({
226
+ text: `${theme.fg("accent", g.model)} ${theme.fg("accent", p.model)}`,
227
+ priority: 5,
228
+ minWidth: utils.measure(g.model) + 1 + utils.measure(g.ellipsis) + 3,
229
+ });
230
+ if (p.thinking && p.thinking !== "off") {
231
+ leftParts.push({ text: theme.fg("muted", p.thinking), priority: 4, clippable: false });
232
+ }
233
+ if (p.cwd) leftParts.push(labelled(g.workspace, p.cwd, 0, "syntaxFunction"));
234
+ if (p.git) leftParts.push(labelled(g.git, p.git, 3, "syntaxFunction"));
235
+
236
+ // line 1: a left identity group and one right-aligned resource group.
237
+ // The resource group progressively contracts before lower-priority identity
238
+ // segments are sacrificed: full bar → compact bar → percentage → token totals.
239
+ const resourceCandidates: string[] = [];
240
+ const statSeparator = ` ${sep} `;
241
+ const addResourceCandidate = (...parts: string[]): void => {
242
+ const candidate = parts.filter((part) => part !== "").join(statSeparator);
243
+ if (!resourceCandidates.includes(candidate)) resourceCandidates.push(candidate);
244
+ };
245
+
246
+ const t = p.totals;
247
+ const inputText = `${theme.fg("accent", g.tokensIn)}${fmtTokens(t.input)}`;
248
+ const outputText = `${theme.fg("success", g.tokensOut)}${fmtTokens(t.output)}`;
249
+ const tokenStats = [inputText, outputText];
250
+ const coreStats = [...tokenStats];
251
+ const hasCache = t.cacheRead > 0 || t.cacheWrite > 0;
252
+ if (hasCache && t.latestCacheHitRate !== undefined) {
253
+ coreStats.push(`${theme.fg("success", g.cacheHit)}${t.latestCacheHitRate.toFixed(0)}%`);
254
+ }
255
+ const fullStats = p.agentSummary
256
+ ? [...coreStats, theme.fg("accent", p.agentSummary)]
257
+ : coreStats;
258
+
259
+ if (p.ctxWindow > 0) {
260
+ // A contextWindow that under-reports the live token count (custom providers
261
+ // do this) previously printed e.g. "137%" beside a bar clamped at full.
262
+ const shownPct = Math.min(100, Math.round(p.ctxPct));
263
+ const pctText = theme.fg(
264
+ contextColor(p.ctxPct),
265
+ `${contextMark(p.ctxPct, g)}${shownPct}%`,
266
+ );
267
+ const tokText = `${theme.fg("text", fmtTokens(p.ctxTokens))}${theme.fg("dim", "/")}${theme.fg("text", fmtTokens(p.ctxWindow))}`;
268
+ const fullContext = `${renderBar(p.ctxPct, 10, g, theme)} ${pctText} ${sep} ${tokText}`;
269
+ const compactContext = `${renderBar(p.ctxPct, 5, g, theme)} ${pctText} ${sep} ${tokText}`;
270
+ const capacityContext = `${pctText} ${sep} ${tokText}`;
271
+ addResourceCandidate(fullContext, ...fullStats);
272
+ addResourceCandidate(fullContext, ...coreStats);
273
+ addResourceCandidate(compactContext, ...coreStats);
274
+ addResourceCandidate(compactContext, ...tokenStats);
275
+ addResourceCandidate(capacityContext, ...tokenStats);
276
+ addResourceCandidate(pctText, ...tokenStats);
277
+ addResourceCandidate(...tokenStats);
278
+ addResourceCandidate(pctText);
279
+ } else {
280
+ addResourceCandidate(...fullStats);
281
+ addResourceCandidate(...coreStats);
282
+ addResourceCandidate(...tokenStats);
283
+ }
284
+ resourceCandidates.push("");
285
+
286
+ const fitLeftBeside = (right: string): string[] => {
287
+ const rightWidth = utils.measure(right);
288
+ const available = Math.max(0, width - rightWidth - (right ? 1 : 0));
289
+ return fitSegmentsByPriority(
290
+ leftParts,
291
+ available,
292
+ utils.measure,
293
+ utils.clip,
294
+ g.ellipsis,
295
+ utils.measure(identitySeparator),
296
+ );
297
+ };
298
+
299
+ let right1 = "";
300
+ let fittedLeft: string[] = [];
301
+ let selected = false;
302
+ for (const candidate of resourceCandidates) {
303
+ const candidateLeft = fitLeftBeside(candidate);
304
+ if (candidateLeft.length !== leftParts.length) continue;
305
+ right1 = candidate;
306
+ fittedLeft = candidateLeft;
307
+ selected = true;
308
+ break;
309
+ }
310
+ if (!selected) {
311
+ // At extreme widths keep the highest-priority identity segment beside the
312
+ // smallest useful context summary. If even that cannot fit, identity wins.
313
+ for (let index = resourceCandidates.length - 2; index >= 0; index--) {
314
+ const candidate = resourceCandidates[index];
315
+ const candidateLeft = fitLeftBeside(candidate);
316
+ if (candidateLeft.length === 0) continue;
317
+ right1 = candidate;
318
+ fittedLeft = candidateLeft;
319
+ selected = true;
320
+ break;
321
+ }
322
+ }
323
+ if (!selected) fittedLeft = fitLeftBeside("");
324
+
325
+ const line1 = alignRight(fittedLeft.join(identitySeparator), right1, width, utils.measure);
326
+ const lines = [utils.clip(line1, width, ell)];
327
+ if (p.workflowStatus) {
328
+ lines.push(utils.clip(theme.fg("muted", p.workflowStatus), width, ell));
329
+ }
330
+ const statuses = visibleStatuses.filter((status) => !controlStatuses.includes(status));
331
+ if (statuses.length > 0) {
332
+ const statusSeparator = ` ${sep} `;
333
+ const fittedStatuses = fitSegmentsByPriority(
334
+ statuses.map((status, index) => {
335
+ const unsafe = status.key === "approval-mode" && UNSAFE_APPROVAL_MODES.has(approvalMode(status));
336
+ return {
337
+ text: paintExtensionStatus(status, theme),
338
+ // An unsafe approval mode must never be the segment that gets dropped.
339
+ priority: unsafe ? statuses.length + 1 : statuses.length - index,
340
+ clippable: !unsafe,
341
+ };
342
+ }),
343
+ width,
344
+ utils.measure,
345
+ utils.clip,
346
+ g.ellipsis,
347
+ utils.measure(statusSeparator),
348
+ );
349
+ if (fittedStatuses.length > 0) {
350
+ lines.push(utils.clip(fittedStatuses.join(statusSeparator), width, ell));
351
+ }
352
+ }
353
+ return lines;
354
+ }
package/src/icons.ts ADDED
@@ -0,0 +1,166 @@
1
+ export type IconMode = "auto" | "nerd" | "ascii";
2
+
3
+ export interface IconGlyphs {
4
+ spinFrames: string;
5
+ dotRunning: string;
6
+ dotIdle: string;
7
+ check: string;
8
+ cross: string;
9
+ pending: string;
10
+ blocked: string;
11
+ barDone: string;
12
+ barActive: string;
13
+ barPending: string;
14
+ arrow: string;
15
+ /** Points at a dependency/upstream ("blocked by", "waits on"). */
16
+ depArrow: string;
17
+ /** Tree connectors. Routed through the glyph set so ascii mode stays ascii. */
18
+ treeBranch: string;
19
+ treeLast: string;
20
+ treeVertical: string;
21
+ treeSpace: string;
22
+ ellipsis: string;
23
+ separator: string;
24
+ /** Overlay chrome. Kept in the glyph table so ascii mode draws a real ascii box. */
25
+ box: {
26
+ topLeft: string;
27
+ topRight: string;
28
+ bottomLeft: string;
29
+ bottomRight: string;
30
+ horizontal: string;
31
+ vertical: string;
32
+ };
33
+ /** Marks the focused row. Must survive without the selected background colour. */
34
+ selectMarker: string;
35
+ /** Marks an empty collection. */
36
+ emptyMark: string;
37
+ /** Vertical navigation affordance shown in help lines. */
38
+ upDown: string;
39
+ model: string;
40
+ workspace: string;
41
+ git: string;
42
+ tokensIn: string;
43
+ tokensOut: string;
44
+ cost: string;
45
+ cacheHit: string;
46
+ }
47
+
48
+ const NERD_GLYPHS: IconGlyphs = {
49
+ spinFrames: "⠋⠙⠹⠴⠦⠇",
50
+ dotRunning: "●",
51
+ dotIdle: "·",
52
+ check: "✓",
53
+ cross: "✕",
54
+ // A hollow ring, not the idle dot: "waiting to start" and "running but quiet"
55
+ // are different states and must not share a glyph.
56
+ pending: "○",
57
+ blocked: "!",
58
+ barDone: "█",
59
+ barActive: "▓",
60
+ barPending: "░",
61
+ arrow: "»",
62
+ depArrow: "←",
63
+ treeBranch: "├─",
64
+ treeLast: "└─",
65
+ treeVertical: "│ ",
66
+ treeSpace: " ",
67
+ ellipsis: "…",
68
+ separator: " · ",
69
+ box: { topLeft: "╭", topRight: "╮", bottomLeft: "╰", bottomRight: "╯", horizontal: "─", vertical: "│" },
70
+ selectMarker: "›",
71
+ emptyMark: "○",
72
+ upDown: "↑↓",
73
+ model: "⚡",
74
+ workspace: "",
75
+ git: "",
76
+ tokensIn: "↑",
77
+ tokensOut: "↓",
78
+ cost: "$",
79
+ cacheHit: "⚡",
80
+ };
81
+
82
+ const ASCII_GLYPHS: IconGlyphs = {
83
+ spinFrames: "|/-\\",
84
+ dotRunning: "*",
85
+ dotIdle: ".",
86
+ check: "+",
87
+ cross: "x",
88
+ pending: "o",
89
+ blocked: "!",
90
+ barDone: "#",
91
+ barActive: "+",
92
+ barPending: "-",
93
+ arrow: ">",
94
+ depArrow: "<-",
95
+ treeBranch: "|-",
96
+ treeLast: "`-",
97
+ treeVertical: "| ",
98
+ treeSpace: " ",
99
+ ellipsis: "...",
100
+ separator: " | ",
101
+ box: { topLeft: "+", topRight: "+", bottomLeft: "+", bottomRight: "+", horizontal: "-", vertical: "|" },
102
+ selectMarker: ">",
103
+ emptyMark: "o",
104
+ upDown: "up/dn",
105
+ model: "~",
106
+ workspace: "[]",
107
+ git: "git",
108
+ tokensIn: "^",
109
+ tokensOut: "v",
110
+ cost: "$",
111
+ cacheHit: "c",
112
+ };
113
+
114
+ const NERD_FONT_TERMINALS = new Set([
115
+ "iTerm.app",
116
+ "Ghostty",
117
+ "WezTerm",
118
+ "kitty",
119
+ "rio",
120
+ "tabby",
121
+ "WindowsTerminal",
122
+ "vscode",
123
+ ]);
124
+
125
+ export function detectNerdFont(): boolean {
126
+ const termProgram = process.env.TERM_PROGRAM;
127
+ if (termProgram && NERD_FONT_TERMINALS.has(termProgram)) return true;
128
+ const lcTerminal = process.env.LC_TERMINAL;
129
+ if (lcTerminal && NERD_FONT_TERMINALS.has(lcTerminal)) return true;
130
+ if (process.env.TERM === "xterm-kitty") return true;
131
+ if (process.env.WT_SESSION) return true;
132
+ return false;
133
+ }
134
+
135
+ export function resolveIconMode(mode: IconMode): "nerd" | "ascii" {
136
+ if (mode === "nerd") return "nerd";
137
+ if (mode === "ascii") return "ascii";
138
+ return detectNerdFont() ? "nerd" : "ascii";
139
+ }
140
+
141
+ export function resolveGlyphs(mode: IconMode): IconGlyphs {
142
+ return resolveIconMode(mode) === "nerd" ? NERD_GLYPHS : ASCII_GLYPHS;
143
+ }
144
+
145
+ /**
146
+ * One source of truth for animation cadence.
147
+ *
148
+ * The frame clock and the redraw tick must be the same number. When frames
149
+ * advanced every 120ms but the UI only repainted every 250ms, each repaint
150
+ * skipped ~2 frames: the 6-frame braille spinner ran at half speed and the
151
+ * 4-frame ASCII spinner degenerated into a two-glyph flip (| ↔ -), which reads
152
+ * as blinking rather than rotation.
153
+ */
154
+ export const ANIMATION_PERIOD_MS = 250;
155
+
156
+ /**
157
+ * The frame to draw at `now`, or a stable glyph when nothing is driving redraws.
158
+ *
159
+ * A frozen mid-cycle spinner claims "busy" while the UI has actually stopped
160
+ * repainting, so an idle surface gets a static marker instead.
161
+ */
162
+ export function spinFrame(glyphs: IconGlyphs, now: number, animating = true): string {
163
+ if (!animating) return glyphs.dotRunning;
164
+ const frames = glyphs.spinFrames;
165
+ return frames[Math.floor(now / ANIMATION_PERIOD_MS) % frames.length];
166
+ }