@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 +63 -0
- package/commands/index.ts +488 -0
- package/constants.ts +2 -0
- package/hooks/cleanup.ts +8 -0
- package/hooks/index.ts +16 -0
- package/hooks/message-renderer.ts +83 -0
- package/hooks/process-end.ts +103 -0
- package/hooks/widget.ts +144 -0
- package/index.ts +13 -0
- package/manager.ts +405 -0
- package/package.json +35 -0
- package/tools/index.ts +532 -0
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
package/hooks/cleanup.ts
ADDED
|
@@ -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
|
+
}
|