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/index.ts ADDED
@@ -0,0 +1,595 @@
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
3
+ import type { TUI } from "@earendil-works/pi-tui";
4
+ import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
+ import { AgentsStore, type CompletePayload, type MessagePayload, type StartedPayload } from "./agents-store.ts";
6
+ import { statusText, titleFor, workingMessage, type AmbientState } from "./ambient.ts";
7
+ import { BashBgStore } from "./bash-bg-store.ts";
8
+ import { BashBgOverlay } from "./bash-bg-overlay.ts";
9
+ import { TodoStore } from "./todo-store.ts";
10
+ import { makeTodoWidget, makeAgentWidget, terminalRows } from "./stack-widget.ts";
11
+ import { activeThemeName, ThemePicker } from "./theme-picker.ts";
12
+ import { getUsageTotals, invalidateUsageCache, renderFooter, type PaintTheme, type WidthUtils } from "./footer.ts";
13
+ import { collectExtensionStatuses } from "./extension-status.ts";
14
+ import { ANIMATION_PERIOD_MS, resolveGlyphs } from "./icons.ts";
15
+ import { ensureConfigExists, loadConfig, saveConfig } from "./config.ts";
16
+ import { applyRow, buildRows, rowKeyForAccel, type SaveState } from "./settings-view.ts";
17
+ import {
18
+ AGENT_WIDGET_KEY,
19
+ BASH_BG_QUERY_EVENT,
20
+ BASH_BG_UPDATE_EVENT,
21
+ COCKPIT_TODO_TOGGLE_EVENT,
22
+ COCKPIT_UI_OWNERSHIP_EVENT,
23
+ DEFAULT_CONFIG,
24
+ STACK_WIDGET_KEY,
25
+ TEAMMATE_COMPLETE_EVENT,
26
+ TEAMMATE_MESSAGE_EVENT,
27
+ TEAMMATE_STARTED_EVENT,
28
+ TODO_TOOL_NAME,
29
+ WORKFLOW_STATUS_KEY,
30
+ type CockpitConfig,
31
+ } from "./types.ts";
32
+
33
+ const FOOTER_UTILS: WidthUtils = { measure: visibleWidth, clip: truncateToWidth };
34
+ const BASH_BG_OVERLAY_KEY = "alt+j";
35
+ const COCKPIT_STATUS_KEY = "cockpit";
36
+ const WIDTH_POLL_INTERVAL_MS = 250;
37
+
38
+ function isTuiContext(ctx: ExtensionContext): boolean {
39
+ try {
40
+ const mode = (ctx as ExtensionContext & { mode?: string }).mode;
41
+ return ctx.hasUI && (mode === undefined || mode === "tui");
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ function formatCwd(cwd: string): string {
48
+ const home = process.env.HOME || process.env.USERPROFILE;
49
+ if (!home) return cwd;
50
+ const rel = relative(resolve(home), resolve(cwd));
51
+ const insideHome = rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
52
+ if (!insideHome) return cwd;
53
+ return rel === "" ? "~" : `~${sep}${rel}`;
54
+ }
55
+
56
+ export default function (pi: ExtensionAPI): void {
57
+ const agents = new AgentsStore();
58
+ const bashBg = new BashBgStore();
59
+ const todos = new TodoStore();
60
+ let config: CockpitConfig = structuredClone(DEFAULT_CONFIG);
61
+ let lastCtx: ExtensionContext | undefined;
62
+ let capturedTui: TUI | undefined;
63
+ let running = false;
64
+ const activeTools = new Map<string, string>();
65
+ let tick: ReturnType<typeof setInterval> | undefined;
66
+ // Persisted rather than toasted: a config that failed to load silently downgrades
67
+ // the whole session to defaults, so it belongs in a slot that does not scroll away.
68
+ let configProblem: string | undefined;
69
+
70
+ // The streaming line, the tab title and the footer status slot are all fed from
71
+ // the same snapshots the widgets read, so they can never disagree with them.
72
+ const refreshAmbient = (): void => {
73
+ const ctx = lastCtx;
74
+ if (!ctx || !isTuiContext(ctx)) return;
75
+ const g = resolveGlyphs(config.icons.mode);
76
+ try {
77
+ if (!config.enabled) {
78
+ ctx.ui.setWorkingMessage(undefined);
79
+ ctx.ui.setStatus(COCKPIT_STATUS_KEY, undefined);
80
+ return;
81
+ }
82
+ const state: AmbientState = {
83
+ todos: todos.snapshot(),
84
+ agents: agents.snapshot(),
85
+ jobs: bashBg.snapshot(),
86
+ running,
87
+ cwd: formatCwd(ctx.sessionManager.getCwd()),
88
+ activeTool: [...activeTools.values()].at(-1),
89
+ };
90
+ ctx.ui.setWorkingMessage(workingMessage(state));
91
+ ctx.ui.setTitle(titleFor(state, { ok: g.check, fail: g.cross }));
92
+ ctx.ui.setStatus(COCKPIT_STATUS_KEY, statusText(configProblem, g.blocked));
93
+ } catch {
94
+ // ambient surfaces are best-effort; never let them break a render
95
+ }
96
+ };
97
+
98
+ const req = (): void => {
99
+ refreshAmbient();
100
+ try {
101
+ capturedTui?.requestRender();
102
+ } catch {
103
+ // tui may be gone between sessions
104
+ }
105
+ };
106
+ // True only while a redraw loop is actually running; widgets use it to avoid
107
+ // painting a frozen spinner frame that reads as a hung UI.
108
+ const isAnimating = (): boolean => tick !== undefined && (running || bashBg.hasActive());
109
+ // A failed agent lingers for a while after it completes, and the loop has to
110
+ // outlive the session that produced it — otherwise the row would sit there
111
+ // until some unrelated event happened to expire it.
112
+ const needsTick = (): boolean => running || bashBg.hasActive() || agents.hasLingering();
113
+ const startTick = (): void => {
114
+ if (tick) return;
115
+ tick = setInterval(() => {
116
+ if (needsTick()) req();
117
+ else syncTick();
118
+ }, ANIMATION_PERIOD_MS);
119
+ tick.unref?.();
120
+ };
121
+ const syncTick = (): void => {
122
+ if (needsTick()) startTick();
123
+ else stopTick();
124
+ };
125
+ const stopTick = (): void => {
126
+ if (tick) {
127
+ clearInterval(tick);
128
+ tick = undefined;
129
+ }
130
+ };
131
+ const publishUiOwnership = (): void => {
132
+ pi.events.emit(COCKPIT_UI_OWNERSHIP_EVENT, {
133
+ todo: config.enabled,
134
+ agents: config.enabled && config.hideNativeAgents,
135
+ todoExpanded: config.todoExpanded,
136
+ });
137
+ };
138
+ const setTodoExpanded = (expanded: boolean): void => {
139
+ if (config.todoExpanded === expanded) return;
140
+ config = { ...config, todoExpanded: expanded };
141
+ saveConfig(config);
142
+ publishUiOwnership();
143
+ req();
144
+ };
145
+
146
+ const uninstallUi = (ctx: ExtensionContext): void => {
147
+ ctx.ui.setWidget(STACK_WIDGET_KEY, undefined);
148
+ ctx.ui.setWidget(AGENT_WIDGET_KEY, undefined);
149
+ ctx.ui.setFooter(undefined);
150
+ try {
151
+ // Leaving these set would strand a title and a status line owned by an
152
+ // extension that is no longer painting anything.
153
+ ctx.ui.setWorkingMessage(undefined);
154
+ ctx.ui.setStatus(COCKPIT_STATUS_KEY, undefined);
155
+ } catch {
156
+ // ambient surfaces are best-effort
157
+ }
158
+ stopTick();
159
+ capturedTui = undefined;
160
+ };
161
+
162
+ const applyUi = (ctx: ExtensionContext): void => {
163
+ if (!isTuiContext(ctx)) return;
164
+ if (!config.enabled) {
165
+ uninstallUi(ctx);
166
+ return;
167
+ }
168
+ ctx.ui.setWidget(
169
+ STACK_WIDGET_KEY,
170
+ (tui, theme) => {
171
+ capturedTui = tui;
172
+ return makeTodoWidget({
173
+ getTodos: () => todos.snapshot(),
174
+ getConfig: () => config,
175
+ isAnimating,
176
+ })(tui, theme);
177
+ },
178
+ { placement: "aboveEditor" },
179
+ );
180
+ ctx.ui.setWidget(
181
+ AGENT_WIDGET_KEY,
182
+ (tui, theme) => {
183
+ capturedTui = tui;
184
+ return makeAgentWidget({
185
+ getAgents: () => agents.snapshot(),
186
+ getBashBgJobs: () => bashBg.snapshot(),
187
+ getConfig: () => config,
188
+ isRunning: () => running,
189
+ isAnimating,
190
+ })(tui, theme);
191
+ },
192
+ { placement: "belowEditor" },
193
+ );
194
+ // Re-enabling mid-run reinstalls the widgets but used to leave the ticker
195
+ // stopped, freezing every spinner and elapsed counter until some unrelated
196
+ // event happened to trigger a redraw.
197
+ syncTick();
198
+ ctx.ui.setFooter((tui, theme, footerData) => {
199
+ capturedTui = tui;
200
+ let observedWidth = tui.terminal.columns;
201
+ const widthTimer = setInterval(() => {
202
+ const nextWidth = tui.terminal.columns;
203
+ if (!Number.isFinite(nextWidth) || nextWidth <= 0 || nextWidth === observedWidth) return;
204
+ observedWidth = nextWidth;
205
+ tui.invalidate();
206
+ tui.requestRender(true);
207
+ }, WIDTH_POLL_INTERVAL_MS);
208
+ widthTimer.unref?.();
209
+ const unsubscribeBranch = footerData.onBranchChange(() => tui.requestRender());
210
+ const component = {
211
+ render(width: number): string[] {
212
+ if (Number.isFinite(width) && width > 0) observedWidth = width;
213
+ const cu = ctx.getContextUsage();
214
+ const branch = footerData.getGitBranch();
215
+ const extensionStatuses = collectExtensionStatuses(footerData.getExtensionStatuses());
216
+ return renderFooter({
217
+ width,
218
+ model: ctx.model?.id ?? "no-model",
219
+ provider: ctx.model?.provider,
220
+ thinking: pi.getThinkingLevel(),
221
+ cwd: formatCwd(ctx.sessionManager.getCwd()),
222
+ ctxPct: cu?.percent ?? 0,
223
+ ctxTokens: cu?.tokens ?? 0,
224
+ ctxWindow: cu?.contextWindow ?? ctx.model?.contextWindow ?? 0,
225
+ totals: getUsageTotals(ctx.sessionManager.getEntries()),
226
+ git: branch ?? undefined,
227
+ // The Agents header one line above already states the roster and
228
+ // the failure count. Repeating it here spent a footer segment to
229
+ // say nothing new, and the two could disagree mid-update.
230
+ workflowStatus: extensionStatuses.find((status) => status.key === WORKFLOW_STATUS_KEY)?.text,
231
+ extensionStatuses: extensionStatuses.filter((status) => status.key !== WORKFLOW_STATUS_KEY),
232
+ glyphs: resolveGlyphs(config.icons.mode),
233
+ theme,
234
+ utils: FOOTER_UTILS,
235
+ });
236
+ },
237
+ invalidate(): void {},
238
+ dispose(): void {
239
+ clearInterval(widthTimer);
240
+ unsubscribeBranch();
241
+ },
242
+ };
243
+ return component;
244
+ });
245
+ };
246
+
247
+ // --- teammate lifecycle (custom event bus; subscribed once for the extension lifetime) ---
248
+ pi.events.on(TEAMMATE_STARTED_EVENT, (d) => {
249
+ agents.applyStarted(d as StartedPayload);
250
+ req();
251
+ });
252
+ pi.events.on(TEAMMATE_MESSAGE_EVENT, (d) => {
253
+ agents.applyMessage(d as MessagePayload);
254
+ req();
255
+ });
256
+ pi.events.on(TEAMMATE_COMPLETE_EVENT, (d) => {
257
+ agents.applyComplete(d as CompletePayload);
258
+ // A failure that arrives after the session went idle still needs a loop to
259
+ // expire it, so the tick is re-evaluated rather than assumed to be running.
260
+ syncTick();
261
+ req();
262
+ });
263
+ pi.events.on(BASH_BG_UPDATE_EVENT, (payload) => {
264
+ if (!bashBg.applySnapshot(payload)) return;
265
+ syncTick();
266
+ req();
267
+ });
268
+ pi.events.on(COCKPIT_TODO_TOGGLE_EVENT, (payload) => {
269
+ if (!config.enabled) return;
270
+ const requested = payload && typeof payload === "object"
271
+ ? (payload as { expanded?: unknown }).expanded
272
+ : undefined;
273
+ setTodoExpanded(typeof requested === "boolean" ? requested : !config.todoExpanded);
274
+ });
275
+
276
+ // --- foreground tool label + todo changes ---
277
+ pi.on("tool_execution_start", (e) => {
278
+ activeTools.set(e.toolCallId, e.toolName);
279
+ req();
280
+ });
281
+ pi.on("tool_execution_end", (e, ctx) => {
282
+ activeTools.delete(e.toolCallId);
283
+ if (e.toolName === TODO_TOOL_NAME) {
284
+ todos.hydrateFromEntries(ctx.sessionManager.getEntries());
285
+ }
286
+ req();
287
+ });
288
+
289
+ // --- session + agent lifecycle ---
290
+ pi.on("session_start", (_e, ctx) => {
291
+ lastCtx = ctx;
292
+ activeTools.clear();
293
+ ensureConfigExists();
294
+ config = loadConfig((m, l) => {
295
+ try {
296
+ ctx.ui.notify(m, l);
297
+ } catch {
298
+ // notify unavailable
299
+ }
300
+ });
301
+ invalidateUsageCache();
302
+ // The theme is deliberately NOT re-applied here. ctx.ui.setTheme writes
303
+ // through to pi's own settings, so pi already restores the user's choice on
304
+ // its own. Replaying cockpit's copy would overwrite whatever the user set
305
+ // through /settings since — including an automatic "light/dark" pair, which
306
+ // cockpit cannot represent and would silently flatten to a single theme.
307
+ todos.hydrateFromEntries(ctx.sessionManager.getEntries());
308
+ applyUi(ctx);
309
+ publishUiOwnership();
310
+ pi.events.emit(BASH_BG_QUERY_EVENT, undefined);
311
+ req();
312
+ });
313
+
314
+ pi.on("session_shutdown", (_e, ctx) => {
315
+ if (lastCtx) uninstallUi(lastCtx);
316
+ pi.events.emit(COCKPIT_UI_OWNERSHIP_EVENT, {
317
+ todo: false,
318
+ agents: false,
319
+ todoExpanded: config.todoExpanded,
320
+ });
321
+ lastCtx = undefined;
322
+ running = false;
323
+ activeTools.clear();
324
+ invalidateUsageCache();
325
+ agents.clear();
326
+ bashBg.clear();
327
+ });
328
+
329
+ pi.on("agent_start", () => {
330
+ running = true;
331
+ startTick();
332
+ req();
333
+ });
334
+ pi.on("agent_end", () => {
335
+ running = false;
336
+ activeTools.clear();
337
+ syncTick();
338
+ req();
339
+ });
340
+
341
+ // --- redraw triggers for the footer's live data ---
342
+ pi.on("message_end", (_e, ctx) => {
343
+ invalidateUsageCache();
344
+ if (isTuiContext(ctx)) req();
345
+ });
346
+ pi.on("model_select", (_e, ctx) => {
347
+ if (isTuiContext(ctx)) req();
348
+ });
349
+ pi.on("session_compact", (_e, ctx) => {
350
+ invalidateUsageCache();
351
+ if (isTuiContext(ctx)) req();
352
+ });
353
+
354
+ const openBashBgOverlay = async (ctx: ExtensionContext): Promise<void> => {
355
+ if (!ctx.hasUI) return;
356
+ await ctx.ui.custom<void>((tui, theme, _kb, done) =>
357
+ new BashBgOverlay({
358
+ getJobs: () => bashBg.snapshot(),
359
+ requestRender: () => tui.requestRender(),
360
+ requestRefresh: () => pi.events.emit(BASH_BG_QUERY_EVENT, undefined),
361
+ close: () => done(undefined),
362
+ theme,
363
+ glyphs: resolveGlyphs(config.icons.mode),
364
+ getTerminalRows: () => terminalRows(tui),
365
+ }), {
366
+ overlay: true,
367
+ overlayOptions: { anchor: "center", width: "92%", maxHeight: "90%" },
368
+ });
369
+ };
370
+
371
+ // --- /theme: pi ships no command for this; themes live under /settings ---
372
+ const makeThemePicker = (
373
+ ctx: ExtensionContext,
374
+ tui: TUI,
375
+ theme: Theme,
376
+ close: () => void,
377
+ ): ThemePicker => {
378
+ const active = activeThemeName(theme);
379
+ return new ThemePicker({
380
+ themes: ctx.ui.getAllThemes().map((t) => t.name),
381
+ // Cockpit's own record is only a fallback: pi's live theme is the truth,
382
+ // and /settings can have changed it since cockpit last wrote anything.
383
+ initial: active ?? config.theme,
384
+ // Resolve a real instance by name. Passing `theme` straight through would
385
+ // store the Proxy into the very slot the Proxy reads from, and the next
386
+ // colour lookup would recurse until the stack blew.
387
+ original: active ? ctx.ui.getTheme(active) : undefined,
388
+ loadTheme: (name) => ctx.ui.getTheme(name),
389
+ // Instance form: applies in memory without writing through to pi's
390
+ // settings, so scrolling the whole list costs the user nothing.
391
+ previewTheme: (instance) => { ctx.ui.setTheme(instance); },
392
+ // Name form: the one call that persists.
393
+ commitTheme: (name) => {
394
+ const applied = ctx.ui.setTheme(name);
395
+ if (applied.success) {
396
+ config = { ...config, theme: name };
397
+ saveConfig(config);
398
+ }
399
+ return applied;
400
+ },
401
+ close,
402
+ requestRender: () => tui.requestRender(),
403
+ getTerminalRows: () => terminalRows(tui),
404
+ theme,
405
+ glyphs: resolveGlyphs(config.icons.mode),
406
+ });
407
+ };
408
+
409
+ const openThemePicker = async (ctx: ExtensionContext): Promise<void> => {
410
+ if (!ctx.hasUI) return;
411
+ await ctx.ui.custom<void>((tui, theme, _kb, done) =>
412
+ makeThemePicker(ctx, tui, theme, () => done(undefined)), {
413
+ overlay: true,
414
+ overlayOptions: { anchor: "center", width: "60%", maxHeight: "90%" },
415
+ });
416
+ };
417
+
418
+ // pi has no /theme command — the built-in picker is a submenu of /settings.
419
+ // This is the shortcut, not a replacement: /settings still owns the automatic
420
+ // light/dark pairing, which a flat list of names cannot express.
421
+ pi.registerCommand("theme", {
422
+ description: "Switch theme — /theme picks with live preview, /theme <name> applies directly",
423
+ getArgumentCompletions: (prefix) => {
424
+ const query = prefix.trim().toLowerCase();
425
+ const ctx = lastCtx;
426
+ if (!ctx || !ctx.hasUI) return null;
427
+ const matches = ctx.ui.getAllThemes()
428
+ .map((t) => t.name)
429
+ .filter((name) => name.toLowerCase().includes(query));
430
+ return matches.length > 0 ? matches.map((name) => ({ value: name, label: name })) : null;
431
+ },
432
+ handler: async (args, ctx) => {
433
+ if (!ctx.hasUI) return;
434
+ const name = args.trim();
435
+ if (name === "") {
436
+ await openThemePicker(ctx);
437
+ return;
438
+ }
439
+ const applied = ctx.ui.setTheme(name);
440
+ if (!applied.success) {
441
+ ctx.ui.notify(`theme "${name}" unavailable — ${applied.error ?? "not found"}`, "warning");
442
+ return;
443
+ }
444
+ config = { ...config, theme: name };
445
+ saveConfig(config);
446
+ ctx.ui.notify(`theme: ${name}`, "info");
447
+ },
448
+ });
449
+
450
+ pi.registerShortcut(BASH_BG_OVERLAY_KEY, {
451
+ description: "Open background Bash jobs — live status, command, cwd, duration and output tail",
452
+ async handler(ctx) {
453
+ await openBashBgOverlay(ctx);
454
+ },
455
+ });
456
+
457
+ // --- /cockpit: toggle list/compact + enabled + hide-native ---
458
+ pi.registerCommand("cockpit", {
459
+ description: "Open pi-cockpit settings; use /cockpit bg for background Bash job details",
460
+ handler: async (args, ctx) => {
461
+ if (!ctx.hasUI) return;
462
+ const action = args.trim().toLowerCase();
463
+ if (action === "bg" || action === "jobs") {
464
+ await openBashBgOverlay(ctx);
465
+ return;
466
+ }
467
+ if (action === "todo" || action === "todo toggle") {
468
+ setTodoExpanded(!config.todoExpanded);
469
+ ctx.ui.notify(`TODO ${config.todoExpanded ? "expanded" : "collapsed"}`, "info");
470
+ return;
471
+ }
472
+ if (action === "todo expand" || action === "todo collapse") {
473
+ setTodoExpanded(action.endsWith("expand"));
474
+ return;
475
+ }
476
+ await openSettings(ctx);
477
+ },
478
+ });
479
+
480
+ const openSettings = async (ctx: ExtensionCommandContext): Promise<void> =>
481
+ ctx.ui.custom<void>((tui, theme, _kb, done) => {
482
+ let settingsCursor = 0;
483
+ let saveState: SaveState = { kind: "idle" };
484
+ // The theme row expands in place rather than closing the panel and opening
485
+ // a second overlay: pi's `theme` is a live Proxy, so both surfaces repaint
486
+ // in the previewed colours, and Esc lands back on the row it was invoked
487
+ // from instead of on a panel that looks freshly opened.
488
+ let sub: ThemePicker | undefined;
489
+ const closeSub = (): void => {
490
+ sub?.dispose();
491
+ sub = undefined;
492
+ tui.requestRender();
493
+ };
494
+ const apply = (key: string): void => {
495
+ const wasEnabled = config.enabled;
496
+ if (key === "theme") {
497
+ // Delegate: the picker previews live and reverts on Esc, neither of
498
+ // which a blind one-key cycle through the name list can do.
499
+ sub = makeThemePicker(ctx, tui, theme, closeSub);
500
+ tui.requestRender();
501
+ return;
502
+ }
503
+ config = applyRow(config, key);
504
+ saveState = { kind: "saving" };
505
+ const result = saveConfig(config);
506
+ // The panel now reports what actually happened instead of showing
507
+ // an optimistic value for a write that may never have landed.
508
+ saveState = result.ok
509
+ ? { kind: "saved" }
510
+ : { kind: "failed", message: result.error ?? "unknown error" };
511
+ if (wasEnabled !== config.enabled) {
512
+ if (config.enabled) applyUi(ctx);
513
+ else uninstallUi(ctx);
514
+ }
515
+ publishUiOwnership();
516
+ req();
517
+ };
518
+ const ui = {
519
+ render(width: number): string[] {
520
+ // The sub-view takes the whole card. It carries its own title and
521
+ // key hints, so stacking the panel's chrome above it would spend two
522
+ // rows saying nothing the picker does not already say.
523
+ if (sub) return sub.render(width);
524
+ const paint: PaintTheme = theme;
525
+ const rows = buildRows(config);
526
+ settingsCursor = Math.max(0, Math.min(settingsCursor, rows.length - 1));
527
+ const w = Math.min(width, 52);
528
+ const labelWidth = Math.max(...rows.map((r) => visibleWidth(r.label)));
529
+ const lines = [
530
+ paint.fg("text", "pi-cockpit"),
531
+ paint.fg("borderMuted", "─".repeat(w)),
532
+ ];
533
+ rows.forEach((row, index) => {
534
+ const selected = index === settingsCursor;
535
+ const marker = selected ? paint.fg("accent", "›") : " ";
536
+ const pad = " ".repeat(Math.max(0, labelWidth - visibleWidth(row.label)));
537
+ const label = paint.fg(selected ? "text" : "muted", row.label) + pad;
538
+ const value = paint.fg("accent", row.value);
539
+ // Showing the next value makes the cycle visible instead of
540
+ // something the user has to discover by pressing and watching.
541
+ const hint = selected ? paint.fg("dim", ` → ${row.next}`) : "";
542
+ lines.push(`${marker} ${paint.fg("dim", row.accel)} ${label} ${value}${hint}`);
543
+ });
544
+ lines.push("");
545
+ if (saveState.kind === "saved") lines.push(paint.fg("success", "✓ saved"));
546
+ else if (saveState.kind === "saving") lines.push(paint.fg("dim", "· saving…"));
547
+ else if (saveState.kind === "failed") {
548
+ lines.push(paint.fg("error", `✗ save failed — ${saveState.message}`));
549
+ // Scoped to cockpit's own rows: the theme is applied through pi,
550
+ // which persists it regardless of whether this file was written.
551
+ lines.push(paint.fg("dim", "cockpit rows apply for this session only"));
552
+ }
553
+ // The theme row opens /theme; /settings additionally pairs a light
554
+ // and a dark theme, which neither of cockpit's surfaces can express.
555
+ lines.push(paint.fg("dim", "theme is stored by pi · /settings pairs light+dark"));
556
+ lines.push(paint.fg("dim", "↑↓ move · Enter change · letter jumps · Esc close"));
557
+ return lines.map((line) => truncateToWidth(line, width, "…"));
558
+ },
559
+ invalidate(): void {},
560
+ handleInput(data: string): void {
561
+ // While expanded the sub-view owns every key, Esc included — that is
562
+ // what makes Esc step back to the panel instead of dismissing both.
563
+ if (sub) {
564
+ sub.handleInput(data);
565
+ tui.requestRender();
566
+ return;
567
+ }
568
+ const rows = buildRows(config);
569
+ if (matchesKey(data, Key.escape)) {
570
+ done(undefined);
571
+ return;
572
+ }
573
+ if (data === "\x1b[A" || data === "\x1b[B") {
574
+ const delta = data === "\x1b[A" ? -1 : 1;
575
+ settingsCursor = (settingsCursor + delta + rows.length) % rows.length;
576
+ } else if (data === "\r" || data === "\n" || data === " ") {
577
+ apply(rows[settingsCursor].key);
578
+ } else {
579
+ const key = rowKeyForAccel(rows, data);
580
+ if (!key) return;
581
+ settingsCursor = rows.findIndex((row) => row.key === key);
582
+ apply(key);
583
+ }
584
+ tui.requestRender();
585
+ },
586
+ dispose(): void {
587
+ // Closing the panel while expanded must not strand a previewed theme
588
+ // with no way back, so route through the picker's own cancel path.
589
+ sub?.handleInput("\x1b");
590
+ sub?.dispose();
591
+ },
592
+ };
593
+ return ui;
594
+ }, { overlay: true });
595
+ }