@aliou/pi-processes 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 ADDED
@@ -0,0 +1,63 @@
1
+ # Processes Extension
2
+
3
+ Manage background processes from Pi. Start long-running commands (dev servers, build watchers, log tailers) without blocking the conversation.
4
+
5
+ ## Features
6
+
7
+ - **Tool**: `processes` with actions: `start`, `list`, `output`, `logs`, `kill`, `clear`
8
+ - **Command**: `/processes` - interactive panel to view and manage processes
9
+ - Auto-cleanup on session exit
10
+ - File-based logging (logs written to temp files, not memory)
11
+ - Friendly process names (auto-inferred or custom)
12
+
13
+ ## Usage
14
+
15
+ ### Tool (for agent)
16
+
17
+ ```
18
+ processes start "pnpm dev" name="backend-dev"
19
+ processes start "pnpm build" name="build" notifyOnSuccess=true
20
+ processes start "pnpm test" notifyOnFailure=true
21
+ processes list
22
+ processes output id="backend"
23
+ processes logs id="proc_1"
24
+ processes kill id="backend"
25
+ processes clear
26
+ ```
27
+
28
+ **Notification parameters** (for `start` action):
29
+ - `notifyOnSuccess` (default: false) - Get notified when process completes successfully. Use for builds/tests where you need confirmation.
30
+ - `notifyOnFailure` (default: true) - Get notified when process crashes/fails. Use to be alerted of unexpected failures.
31
+ - `notifyOnKill` (default: false) - Get notified if killed by external signal. Note: killing via tool never notifies.
32
+
33
+ **Important:** You don't need to poll or wait for processes. Notifications arrive automatically based on your preferences. Start processes and continue with other work - you'll be informed if something requires attention.
34
+
35
+ Note: User always sees notifications in UI. Notification preferences only control whether the agent is informed.
36
+
37
+ ### Command (interactive)
38
+
39
+ Run `/processes` to open the panel:
40
+ - `j/k` - select process
41
+ - `J/K` - scroll logs
42
+ - `x` - kill selected process
43
+ - `c` - clear finished processes
44
+ - `q` - quit
45
+
46
+ ## Test Scripts
47
+
48
+ Test scripts in `test/` directory:
49
+
50
+ ```bash
51
+ ./test/test-output.sh # Continuous output (80 chars/sec)
52
+ ./test/test-exit-success.sh 5 # Exits successfully after 5s
53
+ ./test/test-exit-failure.sh 5 # Exits with code 1 after 5s
54
+ ./test/test-exit-crash.sh 5 # Exits with code 137 after 5s
55
+ ```
56
+
57
+ ## Future Improvements
58
+
59
+ - [ ] **Expandable log view**: Allow toggling between collapsed (current fixed height) and expanded (full height) log view in the `/processes` panel.
60
+
61
+ - [ ] **Copy log file path**: Add keyboard shortcut to copy the stdout/stderr log file path to clipboard for easy access.
62
+
63
+ - [ ] **Open logs in editor**: Add keyboard shortcut to open log files directly in the configured editor (`$EDITOR` or VS Code).
@@ -0,0 +1,488 @@
1
+ import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
2
+ import { type Component, matchesKey, visibleWidth } from "@mariozechner/pi-tui";
3
+ import type { ProcessInfo, ProcessManager } from "../manager";
4
+
5
+ // Max visible processes in the list (scrollable if more)
6
+ const MAX_VISIBLE_PROCESSES = 8;
7
+ // Max log lines shown
8
+ const MAX_LOG_LINES = 12;
9
+
10
+ function formatRuntime(startTime: number, endTime: number | null): string {
11
+ const end = endTime ?? Date.now();
12
+ const ms = end - startTime;
13
+ const seconds = Math.floor(ms / 1000);
14
+ const minutes = Math.floor(seconds / 60);
15
+ const hours = Math.floor(minutes / 60);
16
+
17
+ if (hours > 0) {
18
+ return `${hours}h ${minutes % 60}m`;
19
+ }
20
+ if (minutes > 0) {
21
+ return `${minutes}m ${seconds % 60}s`;
22
+ }
23
+ return `${seconds}s`;
24
+ }
25
+
26
+ function formatBytes(bytes: number): string {
27
+ if (bytes >= 1024 * 1024) {
28
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
29
+ }
30
+ if (bytes >= 1024) {
31
+ return `${(bytes / 1024).toFixed(1)}KB`;
32
+ }
33
+ return `${bytes}B`;
34
+ }
35
+
36
+ function truncate(str: string, maxLen: number): string {
37
+ if (maxLen <= 3) return str.slice(0, maxLen);
38
+ if (str.length <= maxLen) return str;
39
+ return `${str.slice(0, maxLen - 3)}...`;
40
+ }
41
+
42
+ class ProcessesComponent implements Component {
43
+ private tui: { requestRender: () => void };
44
+ private theme: Theme;
45
+ private onClose: () => void;
46
+ private manager: ProcessManager;
47
+
48
+ private selectedIndex = 0;
49
+ private processScrollOffset = 0;
50
+ private logScrollOffset = 0;
51
+ private scrollInfo = { above: 0, below: 0 };
52
+ private cachedLines: string[] = [];
53
+ private cachedWidth = 0;
54
+ private refreshInterval: ReturnType<typeof setInterval> | null = null;
55
+
56
+ constructor(
57
+ tui: { requestRender: () => void },
58
+ theme: Theme,
59
+ onClose: () => void,
60
+ manager: ProcessManager,
61
+ ) {
62
+ this.tui = tui;
63
+ this.theme = theme;
64
+ this.onClose = onClose;
65
+ this.manager = manager;
66
+
67
+ this.refreshInterval = setInterval(() => {
68
+ this.invalidate();
69
+ this.tui.requestRender();
70
+ }, 1000);
71
+ }
72
+
73
+ handleInput(data: string): boolean {
74
+ const processes = this.manager.list();
75
+
76
+ // Navigation
77
+ if (matchesKey(data, "down") || data === "j") {
78
+ if (processes.length > 0) {
79
+ this.selectedIndex = Math.min(
80
+ this.selectedIndex + 1,
81
+ processes.length - 1,
82
+ );
83
+ this.logScrollOffset = 0;
84
+ this.ensureProcessVisible(processes.length);
85
+ this.invalidate();
86
+ this.tui.requestRender();
87
+ }
88
+ return true;
89
+ }
90
+
91
+ if (matchesKey(data, "up") || data === "k") {
92
+ if (processes.length > 0) {
93
+ this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
94
+ this.logScrollOffset = 0;
95
+ this.ensureProcessVisible(processes.length);
96
+ this.invalidate();
97
+ this.tui.requestRender();
98
+ }
99
+ return true;
100
+ }
101
+
102
+ // Scroll logs
103
+ if (data === "J") {
104
+ this.logScrollOffset = Math.max(0, this.logScrollOffset - 5);
105
+ this.invalidate();
106
+ this.tui.requestRender();
107
+ return true;
108
+ }
109
+
110
+ if (data === "K") {
111
+ this.logScrollOffset += 5;
112
+ this.invalidate();
113
+ this.tui.requestRender();
114
+ return true;
115
+ }
116
+
117
+ // Kill selected process
118
+ if (data === "x" || data === "X") {
119
+ if (processes.length > 0 && this.selectedIndex < processes.length) {
120
+ const proc = processes[this.selectedIndex];
121
+ if (proc && proc.status === "running") {
122
+ this.manager.kill(proc.id);
123
+ this.invalidate();
124
+ this.tui.requestRender();
125
+ }
126
+ }
127
+ return true;
128
+ }
129
+
130
+ // Clear finished processes
131
+ if (data === "c" || data === "C") {
132
+ const cleared = this.manager.clearFinished();
133
+ if (cleared > 0) {
134
+ // Adjust selection if needed
135
+ const remaining = this.manager.list();
136
+ if (this.selectedIndex >= remaining.length) {
137
+ this.selectedIndex = Math.max(0, remaining.length - 1);
138
+ }
139
+ this.ensureProcessVisible(remaining.length);
140
+ this.invalidate();
141
+ this.tui.requestRender();
142
+ }
143
+ return true;
144
+ }
145
+
146
+ // Close
147
+ if (matchesKey(data, "escape") || data === "q" || data === "Q") {
148
+ if (this.refreshInterval) {
149
+ clearInterval(this.refreshInterval);
150
+ this.refreshInterval = null;
151
+ }
152
+ this.onClose();
153
+ return true;
154
+ }
155
+
156
+ return true;
157
+ }
158
+
159
+ private ensureProcessVisible(totalProcesses: number): void {
160
+ const visibleCount = Math.min(MAX_VISIBLE_PROCESSES, totalProcesses);
161
+ if (this.selectedIndex < this.processScrollOffset) {
162
+ this.processScrollOffset = this.selectedIndex;
163
+ } else if (this.selectedIndex >= this.processScrollOffset + visibleCount) {
164
+ this.processScrollOffset = this.selectedIndex - visibleCount + 1;
165
+ }
166
+ this.processScrollOffset = Math.max(
167
+ 0,
168
+ Math.min(this.processScrollOffset, totalProcesses - visibleCount),
169
+ );
170
+ }
171
+
172
+ invalidate(): void {
173
+ this.cachedWidth = 0;
174
+ this.cachedLines = [];
175
+ }
176
+
177
+ render(width: number): string[] {
178
+ if (width === this.cachedWidth && this.cachedLines.length > 0) {
179
+ return this.cachedLines;
180
+ }
181
+
182
+ const theme = this.theme;
183
+ const dim = (s: string) => theme.fg("dim", s);
184
+ const accent = (s: string) => theme.fg("accent", s);
185
+ const warning = (s: string) => theme.fg("warning", s);
186
+ const bold = (s: string) => theme.bold(s);
187
+ const border = (s: string) => theme.fg("dim", s);
188
+
189
+ const lines: string[] = [];
190
+ const processes = this.manager.list();
191
+ const innerWidth = width - 2; // 1 char padding each side
192
+
193
+ // Helper to pad line
194
+ const padLine = (content: string): string => {
195
+ const len = visibleWidth(content);
196
+ return ` ${content}${" ".repeat(Math.max(0, innerWidth - len))} `;
197
+ };
198
+
199
+ // Top border with title
200
+ const title = " Background Processes ";
201
+ const titleLen = title.length;
202
+ const borderLen = Math.max(0, width - titleLen);
203
+ const leftBorder = Math.floor(borderLen / 2);
204
+ const rightBorder = borderLen - leftBorder;
205
+ lines.push(
206
+ border("─".repeat(leftBorder)) +
207
+ accent(bold(title)) +
208
+ border("─".repeat(rightBorder)),
209
+ );
210
+
211
+ if (processes.length === 0) {
212
+ lines.push(padLine(""));
213
+ lines.push(padLine(dim("No background processes")));
214
+ lines.push(padLine(dim("Use the processes tool to start commands")));
215
+ lines.push(padLine(""));
216
+ } else {
217
+ // Calculate column widths
218
+ const prefixWidth = 2; // "> " or " "
219
+ const idWidth = 9;
220
+ const nameWidth = 15;
221
+ const statusWidth = 14;
222
+ const timeWidth = 8;
223
+ const sizeWidth = 8;
224
+ const cmdWidth = Math.max(
225
+ 20,
226
+ innerWidth -
227
+ prefixWidth -
228
+ idWidth -
229
+ nameWidth -
230
+ statusWidth -
231
+ timeWidth -
232
+ sizeWidth,
233
+ );
234
+
235
+ // Header with scroll indicator if needed
236
+ const hasProcessScroll = processes.length > MAX_VISIBLE_PROCESSES;
237
+ const headerSuffix = hasProcessScroll
238
+ ? dim(
239
+ ` [${this.processScrollOffset + 1}-${Math.min(this.processScrollOffset + MAX_VISIBLE_PROCESSES, processes.length)}/${processes.length}]`,
240
+ )
241
+ : "";
242
+
243
+ lines.push(padLine(""));
244
+ const header =
245
+ " " +
246
+ dim("ID".padEnd(idWidth)) +
247
+ dim("Name".padEnd(nameWidth)) +
248
+ dim("Command".padEnd(cmdWidth)) +
249
+ dim("Status".padEnd(statusWidth)) +
250
+ dim("Time".padEnd(timeWidth)) +
251
+ dim("Size".padStart(sizeWidth)) +
252
+ headerSuffix;
253
+ lines.push(padLine(header));
254
+ lines.push(border("─".repeat(width)));
255
+
256
+ // Process rows (limited to MAX_VISIBLE_PROCESSES)
257
+ const visibleProcessCount = Math.min(
258
+ MAX_VISIBLE_PROCESSES,
259
+ processes.length,
260
+ );
261
+ const startIdx = this.processScrollOffset;
262
+ const endIdx = startIdx + visibleProcessCount;
263
+
264
+ for (let i = startIdx; i < endIdx; i++) {
265
+ const proc = processes[i];
266
+ if (!proc) continue;
267
+ const isSelected = i === this.selectedIndex;
268
+ const sizes = this.manager.getFileSize(proc.id);
269
+ const totalSize = sizes ? sizes.stdout + sizes.stderr : 0;
270
+
271
+ const statusText = this.formatStatus(proc);
272
+ const statusPadding =
273
+ statusWidth + (statusText.length - visibleWidth(statusText));
274
+
275
+ const row =
276
+ (isSelected
277
+ ? accent(proc.id.padEnd(idWidth))
278
+ : proc.id.padEnd(idWidth)) +
279
+ truncate(proc.name, nameWidth - 1).padEnd(nameWidth) +
280
+ truncate(proc.command, cmdWidth - 1).padEnd(cmdWidth) +
281
+ statusText.padEnd(statusPadding) +
282
+ formatRuntime(proc.startTime, proc.endTime).padEnd(timeWidth) +
283
+ formatBytes(totalSize).padStart(sizeWidth);
284
+
285
+ if (isSelected) {
286
+ lines.push(padLine(`${accent(">")} ${row}`));
287
+ } else {
288
+ lines.push(padLine(` ${row}`));
289
+ }
290
+ }
291
+
292
+ // Pad process list to fixed height
293
+ for (let i = visibleProcessCount; i < MAX_VISIBLE_PROCESSES; i++) {
294
+ lines.push(padLine(""));
295
+ }
296
+
297
+ // Output section for selected process
298
+ if (this.selectedIndex < processes.length) {
299
+ const selected = processes[this.selectedIndex];
300
+ if (!selected) {
301
+ this.cachedLines = lines;
302
+ this.cachedWidth = width;
303
+ return this.cachedLines;
304
+ }
305
+ const output = this.manager.getOutput(selected.id, 200);
306
+ const sizes = this.manager.getFileSize(selected.id);
307
+
308
+ lines.push(border("─".repeat(width)));
309
+
310
+ // Output header with size info
311
+ const logTitle = `Output: ${accent(selected.name)} ${dim(`(${selected.id})`)}`;
312
+ const sizeInfo = sizes
313
+ ? dim(
314
+ ` stdout: ${formatBytes(sizes.stdout)}, stderr: ${formatBytes(sizes.stderr)}`,
315
+ )
316
+ : "";
317
+ lines.push(padLine(logTitle + sizeInfo));
318
+ lines.push(padLine(""));
319
+
320
+ let renderedLines = 0;
321
+
322
+ if (output) {
323
+ const logLines: { type: "stdout" | "stderr"; text: string }[] = [];
324
+ for (const line of output.stdout) {
325
+ logLines.push({ type: "stdout", text: line });
326
+ }
327
+ for (const line of output.stderr) {
328
+ logLines.push({ type: "stderr", text: line });
329
+ }
330
+
331
+ if (logLines.length === 0) {
332
+ lines.push(padLine(dim("(no output yet)")));
333
+ renderedLines = 1;
334
+ } else {
335
+ const startIdx = Math.max(
336
+ 0,
337
+ logLines.length - MAX_LOG_LINES - this.logScrollOffset,
338
+ );
339
+ const endIdx = Math.max(0, logLines.length - this.logScrollOffset);
340
+ const visibleLines = logLines.slice(startIdx, endIdx);
341
+
342
+ // Track scroll info for footer
343
+ this.scrollInfo.above = startIdx;
344
+ this.scrollInfo.below =
345
+ this.logScrollOffset > 0 ? logLines.length - endIdx : 0;
346
+
347
+ for (const line of visibleLines) {
348
+ const displayLine = truncate(line.text, innerWidth - 2);
349
+ if (line.type === "stderr") {
350
+ lines.push(padLine(warning(displayLine)));
351
+ } else {
352
+ lines.push(padLine(displayLine));
353
+ }
354
+ renderedLines++;
355
+ }
356
+ }
357
+ }
358
+
359
+ // Pad to fixed height
360
+ while (renderedLines < MAX_LOG_LINES) {
361
+ lines.push(padLine(""));
362
+ renderedLines++;
363
+ }
364
+ }
365
+ }
366
+
367
+ // Footer divider
368
+ lines.push(border("─".repeat(width)));
369
+
370
+ // Footer with controls
371
+ const footerLeft =
372
+ `${dim("j/k")} select ` +
373
+ `${dim("x")} kill ` +
374
+ `${dim("c")} clear ` +
375
+ `${dim("q")} quit`;
376
+
377
+ let footerRight = "";
378
+ if (this.scrollInfo.above > 0 || this.scrollInfo.below > 0) {
379
+ const parts: string[] = [];
380
+ if (this.scrollInfo.above > 0) {
381
+ parts.push(`↑${this.scrollInfo.above}`);
382
+ }
383
+ if (this.scrollInfo.below > 0) {
384
+ parts.push(`↓${this.scrollInfo.below}`);
385
+ }
386
+ footerRight = `${dim("J/K")} scroll ${dim(parts.join(" "))}`;
387
+ }
388
+
389
+ const footerLeftLen = visibleWidth(footerLeft);
390
+ const footerRightLen = visibleWidth(footerRight);
391
+ const footerGap = Math.max(2, innerWidth - footerLeftLen - footerRightLen);
392
+ const footer = footerLeft + " ".repeat(footerGap) + footerRight;
393
+
394
+ lines.push(padLine(footer));
395
+
396
+ // Bottom border
397
+ lines.push(border("─".repeat(width)));
398
+
399
+ this.cachedLines = lines;
400
+ this.cachedWidth = width;
401
+
402
+ return this.cachedLines;
403
+ }
404
+
405
+ private formatStatus(proc: ProcessInfo): string {
406
+ const theme = this.theme;
407
+ const dim = (s: string) => theme.fg("dim", s);
408
+ const success = (s: string) => theme.fg("success", s);
409
+ const warning = (s: string) => theme.fg("warning", s);
410
+ const error = (s: string) => theme.fg("error", s);
411
+
412
+ const icon = this.getStatusIcon(proc.status, proc.success);
413
+
414
+ switch (proc.status) {
415
+ case "running":
416
+ return success(`${icon} running`);
417
+ case "exited":
418
+ if (proc.success) {
419
+ return dim(`${icon} exit(0)`);
420
+ }
421
+ return error(`${icon} exit(${proc.exitCode ?? "?"})`);
422
+ case "killed":
423
+ return warning(`${icon} killed`);
424
+ default:
425
+ return proc.status;
426
+ }
427
+ }
428
+
429
+ private getStatusIcon(
430
+ status: ProcessInfo["status"],
431
+ success: boolean | null,
432
+ ): string {
433
+ switch (status) {
434
+ case "running":
435
+ return "\u25CF"; // filled circle
436
+ case "exited":
437
+ return success ? "\u2713" : "\u2717"; // check or x
438
+ case "killed":
439
+ return "\u2717"; // x mark
440
+ default:
441
+ return "?";
442
+ }
443
+ }
444
+ }
445
+
446
+ const WIDGET_ID = "processes-status";
447
+
448
+ export function setupProcessesCommands(
449
+ pi: ExtensionAPI,
450
+ manager: ProcessManager,
451
+ ) {
452
+ pi.registerCommand("processes", {
453
+ description: "View and manage background processes",
454
+ handler: async (_args, ctx) => {
455
+ if (!ctx.hasUI) {
456
+ ctx.ui.notify("/processes requires interactive mode", "error");
457
+ return;
458
+ }
459
+ await ctx.ui.custom((tui, theme, _keybindings, done) => {
460
+ return new ProcessesComponent(
461
+ tui,
462
+ theme,
463
+ () => done(undefined),
464
+ manager,
465
+ );
466
+ });
467
+ },
468
+ });
469
+
470
+ pi.registerCommand("processes:clear", {
471
+ description: "Clear finished processes and hide widget",
472
+ handler: async (_args, ctx) => {
473
+ const cleared = manager.clearFinished();
474
+ const remaining = manager.list();
475
+
476
+ // Hide widget if no processes remain
477
+ if (remaining.length === 0 && ctx.hasUI) {
478
+ ctx.ui.setWidget(WIDGET_ID, undefined);
479
+ }
480
+
481
+ if (cleared > 0) {
482
+ ctx.ui.notify(`Cleared ${cleared} finished process(es)`, "info");
483
+ } else {
484
+ ctx.ui.notify("No finished processes to clear", "info");
485
+ }
486
+ },
487
+ });
488
+ }
package/constants.ts ADDED
@@ -0,0 +1,2 @@
1
+ // Custom message type for process update notifications
2
+ export const MESSAGE_TYPE_PROCESS_UPDATE = "ad-process:update";
@@ -0,0 +1,8 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { ProcessManager } from "../manager";
3
+
4
+ export function setupCleanupHook(pi: ExtensionAPI, manager: ProcessManager) {
5
+ pi.on("session_shutdown", async () => {
6
+ manager.cleanup();
7
+ });
8
+ }
package/hooks/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import type { ProcessManager } from "../manager";
3
+ import { setupCleanupHook } from "./cleanup";
4
+ import { setupMessageRenderer } from "./message-renderer";
5
+ import { setupProcessEndHook } from "./process-end";
6
+ import { setupProcessWidget } from "./widget";
7
+
8
+ export function setupProcessesHooks(pi: ExtensionAPI, manager: ProcessManager) {
9
+ setupCleanupHook(pi, manager);
10
+ setupProcessEndHook(pi, manager);
11
+
12
+ // Set up widget AFTER process-end so it chains onto the existing callback
13
+ setupProcessWidget(pi, manager);
14
+
15
+ setupMessageRenderer(pi);
16
+ }
@@ -0,0 +1,83 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ MessageRenderOptions,
4
+ Theme,
5
+ } from "@mariozechner/pi-coding-agent";
6
+ import { Text } from "@mariozechner/pi-tui";
7
+ import { MESSAGE_TYPE_PROCESS_UPDATE } from "../constants";
8
+
9
+ interface ProcessUpdateDetails {
10
+ processId: string;
11
+ processName: string;
12
+ command: string;
13
+ status: "exited" | "killed";
14
+ exitCode: number | null;
15
+ success: boolean;
16
+ runtime: string;
17
+ }
18
+
19
+ interface ProcessUpdateMessage {
20
+ customType: string;
21
+ content: string | Array<{ type: string; text?: string }>;
22
+ details?: ProcessUpdateDetails;
23
+ }
24
+
25
+ function getContentText(
26
+ content: string | Array<{ type: string; text?: string }>,
27
+ ): string {
28
+ if (typeof content === "string") {
29
+ return content;
30
+ }
31
+ return content
32
+ .filter((c) => c.type === "text" && c.text)
33
+ .map((c) => c.text as string)
34
+ .join("");
35
+ }
36
+
37
+ export function setupMessageRenderer(pi: ExtensionAPI) {
38
+ pi.registerMessageRenderer<ProcessUpdateDetails>(
39
+ MESSAGE_TYPE_PROCESS_UPDATE,
40
+ (
41
+ message: ProcessUpdateMessage,
42
+ _options: MessageRenderOptions,
43
+ theme: Theme,
44
+ ) => {
45
+ const details = message.details;
46
+
47
+ if (!details) {
48
+ return new Text(getContentText(message.content), 0, 0);
49
+ }
50
+
51
+ let icon: string;
52
+ let color: "success" | "error" | "warning";
53
+
54
+ if (details.status === "killed") {
55
+ icon = "\u2717"; // x mark
56
+ color = "warning";
57
+ } else if (details.success) {
58
+ icon = "\u2713"; // check mark
59
+ color = "success";
60
+ } else {
61
+ icon = "\u2717"; // x mark
62
+ color = "error";
63
+ }
64
+
65
+ const statusText =
66
+ details.status === "killed"
67
+ ? "terminated"
68
+ : details.success
69
+ ? "completed"
70
+ : `exited(${details.exitCode ?? "?"})`;
71
+
72
+ const text =
73
+ theme.fg(color, `${icon} `) +
74
+ theme.fg("accent", `"${details.processName}"`) +
75
+ theme.fg("muted", ` (${details.processId})`) +
76
+ " " +
77
+ theme.fg(color, statusText) +
78
+ theme.fg("muted", ` ${details.runtime}`);
79
+
80
+ return new Text(text, 0, 0);
81
+ },
82
+ );
83
+ }