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/README.md +87 -0
- package/package.json +70 -0
- package/src/agents-store.ts +270 -0
- package/src/ambient.ts +72 -0
- package/src/bash-bg-overlay.ts +362 -0
- package/src/bash-bg-store.ts +90 -0
- package/src/bash-bg-widget.ts +70 -0
- package/src/config.ts +86 -0
- package/src/extension/index.ts +1 -0
- package/src/extension-status.ts +25 -0
- package/src/footer.ts +354 -0
- package/src/icons.ts +166 -0
- package/src/index.ts +595 -0
- package/src/layout.ts +129 -0
- package/src/render.ts +441 -0
- package/src/settings-view.ts +129 -0
- package/src/stack-widget.ts +121 -0
- package/src/theme-picker.ts +255 -0
- package/src/todo-store.ts +123 -0
- package/src/types.ts +112 -0
- package/src/viewport.ts +62 -0
- package/themes/cockpit-amber.json +24 -0
- package/themes/cockpit-minimal-amber.json +23 -0
- package/themes/cockpit-minimal-cyan.json +23 -0
- package/themes/cockpit-minimal-green.json +23 -0
- package/themes/cockpit-minimal-purple.json +23 -0
- package/themes/cockpit-minimal-rose.json +23 -0
- package/themes/cockpit-minimal.json +23 -0
- package/themes/cockpit-notion.json +24 -0
- package/themes/cockpit-ocean.json +24 -0
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
|
+
}
|