@aliou/pi-processes 0.4.3 → 0.4.5

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.
@@ -1,450 +0,0 @@
1
- import {
2
- createPanelPadder,
3
- renderPanelRule,
4
- renderPanelTitleLine,
5
- } from "@aliou/pi-utils-ui";
6
- import type { Theme } from "@mariozechner/pi-coding-agent";
7
- import { type Component, matchesKey, visibleWidth } from "@mariozechner/pi-tui";
8
- import { configLoader } from "../config";
9
- import type { ProcessInfo } from "../constants";
10
- import type { ProcessManager } from "../manager";
11
- import { stripAnsi } from "../utils";
12
- import { statusIcon, statusLabel } from "./status-format";
13
-
14
- function formatRuntime(startTime: number, endTime: number | null): string {
15
- const end = endTime ?? Date.now();
16
- const ms = end - startTime;
17
- const seconds = Math.floor(ms / 1000);
18
- const minutes = Math.floor(seconds / 60);
19
- const hours = Math.floor(minutes / 60);
20
-
21
- if (hours > 0) {
22
- return `${hours}h ${minutes % 60}m`;
23
- }
24
- if (minutes > 0) {
25
- return `${minutes}m ${seconds % 60}s`;
26
- }
27
- return `${seconds}s`;
28
- }
29
-
30
- function formatBytes(bytes: number): string {
31
- if (bytes >= 1024 * 1024) {
32
- return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
33
- }
34
- if (bytes >= 1024) {
35
- return `${(bytes / 1024).toFixed(1)}KB`;
36
- }
37
- return `${bytes}B`;
38
- }
39
-
40
- function truncate(str: string, maxLen: number): string {
41
- if (maxLen <= 3) return str.slice(0, maxLen);
42
- if (str.length <= maxLen) return str;
43
- return `${str.slice(0, maxLen - 3)}...`;
44
- }
45
-
46
- export class ProcessesComponent implements Component {
47
- private tui: { requestRender: () => void };
48
- private theme: Theme;
49
- private onClose: (processId?: string) => void;
50
- private manager: ProcessManager;
51
-
52
- private selectedIndex = 0;
53
- private processScrollOffset = 0;
54
- private logScrollOffset = 0;
55
- private scrollInfo = { above: 0, below: 0 };
56
- private cachedLines: string[] = [];
57
- private cachedWidth = 0;
58
- private unsubscribe: (() => void) | null = null;
59
-
60
- constructor(
61
- tui: { requestRender: () => void },
62
- theme: Theme,
63
- onClose: (processId?: string) => void,
64
- manager: ProcessManager,
65
- ) {
66
- this.tui = tui;
67
- this.theme = theme;
68
- this.onClose = onClose;
69
- this.manager = manager;
70
-
71
- this.unsubscribe = this.manager.onEvent(() => {
72
- this.invalidate();
73
- this.tui.requestRender();
74
- });
75
- }
76
-
77
- handleInput(data: string): boolean {
78
- const processes = this.manager.list();
79
-
80
- // Navigation
81
- if (matchesKey(data, "down") || data === "j") {
82
- if (processes.length > 0) {
83
- this.selectedIndex = Math.min(
84
- this.selectedIndex + 1,
85
- processes.length - 1,
86
- );
87
- this.logScrollOffset = 0;
88
- this.ensureProcessVisible(processes.length);
89
- this.invalidate();
90
- this.tui.requestRender();
91
- }
92
- return true;
93
- }
94
-
95
- if (matchesKey(data, "up") || data === "k") {
96
- if (processes.length > 0) {
97
- this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
98
- this.logScrollOffset = 0;
99
- this.ensureProcessVisible(processes.length);
100
- this.invalidate();
101
- this.tui.requestRender();
102
- }
103
- return true;
104
- }
105
-
106
- // Scroll logs
107
- if (data === "J") {
108
- this.logScrollOffset = Math.max(0, this.logScrollOffset - 5);
109
- this.invalidate();
110
- this.tui.requestRender();
111
- return true;
112
- }
113
-
114
- if (data === "K") {
115
- this.logScrollOffset += 5;
116
- this.invalidate();
117
- this.tui.requestRender();
118
- return true;
119
- }
120
-
121
- // Stream logs for selected process
122
- if (matchesKey(data, "return")) {
123
- if (processes.length > 0 && this.selectedIndex < processes.length) {
124
- const proc = processes[this.selectedIndex];
125
- if (proc) {
126
- this.unsubscribe?.();
127
- this.unsubscribe = null;
128
- this.onClose(proc.id);
129
- }
130
- }
131
- return true;
132
- }
133
-
134
- // Kill selected process
135
- if (data === "x") {
136
- if (processes.length > 0 && this.selectedIndex < processes.length) {
137
- const proc = processes[this.selectedIndex];
138
- if (proc?.status === "running") {
139
- void this.manager.kill(proc.id, {
140
- signal: "SIGTERM",
141
- timeoutMs: 3000,
142
- });
143
- } else if (proc?.status === "terminate_timeout") {
144
- void this.manager.kill(proc.id, {
145
- signal: "SIGKILL",
146
- timeoutMs: 200,
147
- });
148
- }
149
- }
150
- return true;
151
- }
152
-
153
- // Clear finished processes
154
- if (data === "c" || data === "C") {
155
- const cleared = this.manager.clearFinished();
156
- if (cleared > 0) {
157
- const remaining = this.manager.list();
158
- if (this.selectedIndex >= remaining.length) {
159
- this.selectedIndex = Math.max(0, remaining.length - 1);
160
- }
161
- this.ensureProcessVisible(remaining.length);
162
- this.invalidate();
163
- this.tui.requestRender();
164
- }
165
- return true;
166
- }
167
-
168
- // Close
169
- if (matchesKey(data, "escape") || data === "q" || data === "Q") {
170
- this.unsubscribe?.();
171
- this.unsubscribe = null;
172
- this.onClose();
173
- return true;
174
- }
175
-
176
- return true;
177
- }
178
-
179
- private ensureProcessVisible(totalProcesses: number): void {
180
- const maxVisibleProcesses =
181
- configLoader.getConfig().processList.maxVisibleProcesses;
182
- const visibleCount = Math.min(maxVisibleProcesses, totalProcesses);
183
- if (this.selectedIndex < this.processScrollOffset) {
184
- this.processScrollOffset = this.selectedIndex;
185
- } else if (this.selectedIndex >= this.processScrollOffset + visibleCount) {
186
- this.processScrollOffset = this.selectedIndex - visibleCount + 1;
187
- }
188
- this.processScrollOffset = Math.max(
189
- 0,
190
- Math.min(this.processScrollOffset, totalProcesses - visibleCount),
191
- );
192
- }
193
-
194
- invalidate(): void {
195
- this.cachedWidth = 0;
196
- this.cachedLines = [];
197
- }
198
-
199
- render(width: number): string[] {
200
- if (width === this.cachedWidth && this.cachedLines.length > 0) {
201
- return this.cachedLines;
202
- }
203
-
204
- const cfg = configLoader.getConfig().processList;
205
- const maxVisibleProcesses = cfg.maxVisibleProcesses;
206
- const maxPreviewLines = cfg.maxPreviewLines;
207
-
208
- const theme = this.theme;
209
- const dim = (s: string) => theme.fg("dim", s);
210
- const accent = (s: string) => theme.fg("accent", s);
211
- const warning = (s: string) => theme.fg("warning", s);
212
-
213
- const lines: string[] = [];
214
- const processes = this.manager.list();
215
- const innerWidth = width - 2;
216
-
217
- const padLine = createPanelPadder(width);
218
-
219
- lines.push(renderPanelTitleLine("Background Processes", width, theme));
220
-
221
- if (processes.length === 0) {
222
- lines.push(padLine(""));
223
- lines.push(padLine(dim("No background processes")));
224
- lines.push(padLine(dim("Use the processes tool to start commands")));
225
- lines.push(padLine(""));
226
- } else {
227
- const prefixWidth = 2;
228
- const idWidth = 9;
229
- const nameWidth = 15;
230
- const statusWidth = 18;
231
- const timeWidth = 8;
232
- const sizeWidth = 8;
233
-
234
- const hasProcessScroll = processes.length > maxVisibleProcesses;
235
- const headerSuffixText = hasProcessScroll
236
- ? ` [${this.processScrollOffset + 1}-${Math.min(this.processScrollOffset + maxVisibleProcesses, processes.length)}/${processes.length}]`
237
- : "";
238
- const headerSuffixLen = hasProcessScroll ? headerSuffixText.length : 0;
239
-
240
- // Reserve space for scroll suffix in the command column
241
- const cmdWidth = Math.max(
242
- 10,
243
- innerWidth -
244
- prefixWidth -
245
- idWidth -
246
- nameWidth -
247
- statusWidth -
248
- timeWidth -
249
- sizeWidth -
250
- headerSuffixLen,
251
- );
252
-
253
- lines.push(padLine(""));
254
- const header =
255
- " " +
256
- dim("ID".padEnd(idWidth)) +
257
- dim("Name".padEnd(nameWidth)) +
258
- dim("Command".padEnd(cmdWidth)) +
259
- dim("Status".padEnd(statusWidth)) +
260
- dim("Time".padEnd(timeWidth)) +
261
- dim("Size".padStart(sizeWidth)) +
262
- (hasProcessScroll ? dim(headerSuffixText) : "");
263
- lines.push(padLine(header));
264
- lines.push(renderPanelRule(width, theme));
265
-
266
- const visibleProcessCount = Math.min(
267
- maxVisibleProcesses,
268
- processes.length,
269
- );
270
- const startIdx = this.processScrollOffset;
271
- const endIdx = startIdx + visibleProcessCount;
272
-
273
- for (let i = startIdx; i < endIdx; i++) {
274
- const proc = processes[i];
275
- if (!proc) continue;
276
- const isSelected = i === this.selectedIndex;
277
- const sizes = this.manager.getFileSize(proc.id);
278
- const totalSize = sizes ? sizes.stdout + sizes.stderr : 0;
279
-
280
- const statusText = this.formatStatus(proc);
281
- const statusPadding =
282
- statusWidth + (statusText.length - visibleWidth(statusText));
283
-
284
- const row =
285
- (isSelected
286
- ? accent(proc.id.padEnd(idWidth))
287
- : proc.id.padEnd(idWidth)) +
288
- truncate(proc.name, nameWidth - 1).padEnd(nameWidth) +
289
- truncate(proc.command, cmdWidth - 1).padEnd(cmdWidth) +
290
- statusText.padEnd(statusPadding) +
291
- formatRuntime(proc.startTime, proc.endTime).padEnd(timeWidth) +
292
- formatBytes(totalSize).padStart(sizeWidth);
293
-
294
- if (isSelected) {
295
- lines.push(padLine(`${accent(">")} ${row}`));
296
- } else {
297
- lines.push(padLine(` ${row}`));
298
- }
299
- }
300
-
301
- for (let i = visibleProcessCount; i < maxVisibleProcesses; i++) {
302
- lines.push(padLine(""));
303
- }
304
-
305
- if (this.selectedIndex < processes.length) {
306
- const selected = processes[this.selectedIndex];
307
- if (!selected) {
308
- this.cachedLines = lines;
309
- this.cachedWidth = width;
310
- return this.cachedLines;
311
- }
312
- const output = this.manager.getOutput(selected.id, maxPreviewLines * 2);
313
- const sizes = this.manager.getFileSize(selected.id);
314
-
315
- lines.push(renderPanelRule(width, theme));
316
-
317
- const logTitlePlain = `Output: ${selected.name} (${selected.id})`;
318
- const sizeInfoPlain = sizes
319
- ? ` stdout: ${formatBytes(sizes.stdout)}, stderr: ${formatBytes(sizes.stderr)}`
320
- : "";
321
- const combinedPlain = logTitlePlain + sizeInfoPlain;
322
- // Truncate if combined exceeds innerWidth, prioritizing the title
323
- if (combinedPlain.length <= innerWidth) {
324
- const logTitle = `Output: ${accent(selected.name)} ${dim(`(${selected.id})`)}`;
325
- const sizeInfo = sizes ? dim(sizeInfoPlain) : "";
326
- lines.push(padLine(logTitle + sizeInfo));
327
- } else {
328
- const maxNameLen = Math.max(
329
- 8,
330
- innerWidth -
331
- (`Output: (${selected.id})`.length + sizeInfoPlain.length),
332
- );
333
- const tName = truncate(selected.name, maxNameLen);
334
- const logTitle = `Output: ${accent(tName)} ${dim(`(${selected.id})`)}`;
335
- const sizeInfo = sizes ? dim(sizeInfoPlain) : "";
336
- lines.push(padLine(logTitle + sizeInfo));
337
- }
338
- lines.push(padLine(""));
339
-
340
- let renderedLines = 0;
341
-
342
- if (output) {
343
- const logLines: { type: "stdout" | "stderr"; text: string }[] = [];
344
- for (const line of output.stdout) {
345
- logLines.push({ type: "stdout", text: line });
346
- }
347
- for (const line of output.stderr) {
348
- logLines.push({ type: "stderr", text: line });
349
- }
350
-
351
- if (logLines.length === 0) {
352
- lines.push(padLine(dim("(no output yet)")));
353
- renderedLines = 1;
354
- } else {
355
- const startIdx = Math.max(
356
- 0,
357
- logLines.length - maxPreviewLines - this.logScrollOffset,
358
- );
359
- const endIdx = Math.max(0, logLines.length - this.logScrollOffset);
360
- const visibleLines = logLines.slice(startIdx, endIdx);
361
-
362
- this.scrollInfo.above = startIdx;
363
- this.scrollInfo.below =
364
- this.logScrollOffset > 0 ? logLines.length - endIdx : 0;
365
-
366
- for (const line of visibleLines) {
367
- const displayLine = truncate(
368
- stripAnsi(line.text),
369
- innerWidth - 2,
370
- );
371
- if (line.type === "stderr") {
372
- lines.push(padLine(warning(displayLine)));
373
- } else {
374
- lines.push(padLine(displayLine));
375
- }
376
- renderedLines++;
377
- }
378
- }
379
- }
380
-
381
- while (renderedLines < maxPreviewLines) {
382
- lines.push(padLine(""));
383
- renderedLines++;
384
- }
385
- }
386
- }
387
-
388
- lines.push(renderPanelRule(width, theme));
389
-
390
- const footerLeft =
391
- `${dim("enter")} stream ` +
392
- `${dim("j/k")} select ` +
393
- `${dim("x")} term/kill ` +
394
- `${dim("c")} clear ` +
395
- `${dim("q")} quit`;
396
-
397
- let footerRight = "";
398
- if (this.scrollInfo.above > 0 || this.scrollInfo.below > 0) {
399
- const parts: string[] = [];
400
- if (this.scrollInfo.above > 0) {
401
- parts.push(`↑${this.scrollInfo.above}`);
402
- }
403
- if (this.scrollInfo.below > 0) {
404
- parts.push(`↓${this.scrollInfo.below}`);
405
- }
406
- footerRight = `${dim("J/K")} scroll ${dim(parts.join(" "))}`;
407
- }
408
-
409
- const footerLeftLen = visibleWidth(footerLeft);
410
- const footerRightLen = visibleWidth(footerRight);
411
- const footerGap = Math.max(2, innerWidth - footerLeftLen - footerRightLen);
412
- const footer = footerLeft + " ".repeat(footerGap) + footerRight;
413
-
414
- lines.push(padLine(footer));
415
- lines.push(renderPanelRule(width, theme));
416
-
417
- this.cachedLines = lines;
418
- this.cachedWidth = width;
419
-
420
- return this.cachedLines;
421
- }
422
-
423
- private formatStatus(proc: ProcessInfo): string {
424
- const theme = this.theme;
425
- const dim = (s: string) => theme.fg("dim", s);
426
- const success = (s: string) => theme.fg("success", s);
427
- const warning = (s: string) => theme.fg("warning", s);
428
- const error = (s: string) => theme.fg("error", s);
429
-
430
- const icon = statusIcon(proc.status, proc.success);
431
- const label = statusLabel(proc);
432
-
433
- switch (proc.status) {
434
- case "running":
435
- return success(`${icon} ${label}`);
436
- case "terminating":
437
- return warning(`${icon} ${label}`);
438
- case "terminate_timeout":
439
- return error(`${icon} ${label}`);
440
- case "killed":
441
- return warning(`${icon} ${label}`);
442
- case "exited":
443
- return proc.success
444
- ? dim(`${icon} ${label}`)
445
- : error(`${icon} ${label}`);
446
- default:
447
- return dim(`${icon} ${label}`);
448
- }
449
- }
450
- }
@@ -1,38 +0,0 @@
1
- import type { ProcessInfo, ProcessStatus } from "../constants";
2
-
3
- export function statusLabel(proc: ProcessInfo): string {
4
- switch (proc.status) {
5
- case "running":
6
- return "running";
7
- case "terminating":
8
- return "terminating";
9
- case "terminate_timeout":
10
- return "terminate_timeout";
11
- case "killed":
12
- return "killed";
13
- case "exited":
14
- return proc.success ? "exit(0)" : `exit(${proc.exitCode ?? "?"})`;
15
- default:
16
- return proc.status;
17
- }
18
- }
19
-
20
- export function statusIcon(
21
- status: ProcessStatus,
22
- success: boolean | null,
23
- ): string {
24
- switch (status) {
25
- case "running":
26
- return "\u25CF"; // filled circle
27
- case "terminating":
28
- return "\u25CF"; // filled circle
29
- case "terminate_timeout":
30
- return "\u2717"; // x mark
31
- case "exited":
32
- return success ? "\u2713" : "\u2717";
33
- case "killed":
34
- return "\u2717";
35
- default:
36
- return "?";
37
- }
38
- }
package/config.ts DELETED
@@ -1,62 +0,0 @@
1
- /**
2
- * Configuration for the processes extension.
3
- *
4
- * Global: ~/.pi/agent/extensions/processes.json
5
- * Memory: ephemeral overrides via /process:settings
6
- */
7
-
8
- import { ConfigLoader } from "@aliou/pi-utils-settings";
9
-
10
- export interface ProcessesConfig {
11
- processList?: {
12
- /** Max visible processes in the /process:list TUI list. */
13
- maxVisibleProcesses?: number;
14
- /** Max log preview lines shown below the selected process. */
15
- maxPreviewLines?: number;
16
- };
17
- output?: {
18
- /** Default number of tail lines returned to the agent. */
19
- defaultTailLines?: number;
20
- /** Hard cap on output lines returned to the agent. */
21
- maxOutputLines?: number;
22
- };
23
- widget?: {
24
- /** Show the status widget below the editor. */
25
- showStatusWidget?: boolean;
26
- };
27
- }
28
-
29
- export interface ResolvedProcessesConfig {
30
- processList: {
31
- maxVisibleProcesses: number;
32
- maxPreviewLines: number;
33
- };
34
- output: {
35
- defaultTailLines: number;
36
- maxOutputLines: number;
37
- };
38
- widget: {
39
- showStatusWidget: boolean;
40
- };
41
- }
42
-
43
- const DEFAULT_CONFIG: ResolvedProcessesConfig = {
44
- processList: {
45
- maxVisibleProcesses: 8,
46
- maxPreviewLines: 12,
47
- },
48
- output: {
49
- defaultTailLines: 100,
50
- maxOutputLines: 200,
51
- },
52
- widget: {
53
- showStatusWidget: true,
54
- },
55
- };
56
-
57
- export const configLoader = new ConfigLoader<
58
- ProcessesConfig,
59
- ResolvedProcessesConfig
60
- >("process", DEFAULT_CONFIG, {
61
- scopes: ["global", "memory"],
62
- });
@@ -1,11 +0,0 @@
1
- export type {
2
- ExecuteResult,
3
- KillResult,
4
- ManagerEvent,
5
- ProcessesDetails,
6
- ProcessInfo,
7
- ProcessStatus,
8
- StartOptions,
9
- } from "./types";
10
-
11
- export { LIVE_STATUSES, MESSAGE_TYPE_PROCESS_UPDATE } from "./types";
@@ -1,65 +0,0 @@
1
- // Custom message type for process update notifications
2
- export const MESSAGE_TYPE_PROCESS_UPDATE = "ad-process:update";
3
-
4
- export type ProcessStatus =
5
- | "running"
6
- | "terminating"
7
- | "terminate_timeout"
8
- | "exited"
9
- | "killed";
10
-
11
- export const LIVE_STATUSES: ReadonlySet<ProcessStatus> = new Set([
12
- "running",
13
- "terminating",
14
- "terminate_timeout",
15
- ]);
16
-
17
- export interface ProcessInfo {
18
- id: string;
19
- name: string;
20
- pid: number; // On Unix, this is also the PGID (process group leader)
21
- command: string;
22
- cwd: string;
23
- startTime: number;
24
- endTime: number | null;
25
- status: ProcessStatus;
26
- exitCode: number | null;
27
- success: boolean | null; // null if running, true if exit code 0, false otherwise
28
- stdoutFile: string;
29
- stderrFile: string;
30
- alertOnSuccess: boolean;
31
- alertOnFailure: boolean;
32
- alertOnKill: boolean;
33
- }
34
-
35
- export type ManagerEvent =
36
- | { type: "process_started"; info: ProcessInfo }
37
- | { type: "process_status_changed"; info: ProcessInfo; prev: ProcessStatus }
38
- | { type: "process_ended"; info: ProcessInfo }
39
- | { type: "processes_changed" };
40
-
41
- export type KillResult =
42
- | { ok: true; info: ProcessInfo }
43
- | { ok: false; info: ProcessInfo; reason: "not_found" | "timeout" | "error" };
44
-
45
- export interface StartOptions {
46
- alertOnSuccess?: boolean;
47
- alertOnFailure?: boolean;
48
- alertOnKill?: boolean;
49
- }
50
-
51
- export interface ProcessesDetails {
52
- action: string;
53
- success: boolean;
54
- message: string;
55
- process?: ProcessInfo;
56
- processes?: ProcessInfo[];
57
- output?: { stdout: string[]; stderr: string[]; status: string };
58
- logFiles?: { stdoutFile: string; stderrFile: string };
59
- cleared?: number;
60
- }
61
-
62
- export interface ExecuteResult {
63
- content: Array<{ type: "text"; text: string }>;
64
- details: ProcessesDetails;
65
- }
package/hooks/cleanup.ts DELETED
@@ -1,10 +0,0 @@
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", () => {
6
- manager.stopWatcher();
7
- manager.shutdownKillAll();
8
- manager.cleanup();
9
- });
10
- }
package/hooks/index.ts DELETED
@@ -1,18 +0,0 @@
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
- const widget = setupProcessWidget(pi, manager);
14
-
15
- setupMessageRenderer(pi);
16
-
17
- return widget;
18
- }