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
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
type Component,
|
|
4
|
+
type Focusable,
|
|
5
|
+
truncateToWidth,
|
|
6
|
+
visibleWidth,
|
|
7
|
+
wrapTextWithAnsi,
|
|
8
|
+
} from "@earendil-works/pi-tui";
|
|
9
|
+
import { sanitizeExtensionStatusText } from "./extension-status.ts";
|
|
10
|
+
import type { IconGlyphs } from "./icons.ts";
|
|
11
|
+
import { fitLineByPriority, visibleStart, type PrioritizedSegment, type WidthUtils } from "./layout.ts";
|
|
12
|
+
import { formatDuration } from "./render.ts";
|
|
13
|
+
import type { BashBgJob, BashBgStatus } from "./types.ts";
|
|
14
|
+
import { overlayListRows } from "./viewport.ts";
|
|
15
|
+
|
|
16
|
+
const WIDTH_UTILS: WidthUtils = { measure: visibleWidth, clip: truncateToWidth };
|
|
17
|
+
|
|
18
|
+
export interface BashBgOverlayParams {
|
|
19
|
+
getJobs: () => readonly BashBgJob[];
|
|
20
|
+
requestRender: () => void;
|
|
21
|
+
requestRefresh: () => void;
|
|
22
|
+
close: () => void;
|
|
23
|
+
theme: Theme;
|
|
24
|
+
glyphs: IconGlyphs;
|
|
25
|
+
/** Live terminal height, so the card can use the space it already reserves. */
|
|
26
|
+
getTerminalRows?: () => number | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Rows the card spends on itself: two borders, header, separator, help line.
|
|
30
|
+
const CARD_CHROME_ROWS = 5;
|
|
31
|
+
|
|
32
|
+
// How long the header keeps acknowledging a manual refresh. The snapshot arrives
|
|
33
|
+
// asynchronously, so without this the keypress looks like it did nothing.
|
|
34
|
+
const REFRESH_ACK_MS = 1_500;
|
|
35
|
+
|
|
36
|
+
type BashBgOverlayMode = "list" | "detail";
|
|
37
|
+
|
|
38
|
+
export class BashBgOverlay implements Component, Focusable {
|
|
39
|
+
focused = false;
|
|
40
|
+
private mode: BashBgOverlayMode = "list";
|
|
41
|
+
private selected = 0;
|
|
42
|
+
private selectedId: string | undefined;
|
|
43
|
+
private refreshedAt = 0;
|
|
44
|
+
|
|
45
|
+
constructor(private readonly params: BashBgOverlayParams) {}
|
|
46
|
+
|
|
47
|
+
invalidate(): void {}
|
|
48
|
+
dispose(): void {}
|
|
49
|
+
|
|
50
|
+
handleInput(data: string): void {
|
|
51
|
+
if (data === "\x1b") {
|
|
52
|
+
if (this.mode === "detail") this.mode = "list";
|
|
53
|
+
else this.params.close();
|
|
54
|
+
this.params.requestRender();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
// Plain letters stay reserved for future filter/command modes, so navigation
|
|
58
|
+
// and refresh use arrows and a non-letter key only.
|
|
59
|
+
if (data === "\x1b[A") {
|
|
60
|
+
this.move(-1);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (data === "\x1b[B") {
|
|
64
|
+
this.move(1);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
if (data === "\r" || data === "\n") {
|
|
68
|
+
if (this.selectedJob()) this.mode = this.mode === "detail" ? "list" : "detail";
|
|
69
|
+
this.params.requestRender();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (data === "\x12" || data === "\x1b[15~") {
|
|
73
|
+
// Ctrl+R / F5 — refresh is acknowledged immediately even though the
|
|
74
|
+
// authoritative snapshot only arrives later.
|
|
75
|
+
this.refreshedAt = Date.now();
|
|
76
|
+
this.params.requestRefresh();
|
|
77
|
+
this.params.requestRender();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
render(width: number): string[] {
|
|
82
|
+
const safeWidth = Math.max(1, Math.min(width, 140));
|
|
83
|
+
this.clampState();
|
|
84
|
+
if (safeWidth < 20) return [this.renderCompact(safeWidth)];
|
|
85
|
+
if (this.mode === "detail") return this.renderDetail(safeWidth);
|
|
86
|
+
if (safeWidth < 88) return this.renderList(safeWidth);
|
|
87
|
+
return this.renderWide(safeWidth);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private renderCompact(width: number): string {
|
|
91
|
+
const job = this.selectedJob();
|
|
92
|
+
const text = job ? `${jobVisual(job.status, this.params.glyphs).glyph} ${this.orderOf(job)} ${job.status}${this.params.glyphs.separator}Esc` : `BG${this.params.glyphs.separator}none${this.params.glyphs.separator}Esc`;
|
|
93
|
+
return this.params.theme.bg("customMessageBg", pad(text, width));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// How many job rows the card can show before it has to start paging.
|
|
97
|
+
private listRows(): number {
|
|
98
|
+
return overlayListRows(this.params.getTerminalRows?.(), CARD_CHROME_ROWS);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private renderList(width: number): string[] {
|
|
102
|
+
const inner = width - 2;
|
|
103
|
+
const jobs = this.jobs();
|
|
104
|
+
const rows = [this.header(inner), this.separator(inner)];
|
|
105
|
+
const selectedRows = new Set<number>();
|
|
106
|
+
if (jobs.length === 0) {
|
|
107
|
+
rows.push(fitLine(this.emptyState(), inner));
|
|
108
|
+
} else {
|
|
109
|
+
const page = this.listRows();
|
|
110
|
+
const start = visibleStart(this.selected, jobs.length, page);
|
|
111
|
+
for (let index = start; index < Math.min(jobs.length, start + page); index++) {
|
|
112
|
+
if (index === this.selected) selectedRows.add(rows.length);
|
|
113
|
+
rows.push(this.jobRow(jobs[index], index === this.selected, inner));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
rows.push(this.helpLine(inner));
|
|
117
|
+
return this.card(rows, width, selectedRows);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private renderWide(width: number): string[] {
|
|
121
|
+
const inner = width - 2;
|
|
122
|
+
const leftWidth = Math.max(32, Math.floor((inner - 3) * 0.42));
|
|
123
|
+
const rightWidth = inner - leftWidth - 3;
|
|
124
|
+
const jobs = this.jobs();
|
|
125
|
+
const page = this.listRows();
|
|
126
|
+
const start = visibleStart(this.selected, jobs.length, page);
|
|
127
|
+
const left = jobs.slice(start, start + page).map((job, offset) =>
|
|
128
|
+
this.jobRow(job, start + offset === this.selected, leftWidth));
|
|
129
|
+
// The detail pane grows with the list so a tall card is not half empty, but
|
|
130
|
+
// the output tail keeps the larger share — that is what the pane is for.
|
|
131
|
+
const right = this.detailLines(this.selectedJob(), rightWidth, 2, Math.max(5, page - 3));
|
|
132
|
+
const rowCount = Math.max(left.length, right.length, 1);
|
|
133
|
+
const rows = [this.header(inner), this.separator(inner)];
|
|
134
|
+
const selectedRows = new Set<number>();
|
|
135
|
+
for (let index = 0; index < rowCount; index++) {
|
|
136
|
+
if (jobs.length > 0 && start + index === this.selected) selectedRows.add(rows.length);
|
|
137
|
+
rows.push(`${pad(left[index] ?? "", leftWidth)} ${this.params.glyphs.box.vertical} ${pad(right[index] ?? "", rightWidth)}`);
|
|
138
|
+
}
|
|
139
|
+
rows.push(this.helpLine(inner));
|
|
140
|
+
return this.card(rows, width, selectedRows);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private renderDetail(width: number): string[] {
|
|
144
|
+
const inner = width - 2;
|
|
145
|
+
const rows = [
|
|
146
|
+
this.header(inner),
|
|
147
|
+
this.separator(inner),
|
|
148
|
+
...this.detailLines(this.selectedJob(), inner, 8, 18),
|
|
149
|
+
this.helpLine(inner, ["Esc back", `${this.params.glyphs.upDown} job`, "Ctrl+R refresh"]),
|
|
150
|
+
];
|
|
151
|
+
return this.card(rows, width);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private jobRow(job: BashBgJob, selected: boolean, width: number): string {
|
|
155
|
+
const visual = jobVisual(job.status, this.params.glyphs);
|
|
156
|
+
const duration = formatDuration((job.finishedAt ?? Date.now()) - job.startedAt);
|
|
157
|
+
const sep = this.params.glyphs.separator;
|
|
158
|
+
const exit = job.exitCode === null ? "" : `${sep}exit ${job.exitCode}`;
|
|
159
|
+
return fitLine(
|
|
160
|
+
`${selected ? this.params.glyphs.selectMarker : " "} ${this.params.theme.fg(visual.color, visual.glyph)} `
|
|
161
|
+
+ `${this.orderOf(job)}${sep}${job.status}${sep}${duration}${exit}${sep}${oneLine(job.command)}`,
|
|
162
|
+
width,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private detailLines(job: BashBgJob | undefined, width: number, commandMax: number, outputMax: number): string[] {
|
|
167
|
+
if (!job) return [fitLine(this.emptyState(), width)];
|
|
168
|
+
const visual = jobVisual(job.status, this.params.glyphs);
|
|
169
|
+
const theme = this.params.theme;
|
|
170
|
+
const lines: string[] = [
|
|
171
|
+
fitLine(`${theme.fg(visual.color, theme.bold(`${visual.glyph} ${job.id}`))}${this.params.glyphs.separator}${job.status}`, width),
|
|
172
|
+
field("PID", String(job.pid), width),
|
|
173
|
+
field("Duration", formatDuration((job.finishedAt ?? Date.now()) - job.startedAt), width),
|
|
174
|
+
field("Started", formatTimestamp(job.startedAt), width),
|
|
175
|
+
field("Updated", formatTimestamp(job.updatedAt), width),
|
|
176
|
+
field("Exit", job.exitCode === null ? this.params.glyphs.dotIdle : String(job.exitCode), width),
|
|
177
|
+
field("Output", formatBytes(job.outputBytes), width),
|
|
178
|
+
field("CWD", job.cwd, width),
|
|
179
|
+
field("Log", job.logPath, width),
|
|
180
|
+
fitLine(theme.fg("dim", "Command"), width),
|
|
181
|
+
...wrappedSlice(oneLine(job.command), width, commandMax, theme),
|
|
182
|
+
fitLine(theme.fg("dim", "Output tail"), width),
|
|
183
|
+
...outputSlice(job.outputTail, width, outputMax, theme),
|
|
184
|
+
];
|
|
185
|
+
return lines;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private emptyState(): string {
|
|
189
|
+
const g = this.params.glyphs;
|
|
190
|
+
return `${g.emptyMark} no background jobs${g.separator}use bash_bg action=start`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private header(width: number): string {
|
|
194
|
+
const jobs = this.jobs();
|
|
195
|
+
const running = jobs.filter((job) => job.status === "running").length;
|
|
196
|
+
const stopping = jobs.filter((job) => job.status === "stopping").length;
|
|
197
|
+
const failed = jobs.filter((job) => job.status === "failed").length;
|
|
198
|
+
const done = jobs.length - running - stopping - failed;
|
|
199
|
+
const theme = this.params.theme;
|
|
200
|
+
const g = this.params.glyphs;
|
|
201
|
+
// Zero-valued states are suppressed ("0 stopping · 0 failed" is filler), but
|
|
202
|
+
// total stays — it is the denominator that gives the other counts meaning.
|
|
203
|
+
// Priorities keep the refresh acknowledgement and the failure count alive on
|
|
204
|
+
// a narrow overlay; without that the ack was clipped off the end and the
|
|
205
|
+
// keypress looked like it did nothing.
|
|
206
|
+
const segs: PrioritizedSegment[] = [
|
|
207
|
+
{ text: "Background jobs", priority: 60, minWidth: 6 },
|
|
208
|
+
{ text: theme.fg("dim", `${jobs.length} total`), priority: 50, clippable: false },
|
|
209
|
+
];
|
|
210
|
+
if (Date.now() - this.refreshedAt < REFRESH_ACK_MS) {
|
|
211
|
+
segs.push({ text: theme.fg("dim", "refreshing…"), priority: 100, clippable: false });
|
|
212
|
+
}
|
|
213
|
+
if (failed) segs.push({ text: theme.fg("error", `${failed} failed`), priority: 90, clippable: false });
|
|
214
|
+
if (stopping) segs.push({ text: theme.fg("warning", `${stopping} stopping`), priority: 80, clippable: false });
|
|
215
|
+
if (running) segs.push({ text: theme.fg("accent", `${running} running`), priority: 70, clippable: false });
|
|
216
|
+
if (done) segs.push({ text: theme.fg("success", `${done} finished`), priority: 40, clippable: false });
|
|
217
|
+
return fitLineByPriority(segs, width, WIDTH_UTILS, g.separator, g.ellipsis);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private helpLine(width: number, segments?: readonly string[]): string {
|
|
221
|
+
const g = this.params.glyphs;
|
|
222
|
+
return fitSegments(
|
|
223
|
+
width,
|
|
224
|
+
segments ?? ["Esc close", "Enter detail", `${g.upDown} job`, "Ctrl+R refresh"],
|
|
225
|
+
g.separator,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private separator(width: number): string {
|
|
230
|
+
return this.params.theme.fg("borderMuted", this.params.glyphs.box.horizontal.repeat(Math.max(1, width)));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private card(rows: string[], width: number, selectedRows: ReadonlySet<number> = new Set()): string[] {
|
|
234
|
+
const theme = this.params.theme;
|
|
235
|
+
const box = this.params.glyphs.box;
|
|
236
|
+
const edge = box.horizontal.repeat(Math.max(0, width - 2));
|
|
237
|
+
const border = (glyph: string) => theme.bg("customMessageBg", theme.fg("borderMuted", glyph));
|
|
238
|
+
const out = [border(`${box.topLeft}${edge}${box.topRight}`)];
|
|
239
|
+
rows.forEach((row, index) => {
|
|
240
|
+
out.push(theme.bg(selectedRows.has(index) ? "selectedBg" : "customMessageBg", pad(` ${row}`, width)));
|
|
241
|
+
});
|
|
242
|
+
out.push(border(`${box.bottomLeft}${edge}${box.bottomRight}`));
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private move(delta: number): void {
|
|
247
|
+
this.selected = wrapIndex(this.selected + delta, this.jobs().length);
|
|
248
|
+
this.selectedId = this.jobs()[this.selected]?.id;
|
|
249
|
+
this.params.requestRender();
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private clampState(): void {
|
|
253
|
+
const jobs = this.jobs();
|
|
254
|
+
const preserved = this.selectedId === undefined
|
|
255
|
+
? -1
|
|
256
|
+
: jobs.findIndex((job) => job.id === this.selectedId);
|
|
257
|
+
this.selected = preserved >= 0 ? preserved : clampIndex(this.selected, jobs.length);
|
|
258
|
+
this.selectedId = jobs[this.selected]?.id;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private jobs(): readonly BashBgJob[] {
|
|
262
|
+
return this.params.getJobs();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private selectedJob(): BashBgJob | undefined {
|
|
266
|
+
return this.jobs()[this.selected];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private orderOf(job: BashBgJob): string {
|
|
270
|
+
const jobs = this.jobs();
|
|
271
|
+
return `${jobs.findIndex((candidate) => candidate.id === job.id) + 1}/${jobs.length}`;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Routed through the resolved glyph set so an ascii-only terminal shows ascii
|
|
276
|
+
// rather than tofu. Status text always accompanies the glyph at the call sites,
|
|
277
|
+
// so colour never carries the state on its own.
|
|
278
|
+
function jobVisual(status: BashBgStatus, g: IconGlyphs): { glyph: string; color: ThemeColor } {
|
|
279
|
+
if (status === "running") return { glyph: g.dotRunning, color: "accent" };
|
|
280
|
+
if (status === "stopping") return { glyph: g.blocked, color: "warning" };
|
|
281
|
+
if (status === "completed") return { glyph: g.check, color: "success" };
|
|
282
|
+
if (status === "failed") return { glyph: g.cross, color: "error" };
|
|
283
|
+
return { glyph: g.dotIdle, color: "warning" };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function formatTimestamp(ms: number): string {
|
|
287
|
+
const date = new Date(ms);
|
|
288
|
+
// parseJob only checks Number.isFinite, so an out-of-range timestamp reaches
|
|
289
|
+
// here and would throw RangeError out of the render path.
|
|
290
|
+
return Number.isNaN(date.getTime()) ? "unknown" : date.toISOString();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function wrappedSlice(value: string, width: number, max: number, theme: Theme): string[] {
|
|
294
|
+
const wrapped = wrapTextWithAnsi(value || "(empty)", Math.max(10, width));
|
|
295
|
+
const lines = wrapped.slice(0, max).map((line) => fitLine(line, width));
|
|
296
|
+
if (wrapped.length > max) lines.push(fitLine(theme.fg("dim", `… +${wrapped.length - max} more line(s)`), width));
|
|
297
|
+
return lines;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function outputSlice(output: string, width: number, max: number, theme: Theme): string[] {
|
|
301
|
+
const rawLines = output.replace(/\r/g, "").split("\n").map(sanitizeExtensionStatusText);
|
|
302
|
+
while (rawLines.length > 0 && rawLines.at(-1) === "") rawLines.pop();
|
|
303
|
+
if (rawLines.length === 0) return [fitLine(theme.fg("dim", "(empty)"), width)];
|
|
304
|
+
const wrapped = rawLines.flatMap((line) => wrapTextWithAnsi(line || " ", Math.max(10, width)));
|
|
305
|
+
const omitted = Math.max(0, wrapped.length - max);
|
|
306
|
+
const lines = wrapped.slice(-max).map((line) => fitLine(line, width));
|
|
307
|
+
if (omitted > 0) lines.unshift(fitLine(theme.fg("dim", `… ${omitted} earlier line(s) · full output at log path`), width));
|
|
308
|
+
return lines;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function field(label: string, value: string, width: number): string {
|
|
312
|
+
// pad, not padEnd: the label column must align by display width, so this does
|
|
313
|
+
// not quietly break the day a label stops being pure ASCII.
|
|
314
|
+
return fitLine(`${pad(label, 10)} ${sanitizeExtensionStatusText(value)}`, width);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function formatBytes(bytes: number): string {
|
|
318
|
+
// A long-running job can emit gigabytes; without these tiers 5 GiB rendered
|
|
319
|
+
// as "5120 MiB".
|
|
320
|
+
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
|
|
321
|
+
let value = bytes;
|
|
322
|
+
let unit = 0;
|
|
323
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
324
|
+
value /= 1024;
|
|
325
|
+
unit++;
|
|
326
|
+
}
|
|
327
|
+
return unit === 0 ? `${value} B` : `${trimDecimal(value)} ${units[unit]}`;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function trimDecimal(value: number): string {
|
|
331
|
+
return value >= 10 ? value.toFixed(0) : value.toFixed(1).replace(/\.0$/, "");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function oneLine(value: string): string {
|
|
335
|
+
return sanitizeExtensionStatusText(value);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function wrapIndex(index: number, length: number): number {
|
|
339
|
+
return length === 0 ? 0 : (index + length) % length;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function clampIndex(index: number, length: number): number {
|
|
343
|
+
return length === 0 ? 0 : Math.max(0, Math.min(index, length - 1));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function fitLine(value: string, width: number): string {
|
|
347
|
+
return truncateToWidth(value, Math.max(1, width), "…");
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function fitSegments(width: number, segments: readonly string[], separator: string): string {
|
|
351
|
+
const kept: string[] = [];
|
|
352
|
+
for (const segment of segments) {
|
|
353
|
+
if (visibleWidth([...kept, segment].join(separator)) > width) break;
|
|
354
|
+
kept.push(segment);
|
|
355
|
+
}
|
|
356
|
+
return kept.length ? kept.join(separator) : fitLine(segments[0] ?? "", width);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function pad(value: string, width: number): string {
|
|
360
|
+
const fitted = fitLine(value, width);
|
|
361
|
+
return `${fitted}${" ".repeat(Math.max(0, width - visibleWidth(fitted)))}`;
|
|
362
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { BashBgJob, BashBgStatus } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
const STATUSES = new Set<BashBgStatus>(["running", "stopping", "completed", "failed", "killed"]);
|
|
4
|
+
|
|
5
|
+
export class BashBgStore {
|
|
6
|
+
private jobs = new Map<string, BashBgJob>();
|
|
7
|
+
|
|
8
|
+
applySnapshot(payload: unknown): boolean {
|
|
9
|
+
if (!isRecord(payload) || !Array.isArray(payload.jobs)) return false;
|
|
10
|
+
const next = new Map<string, BashBgJob>();
|
|
11
|
+
for (const value of payload.jobs) {
|
|
12
|
+
const job = parseJob(value);
|
|
13
|
+
if (job) next.set(job.id, job);
|
|
14
|
+
}
|
|
15
|
+
this.jobs = next;
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
snapshot(): BashBgJob[] {
|
|
20
|
+
return [...this.jobs.values()].sort((a, b) =>
|
|
21
|
+
statusRank(a.status) - statusRank(b.status)
|
|
22
|
+
|| b.startedAt - a.startedAt
|
|
23
|
+
|| a.id.localeCompare(b.id));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
hasActive(): boolean {
|
|
27
|
+
for (const job of this.jobs.values()) {
|
|
28
|
+
if (job.status === "running" || job.status === "stopping") return true;
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
clear(): void {
|
|
34
|
+
this.jobs.clear();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseJob(value: unknown): BashBgJob | undefined {
|
|
39
|
+
if (!isRecord(value)) return undefined;
|
|
40
|
+
const status = value.status;
|
|
41
|
+
if (
|
|
42
|
+
typeof value.id !== "string"
|
|
43
|
+
|| !value.id
|
|
44
|
+
|| typeof value.command !== "string"
|
|
45
|
+
|| typeof value.cwd !== "string"
|
|
46
|
+
|| typeof value.pid !== "number"
|
|
47
|
+
|| !Number.isFinite(value.pid)
|
|
48
|
+
|| typeof status !== "string"
|
|
49
|
+
|| !STATUSES.has(status as BashBgStatus)
|
|
50
|
+
|| typeof value.startedAt !== "number"
|
|
51
|
+
|| !Number.isFinite(value.startedAt)
|
|
52
|
+
|| typeof value.updatedAt !== "number"
|
|
53
|
+
|| !Number.isFinite(value.updatedAt)
|
|
54
|
+
|| (value.finishedAt !== undefined && (typeof value.finishedAt !== "number" || !Number.isFinite(value.finishedAt)))
|
|
55
|
+
|| (value.exitCode !== null && (typeof value.exitCode !== "number" || !Number.isFinite(value.exitCode)))
|
|
56
|
+
|| typeof value.outputTail !== "string"
|
|
57
|
+
|| typeof value.outputBytes !== "number"
|
|
58
|
+
|| !Number.isFinite(value.outputBytes)
|
|
59
|
+
|| value.outputBytes < 0
|
|
60
|
+
|| typeof value.logPath !== "string"
|
|
61
|
+
) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
id: value.id,
|
|
66
|
+
command: value.command,
|
|
67
|
+
cwd: value.cwd,
|
|
68
|
+
pid: value.pid,
|
|
69
|
+
status: status as BashBgStatus,
|
|
70
|
+
startedAt: value.startedAt,
|
|
71
|
+
updatedAt: value.updatedAt,
|
|
72
|
+
...(value.finishedAt === undefined ? {} : { finishedAt: value.finishedAt }),
|
|
73
|
+
exitCode: value.exitCode,
|
|
74
|
+
outputTail: value.outputTail,
|
|
75
|
+
outputBytes: value.outputBytes,
|
|
76
|
+
logPath: value.logPath,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function statusRank(status: BashBgStatus): number {
|
|
81
|
+
if (status === "running") return 0;
|
|
82
|
+
if (status === "stopping") return 1;
|
|
83
|
+
if (status === "failed") return 2;
|
|
84
|
+
if (status === "killed") return 3;
|
|
85
|
+
return 4;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
89
|
+
return typeof value === "object" && value !== null;
|
|
90
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { IconGlyphs } from "./icons.ts";
|
|
3
|
+
import { sanitizeExtensionStatusText } from "./extension-status.ts";
|
|
4
|
+
import { fitLineByPriority, type PrioritizedSegment } from "./layout.ts";
|
|
5
|
+
import { formatDuration, type PaintTheme, type WidthUtils } from "./render.ts";
|
|
6
|
+
import type { BashBgJob } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
// Registered by cockpit itself (index.ts), so this hint is always accurate.
|
|
9
|
+
export const DEFAULT_BASH_BG_HINT = "Alt+J";
|
|
10
|
+
|
|
11
|
+
export interface BashBgRenderOptions {
|
|
12
|
+
glyphs: IconGlyphs;
|
|
13
|
+
spin: string;
|
|
14
|
+
now: number;
|
|
15
|
+
/** Overrides the advertised overlay shortcut when the binding differs. */
|
|
16
|
+
hint?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function renderBashBgSummary(
|
|
20
|
+
jobs: readonly BashBgJob[],
|
|
21
|
+
width: number,
|
|
22
|
+
theme: PaintTheme,
|
|
23
|
+
utils: WidthUtils,
|
|
24
|
+
options: BashBgRenderOptions,
|
|
25
|
+
): string[] {
|
|
26
|
+
if (width < 1) return [];
|
|
27
|
+
const active = jobs.filter((job) => job.status === "running" || job.status === "stopping");
|
|
28
|
+
if (active.length === 0) return [];
|
|
29
|
+
const current = active[0];
|
|
30
|
+
const stopping = active.filter((job) => job.status === "stopping").length;
|
|
31
|
+
const running = active.length - stopping;
|
|
32
|
+
const counts = [
|
|
33
|
+
running ? `${running} running` : "",
|
|
34
|
+
stopping ? `${stopping} stopping` : "",
|
|
35
|
+
].filter(Boolean).join(" · ");
|
|
36
|
+
const statusColor: ThemeColor = stopping > 0 ? "warning" : "accent";
|
|
37
|
+
const elapsed = formatDuration(options.now - current.startedAt);
|
|
38
|
+
const preview = lastOutputLine(current.outputTail);
|
|
39
|
+
const g = options.glyphs;
|
|
40
|
+
// The Alt+J hint is the only advertised route into the job detail overlay, so
|
|
41
|
+
// it outranks the command preview instead of being the first thing clipped.
|
|
42
|
+
const segments: PrioritizedSegment[] = [
|
|
43
|
+
{
|
|
44
|
+
text: theme.fg(statusColor, current.status === "stopping" ? g.blocked : options.spin),
|
|
45
|
+
priority: 100,
|
|
46
|
+
clippable: false,
|
|
47
|
+
},
|
|
48
|
+
{ text: theme.fg("muted", "BG"), priority: 95, clippable: false },
|
|
49
|
+
{ text: theme.fg(statusColor, counts), priority: 90, clippable: false },
|
|
50
|
+
{ text: theme.fg("text", oneLine(current.command)), priority: 70, minWidth: 8 },
|
|
51
|
+
{ text: theme.fg("dim", `${options.hint ?? DEFAULT_BASH_BG_HINT} details`), priority: 60, clippable: false },
|
|
52
|
+
{ text: theme.fg("dim", elapsed), priority: 50, clippable: false },
|
|
53
|
+
{ text: theme.fg("mdLink", current.id), priority: 30, clippable: false },
|
|
54
|
+
];
|
|
55
|
+
if (preview) segments.push({ text: theme.fg("dim", `${g.arrow} ${preview}`), priority: 20, minWidth: 8 });
|
|
56
|
+
return [fitLineByPriority(segments, width, utils, theme.fg("dim", g.separator), g.ellipsis)];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function lastOutputLine(output: string): string {
|
|
60
|
+
const lines = output.replace(/\r/g, "").split("\n");
|
|
61
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
62
|
+
const line = lines[index]?.trim();
|
|
63
|
+
if (line) return oneLine(line);
|
|
64
|
+
}
|
|
65
|
+
return "";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function oneLine(value: string): string {
|
|
69
|
+
return sanitizeExtensionStatusText(value);
|
|
70
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { type CockpitConfig, DEFAULT_CONFIG } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export function getConfigPath(): string {
|
|
7
|
+
return join(getAgentDir(), "cockpit.json");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Flat, field-by-field merge: type-safe and forward-compatible (unknown keys ignored).
|
|
11
|
+
function deepMerge(base: CockpitConfig, over: unknown): CockpitConfig {
|
|
12
|
+
if (!over || typeof over !== "object" || Array.isArray(over)) return base;
|
|
13
|
+
const o = over as Record<string, unknown>;
|
|
14
|
+
const isMode = (v: unknown): v is "list" | "compact" => v === "list" || v === "compact";
|
|
15
|
+
const isIconMode = (v: unknown): v is "auto" | "nerd" | "ascii" => v === "auto" || v === "nerd" || v === "ascii";
|
|
16
|
+
const iconsRaw = o.icons && typeof o.icons === "object" && !Array.isArray(o.icons)
|
|
17
|
+
? (o.icons as Record<string, unknown>)
|
|
18
|
+
: undefined;
|
|
19
|
+
return {
|
|
20
|
+
enabled: typeof o.enabled === "boolean" ? o.enabled : base.enabled,
|
|
21
|
+
agentsMode: isMode(o.agentsMode) ? o.agentsMode : base.agentsMode,
|
|
22
|
+
todoMode: isMode(o.todoMode) ? o.todoMode : base.todoMode,
|
|
23
|
+
todoExpanded: typeof o.todoExpanded === "boolean" ? o.todoExpanded : base.todoExpanded,
|
|
24
|
+
hideNativeAgents: typeof o.hideNativeAgents === "boolean" ? o.hideNativeAgents : base.hideNativeAgents,
|
|
25
|
+
icons: { mode: iconsRaw && isIconMode(iconsRaw.mode) ? iconsRaw.mode : base.icons.mode },
|
|
26
|
+
theme: typeof o.theme === "string" ? o.theme : base.theme,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function ensureConfigExists(): void {
|
|
31
|
+
const path = getConfigPath();
|
|
32
|
+
if (existsSync(path)) return;
|
|
33
|
+
try {
|
|
34
|
+
const dir = getAgentDir();
|
|
35
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
36
|
+
writeFileSync(path, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n", "utf8");
|
|
37
|
+
} catch {
|
|
38
|
+
// best-effort: a read-only or missing agent dir must not break the extension
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function loadConfig(notify?: (msg: string, level: "warning" | "info") => void): CockpitConfig {
|
|
43
|
+
const path = getConfigPath();
|
|
44
|
+
if (!existsSync(path)) {
|
|
45
|
+
ensureConfigExists();
|
|
46
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
return deepMerge(DEFAULT_CONFIG, JSON.parse(readFileSync(path, "utf8")));
|
|
50
|
+
} catch (err) {
|
|
51
|
+
notify?.(`pi-cockpit config parse error: ${err instanceof Error ? err.message : String(err)}`, "warning");
|
|
52
|
+
return structuredClone(DEFAULT_CONFIG);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface SaveResult {
|
|
57
|
+
ok: boolean;
|
|
58
|
+
error?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Persist the config, reporting whether it actually landed.
|
|
63
|
+
*
|
|
64
|
+
* This used to swallow every failure, so on a read-only agent dir the settings
|
|
65
|
+
* panel showed the new value while nothing was written and the change silently
|
|
66
|
+
* vanished at next start. Writes go through a temp file + rename so an
|
|
67
|
+
* interrupted write cannot leave a half-written config behind.
|
|
68
|
+
*/
|
|
69
|
+
export function saveConfig(config: CockpitConfig): SaveResult {
|
|
70
|
+
const path = getConfigPath();
|
|
71
|
+
const tmp = `${path}.tmp`;
|
|
72
|
+
try {
|
|
73
|
+
const dir = getAgentDir();
|
|
74
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
75
|
+
writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
76
|
+
renameSync(tmp, path);
|
|
77
|
+
return { ok: true };
|
|
78
|
+
} catch (err) {
|
|
79
|
+
try {
|
|
80
|
+
if (existsSync(tmp)) rmSync(tmp);
|
|
81
|
+
} catch {
|
|
82
|
+
// a stray temp file must not mask the original failure
|
|
83
|
+
}
|
|
84
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "../index.ts";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
|
|
3
|
+
export interface ExtensionStatusSegment {
|
|
4
|
+
key: string;
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function sanitizeExtensionStatusText(value: string): string {
|
|
9
|
+
return stripVTControlCharacters(value)
|
|
10
|
+
.replace(/[\r\n\t\f\v]+/g, " ")
|
|
11
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, "")
|
|
12
|
+
.replace(/\s+/g, " ")
|
|
13
|
+
.trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function collectExtensionStatuses(
|
|
17
|
+
statuses: ReadonlyMap<string, string>,
|
|
18
|
+
): ExtensionStatusSegment[] {
|
|
19
|
+
const segments: ExtensionStatusSegment[] = [];
|
|
20
|
+
for (const [key, value] of statuses) {
|
|
21
|
+
const text = sanitizeExtensionStatusText(value);
|
|
22
|
+
if (text) segments.push({ key, text });
|
|
23
|
+
}
|
|
24
|
+
return segments.sort((a, b) => a.key.localeCompare(b.key));
|
|
25
|
+
}
|