@aliou/pi-processes 0.3.4 → 0.4.2
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/commands/index.ts +282 -16
- package/commands/settings-command.ts +128 -0
- package/components/log-stream-component.ts +149 -0
- package/components/process-picker-component.ts +155 -0
- package/components/processes-component.ts +450 -0
- package/components/status-format.ts +38 -0
- package/config.ts +62 -0
- package/constants/index.ts +11 -0
- package/constants/types.ts +65 -0
- package/hooks/index.ts +3 -1
- package/hooks/widget.ts +6 -0
- package/index.ts +9 -3
- package/manager.ts +41 -0
- package/package.json +7 -2
- package/tools/actions/kill.ts +1 -1
- package/tools/actions/output.ts +8 -5
- package/tools/index.ts +107 -87
- package/utils/ansi.ts +36 -0
- package/utils/format.ts +42 -0
- package/utils/index.ts +3 -0
- package/utils/process-group.ts +22 -0
package/commands/index.ts
CHANGED
|
@@ -1,29 +1,73 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
Theme,
|
|
5
|
+
} from "@mariozechner/pi-coding-agent";
|
|
2
6
|
import { LogStreamComponent } from "../components/log-stream-component";
|
|
7
|
+
import { ProcessPickerComponent } from "../components/process-picker-component";
|
|
3
8
|
import { ProcessesComponent } from "../components/processes-component";
|
|
9
|
+
import { LIVE_STATUSES, type ProcessInfo } from "../constants";
|
|
4
10
|
import type { ProcessManager } from "../manager";
|
|
5
11
|
|
|
6
12
|
const LOG_STREAM_WIDGET_ID = "processes-log-stream";
|
|
7
13
|
|
|
14
|
+
function runningProcessCompletions(manager: ProcessManager) {
|
|
15
|
+
return (prefix: string) => {
|
|
16
|
+
const processes = manager.list();
|
|
17
|
+
const lower = prefix.toLowerCase();
|
|
18
|
+
return processes
|
|
19
|
+
.filter(
|
|
20
|
+
(p) =>
|
|
21
|
+
LIVE_STATUSES.has(p.status) &&
|
|
22
|
+
(p.id.toLowerCase().startsWith(lower) ||
|
|
23
|
+
p.name.toLowerCase().startsWith(lower)),
|
|
24
|
+
)
|
|
25
|
+
.map((p) => ({
|
|
26
|
+
value: p.id,
|
|
27
|
+
label: p.id,
|
|
28
|
+
description: p.name,
|
|
29
|
+
}));
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function allProcessCompletions(manager: ProcessManager) {
|
|
34
|
+
return (prefix: string) => {
|
|
35
|
+
const processes = manager.list();
|
|
36
|
+
const lower = prefix.toLowerCase();
|
|
37
|
+
return processes
|
|
38
|
+
.filter(
|
|
39
|
+
(p) =>
|
|
40
|
+
p.id.toLowerCase().startsWith(lower) ||
|
|
41
|
+
p.name.toLowerCase().startsWith(lower),
|
|
42
|
+
)
|
|
43
|
+
.map((p) => ({
|
|
44
|
+
value: p.id,
|
|
45
|
+
label: p.id,
|
|
46
|
+
description: p.name,
|
|
47
|
+
}));
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
8
51
|
export function setupProcessesCommands(
|
|
9
52
|
pi: ExtensionAPI,
|
|
10
53
|
manager: ProcessManager,
|
|
11
|
-
) {
|
|
12
|
-
|
|
13
|
-
let isStreaming = false;
|
|
54
|
+
): void {
|
|
55
|
+
let streamingProcessId: string | null = null;
|
|
14
56
|
|
|
15
|
-
|
|
57
|
+
// ── /process:list ──────────────────────────────────────────────────
|
|
58
|
+
// Registered first so it appears first in autocomplete.
|
|
59
|
+
pi.registerCommand("process:list", {
|
|
16
60
|
description: "View and manage background processes",
|
|
17
61
|
handler: async (_args, ctx) => {
|
|
18
62
|
if (!ctx.hasUI) {
|
|
19
|
-
ctx.ui.notify("/
|
|
63
|
+
ctx.ui.notify("/process:list requires interactive mode", "error");
|
|
20
64
|
return;
|
|
21
65
|
}
|
|
22
66
|
|
|
23
67
|
// If currently streaming, dismiss the stream widget and show the list.
|
|
24
|
-
if (
|
|
68
|
+
if (streamingProcessId) {
|
|
25
69
|
ctx.ui.setWidget(LOG_STREAM_WIDGET_ID, undefined);
|
|
26
|
-
|
|
70
|
+
streamingProcessId = null;
|
|
27
71
|
}
|
|
28
72
|
|
|
29
73
|
const result = await ctx.ui.custom<string | null>(
|
|
@@ -45,7 +89,7 @@ export function setupProcessesCommands(
|
|
|
45
89
|
|
|
46
90
|
// RPC fallback.
|
|
47
91
|
if (result === undefined) {
|
|
48
|
-
ctx.ui.notify("/
|
|
92
|
+
ctx.ui.notify("/process:list requires interactive mode", "info");
|
|
49
93
|
return;
|
|
50
94
|
}
|
|
51
95
|
|
|
@@ -55,14 +99,236 @@ export function setupProcessesCommands(
|
|
|
55
99
|
}
|
|
56
100
|
|
|
57
101
|
// User selected a process — start streaming its logs.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
102
|
+
startStreaming(ctx.ui, manager, result);
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── /process:stream [id|name] ──────────────────────────────────────
|
|
107
|
+
pi.registerCommand("process:stream", {
|
|
108
|
+
description: "Stream logs from a running process",
|
|
109
|
+
getArgumentCompletions: runningProcessCompletions(manager),
|
|
110
|
+
handler: async (args, ctx) => {
|
|
111
|
+
const arg = args.trim();
|
|
112
|
+
|
|
113
|
+
// Explicit argument: stream that process.
|
|
114
|
+
if (arg) {
|
|
115
|
+
const proc = manager.find(arg);
|
|
116
|
+
if (!proc) {
|
|
117
|
+
ctx.ui.notify(`Process not found: ${arg}`, "error");
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!LIVE_STATUSES.has(proc.status)) {
|
|
121
|
+
ctx.ui.notify(
|
|
122
|
+
`${proc.name} (${proc.id}) is not running (${proc.status})`,
|
|
123
|
+
"info",
|
|
124
|
+
);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
startStreaming(ctx.ui, manager, proc.id);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// No argument + currently streaming: dismiss.
|
|
132
|
+
if (streamingProcessId) {
|
|
133
|
+
ctx.ui.setWidget(LOG_STREAM_WIDGET_ID, undefined);
|
|
134
|
+
streamingProcessId = null;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// No argument + not streaming: pick from running processes.
|
|
139
|
+
const running = manager.list().filter((p) => LIVE_STATUSES.has(p.status));
|
|
140
|
+
|
|
141
|
+
// No running processes.
|
|
142
|
+
if (running.length === 0) {
|
|
143
|
+
ctx.ui.notify("No running processes", "info");
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Single running process: auto-select.
|
|
148
|
+
if (running.length === 1 && running[0]) {
|
|
149
|
+
startStreaming(ctx.ui, manager, running[0].id);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Multiple running processes: show picker.
|
|
154
|
+
const processId = await pickProcess(
|
|
155
|
+
ctx,
|
|
156
|
+
manager,
|
|
157
|
+
"Select process to stream",
|
|
158
|
+
(p) => LIVE_STATUSES.has(p.status),
|
|
159
|
+
);
|
|
160
|
+
if (!processId) return;
|
|
161
|
+
startStreaming(ctx.ui, manager, processId);
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── /process:logs [id|name] ────────────────────────────────────────
|
|
166
|
+
pi.registerCommand("process:logs", {
|
|
167
|
+
description: "Show log file paths for a process",
|
|
168
|
+
getArgumentCompletions: allProcessCompletions(manager),
|
|
169
|
+
handler: async (args, ctx) => {
|
|
170
|
+
const arg = args.trim();
|
|
171
|
+
|
|
172
|
+
let processId: string | undefined;
|
|
173
|
+
|
|
174
|
+
if (arg) {
|
|
175
|
+
const proc = manager.find(arg);
|
|
176
|
+
if (!proc) {
|
|
177
|
+
ctx.ui.notify(`Process not found: ${arg}`, "error");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
processId = proc.id;
|
|
181
|
+
} else {
|
|
182
|
+
// No argument: show picker.
|
|
183
|
+
processId = await pickProcess(ctx, manager, "Select process for logs");
|
|
184
|
+
if (!processId) return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const logFiles = manager.getLogFiles(processId);
|
|
188
|
+
const proc = manager.get(processId);
|
|
189
|
+
if (!logFiles || !proc) {
|
|
190
|
+
ctx.ui.notify(`Process not found: ${processId}`, "error");
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
ctx.ui.notify(
|
|
195
|
+
`${proc.name} (${proc.id})\nstdout: ${logFiles.stdoutFile}\nstderr: ${logFiles.stderrFile}`,
|
|
196
|
+
"info",
|
|
65
197
|
);
|
|
66
198
|
},
|
|
67
199
|
});
|
|
200
|
+
|
|
201
|
+
// ── /process:kill [id|name] ────────────────────────────────────────
|
|
202
|
+
pi.registerCommand("process:kill", {
|
|
203
|
+
description: "Kill a running background process",
|
|
204
|
+
getArgumentCompletions: runningProcessCompletions(manager),
|
|
205
|
+
handler: async (args, ctx) => {
|
|
206
|
+
const arg = args.trim();
|
|
207
|
+
|
|
208
|
+
let processId: string | undefined;
|
|
209
|
+
|
|
210
|
+
if (arg) {
|
|
211
|
+
const proc = manager.find(arg);
|
|
212
|
+
if (!proc) {
|
|
213
|
+
ctx.ui.notify(`Process not found: ${arg}`, "error");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (!LIVE_STATUSES.has(proc.status)) {
|
|
217
|
+
ctx.ui.notify(
|
|
218
|
+
`${proc.name} (${proc.id}) is not running (${proc.status})`,
|
|
219
|
+
"info",
|
|
220
|
+
);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
processId = proc.id;
|
|
224
|
+
} else {
|
|
225
|
+
// No argument: show picker (only running processes).
|
|
226
|
+
const running = manager
|
|
227
|
+
.list()
|
|
228
|
+
.filter((p) => LIVE_STATUSES.has(p.status));
|
|
229
|
+
|
|
230
|
+
if (running.length === 0) {
|
|
231
|
+
ctx.ui.notify("No running processes to kill", "info");
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (running.length === 1 && running[0]) {
|
|
236
|
+
processId = running[0].id;
|
|
237
|
+
} else {
|
|
238
|
+
processId = await pickProcess(
|
|
239
|
+
ctx,
|
|
240
|
+
manager,
|
|
241
|
+
"Select process to kill",
|
|
242
|
+
(p) => LIVE_STATUSES.has(p.status),
|
|
243
|
+
);
|
|
244
|
+
if (!processId) return;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const proc = manager.get(processId);
|
|
249
|
+
if (!proc) {
|
|
250
|
+
ctx.ui.notify(`Process not found: ${processId}`, "error");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const signal =
|
|
255
|
+
proc.status === "terminate_timeout" ? "SIGKILL" : "SIGTERM";
|
|
256
|
+
const timeoutMs = signal === "SIGKILL" ? 200 : 3000;
|
|
257
|
+
const result = await manager.kill(processId, { signal, timeoutMs });
|
|
258
|
+
|
|
259
|
+
if (result.ok) {
|
|
260
|
+
ctx.ui.notify(`Killed ${proc.name} (${proc.id})`, "info");
|
|
261
|
+
} else {
|
|
262
|
+
ctx.ui.notify(
|
|
263
|
+
`Failed to kill ${proc.name} (${proc.id}): ${result.reason}`,
|
|
264
|
+
"error",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ── /process:clear ─────────────────────────────────────────────────
|
|
271
|
+
pi.registerCommand("process:clear", {
|
|
272
|
+
description: "Clear finished processes",
|
|
273
|
+
handler: async (_args, ctx) => {
|
|
274
|
+
const cleared = manager.clearFinished();
|
|
275
|
+
if (cleared > 0) {
|
|
276
|
+
ctx.ui.notify(
|
|
277
|
+
`Cleared ${cleared} finished process${cleared > 1 ? "es" : ""}`,
|
|
278
|
+
"info",
|
|
279
|
+
);
|
|
280
|
+
} else {
|
|
281
|
+
ctx.ui.notify("No finished processes to clear", "info");
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ── Helpers ────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
function startStreaming(
|
|
289
|
+
ui: ExtensionCommandContext["ui"],
|
|
290
|
+
mgr: ProcessManager,
|
|
291
|
+
processId: string,
|
|
292
|
+
) {
|
|
293
|
+
streamingProcessId = processId;
|
|
294
|
+
ui.setWidget(
|
|
295
|
+
LOG_STREAM_WIDGET_ID,
|
|
296
|
+
(tui: { requestRender: () => void }, theme: Theme) => {
|
|
297
|
+
return new LogStreamComponent(tui, theme, mgr, processId);
|
|
298
|
+
},
|
|
299
|
+
{ placement: "aboveEditor" },
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function pickProcess(
|
|
305
|
+
ctx: ExtensionCommandContext,
|
|
306
|
+
manager: ProcessManager,
|
|
307
|
+
title: string,
|
|
308
|
+
filter?: (proc: ProcessInfo) => boolean,
|
|
309
|
+
): Promise<string | undefined> {
|
|
310
|
+
if (!ctx.hasUI) {
|
|
311
|
+
ctx.ui.notify("Interactive mode required", "error");
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
316
|
+
return new ProcessPickerComponent(
|
|
317
|
+
tui,
|
|
318
|
+
theme,
|
|
319
|
+
(processId?: string) => {
|
|
320
|
+
done(processId ?? null);
|
|
321
|
+
},
|
|
322
|
+
manager,
|
|
323
|
+
title,
|
|
324
|
+
filter,
|
|
325
|
+
);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// RPC fallback or user cancelled.
|
|
329
|
+
if (result === undefined || result === null) {
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return result;
|
|
68
334
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
registerSettingsCommand,
|
|
3
|
+
type SettingsSection,
|
|
4
|
+
} from "@aliou/pi-utils-settings";
|
|
5
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import type { ProcessesConfig, ResolvedProcessesConfig } from "../config";
|
|
7
|
+
import { configLoader } from "../config";
|
|
8
|
+
|
|
9
|
+
export function registerProcessesSettings(
|
|
10
|
+
pi: ExtensionAPI,
|
|
11
|
+
onSave?: () => void,
|
|
12
|
+
): void {
|
|
13
|
+
registerSettingsCommand<ProcessesConfig, ResolvedProcessesConfig>(pi, {
|
|
14
|
+
commandName: "process:settings",
|
|
15
|
+
title: "Processes Settings",
|
|
16
|
+
configStore: configLoader,
|
|
17
|
+
buildSections: (
|
|
18
|
+
tabConfig: ProcessesConfig | null,
|
|
19
|
+
resolved: ResolvedProcessesConfig,
|
|
20
|
+
): SettingsSection[] => {
|
|
21
|
+
return [
|
|
22
|
+
{
|
|
23
|
+
label: "Process List",
|
|
24
|
+
items: [
|
|
25
|
+
{
|
|
26
|
+
id: "processList.maxVisibleProcesses",
|
|
27
|
+
label: "Max visible processes",
|
|
28
|
+
description:
|
|
29
|
+
"Maximum processes shown in the /processes list before scrolling",
|
|
30
|
+
currentValue: String(
|
|
31
|
+
tabConfig?.processList?.maxVisibleProcesses ??
|
|
32
|
+
resolved.processList.maxVisibleProcesses,
|
|
33
|
+
),
|
|
34
|
+
values: ["4", "6", "8", "12", "16"],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "processList.maxPreviewLines",
|
|
38
|
+
label: "Max preview lines",
|
|
39
|
+
description: "Log preview lines shown below the selected process",
|
|
40
|
+
currentValue: String(
|
|
41
|
+
tabConfig?.processList?.maxPreviewLines ??
|
|
42
|
+
resolved.processList.maxPreviewLines,
|
|
43
|
+
),
|
|
44
|
+
values: ["6", "8", "12", "16", "24"],
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
label: "Output Limits",
|
|
50
|
+
items: [
|
|
51
|
+
{
|
|
52
|
+
id: "output.defaultTailLines",
|
|
53
|
+
label: "Default tail lines",
|
|
54
|
+
description:
|
|
55
|
+
"Number of tail lines returned to the agent by default",
|
|
56
|
+
currentValue: String(
|
|
57
|
+
tabConfig?.output?.defaultTailLines ??
|
|
58
|
+
resolved.output.defaultTailLines,
|
|
59
|
+
),
|
|
60
|
+
values: ["50", "100", "200", "500"],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "output.maxOutputLines",
|
|
64
|
+
label: "Max output lines",
|
|
65
|
+
description: "Hard cap on output lines returned to the agent",
|
|
66
|
+
currentValue: String(
|
|
67
|
+
tabConfig?.output?.maxOutputLines ??
|
|
68
|
+
resolved.output.maxOutputLines,
|
|
69
|
+
),
|
|
70
|
+
values: ["100", "200", "500", "1000"],
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
label: "Widget",
|
|
76
|
+
items: [
|
|
77
|
+
{
|
|
78
|
+
id: "widget.showStatusWidget",
|
|
79
|
+
label: "Show status widget",
|
|
80
|
+
description: "Show process status widget below the editor",
|
|
81
|
+
currentValue:
|
|
82
|
+
(tabConfig?.widget?.showStatusWidget ??
|
|
83
|
+
resolved.widget.showStatusWidget)
|
|
84
|
+
? "on"
|
|
85
|
+
: "off",
|
|
86
|
+
values: ["on", "off"],
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
},
|
|
92
|
+
onSettingChange: (id, newValue, config) => {
|
|
93
|
+
const updated = structuredClone(config);
|
|
94
|
+
// Boolean fields.
|
|
95
|
+
if (id === "widget.showStatusWidget") {
|
|
96
|
+
if (!updated.widget) updated.widget = {};
|
|
97
|
+
updated.widget.showStatusWidget = newValue === "on";
|
|
98
|
+
return updated;
|
|
99
|
+
}
|
|
100
|
+
// Numeric fields.
|
|
101
|
+
const num = Number.parseInt(newValue, 10);
|
|
102
|
+
if (Number.isNaN(num)) return null;
|
|
103
|
+
|
|
104
|
+
switch (id) {
|
|
105
|
+
case "processList.maxVisibleProcesses":
|
|
106
|
+
if (!updated.processList) updated.processList = {};
|
|
107
|
+
updated.processList.maxVisibleProcesses = num;
|
|
108
|
+
break;
|
|
109
|
+
case "processList.maxPreviewLines":
|
|
110
|
+
if (!updated.processList) updated.processList = {};
|
|
111
|
+
updated.processList.maxPreviewLines = num;
|
|
112
|
+
break;
|
|
113
|
+
case "output.defaultTailLines":
|
|
114
|
+
if (!updated.output) updated.output = {};
|
|
115
|
+
updated.output.defaultTailLines = num;
|
|
116
|
+
break;
|
|
117
|
+
case "output.maxOutputLines":
|
|
118
|
+
if (!updated.output) updated.output = {};
|
|
119
|
+
updated.output.maxOutputLines = num;
|
|
120
|
+
break;
|
|
121
|
+
default:
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return updated;
|
|
125
|
+
},
|
|
126
|
+
onSave,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
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 {
|
|
8
|
+
type Component,
|
|
9
|
+
truncateToWidth,
|
|
10
|
+
visibleWidth,
|
|
11
|
+
} from "@mariozechner/pi-tui";
|
|
12
|
+
import type { ProcessManager } from "../manager";
|
|
13
|
+
import { stripAnsi } from "../utils";
|
|
14
|
+
import { statusIcon, statusLabel } from "./status-format";
|
|
15
|
+
|
|
16
|
+
const MAX_LOG_LINES = 16;
|
|
17
|
+
const POLL_INTERVAL_MS = 500;
|
|
18
|
+
|
|
19
|
+
export class LogStreamComponent implements Component {
|
|
20
|
+
private tui: { requestRender: () => void };
|
|
21
|
+
private theme: Theme;
|
|
22
|
+
private manager: ProcessManager;
|
|
23
|
+
private processId: string;
|
|
24
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
25
|
+
private unsubscribe: (() => void) | null = null;
|
|
26
|
+
private cachedLines: string[] = [];
|
|
27
|
+
private cachedWidth = 0;
|
|
28
|
+
|
|
29
|
+
constructor(
|
|
30
|
+
tui: { requestRender: () => void },
|
|
31
|
+
theme: Theme,
|
|
32
|
+
manager: ProcessManager,
|
|
33
|
+
processId: string,
|
|
34
|
+
) {
|
|
35
|
+
this.tui = tui;
|
|
36
|
+
this.theme = theme;
|
|
37
|
+
this.manager = manager;
|
|
38
|
+
this.processId = processId;
|
|
39
|
+
|
|
40
|
+
// Poll log file for new output.
|
|
41
|
+
this.timer = setInterval(() => {
|
|
42
|
+
this.invalidate();
|
|
43
|
+
this.tui.requestRender();
|
|
44
|
+
}, POLL_INTERVAL_MS);
|
|
45
|
+
|
|
46
|
+
// Also re-render on process events (status changes, etc.).
|
|
47
|
+
this.unsubscribe = this.manager.onEvent(() => {
|
|
48
|
+
this.invalidate();
|
|
49
|
+
this.tui.requestRender();
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
handleInput(_data: string): boolean {
|
|
54
|
+
// Widget doesn't handle input — the editor is still active.
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
invalidate(): void {
|
|
59
|
+
this.cachedWidth = 0;
|
|
60
|
+
this.cachedLines = [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
render(width: number): string[] {
|
|
64
|
+
if (width === this.cachedWidth && this.cachedLines.length > 0) {
|
|
65
|
+
return this.cachedLines;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const theme = this.theme;
|
|
69
|
+
const dim = (s: string) => theme.fg("dim", s);
|
|
70
|
+
const warning = (s: string) => theme.fg("warning", s);
|
|
71
|
+
const innerWidth = width - 2;
|
|
72
|
+
|
|
73
|
+
const basePadLine = createPanelPadder(width);
|
|
74
|
+
const padLine = (content: string): string => {
|
|
75
|
+
const contentWidth = visibleWidth(content);
|
|
76
|
+
return basePadLine(
|
|
77
|
+
contentWidth > innerWidth
|
|
78
|
+
? truncateToWidth(content, innerWidth)
|
|
79
|
+
: content,
|
|
80
|
+
);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const lines: string[] = [];
|
|
84
|
+
const proc = this.manager.get(this.processId);
|
|
85
|
+
|
|
86
|
+
if (!proc) {
|
|
87
|
+
lines.push(renderPanelRule(width, theme));
|
|
88
|
+
lines.push(padLine(warning("Process not found")));
|
|
89
|
+
lines.push(renderPanelRule(width, theme));
|
|
90
|
+
this.cachedLines = lines;
|
|
91
|
+
this.cachedWidth = width;
|
|
92
|
+
return this.cachedLines;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Header
|
|
96
|
+
const icon = statusIcon(proc.status, proc.success);
|
|
97
|
+
const label = statusLabel(proc);
|
|
98
|
+
lines.push(
|
|
99
|
+
renderPanelTitleLine(
|
|
100
|
+
`Process: ${proc.name} (${proc.id}) ${icon} ${label}`,
|
|
101
|
+
width,
|
|
102
|
+
theme,
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Log lines (interleaved stdout + stderr in temporal order).
|
|
107
|
+
const logLines = this.manager.getCombinedOutput(
|
|
108
|
+
this.processId,
|
|
109
|
+
MAX_LOG_LINES,
|
|
110
|
+
);
|
|
111
|
+
if (logLines && logLines.length > 0) {
|
|
112
|
+
for (const line of logLines) {
|
|
113
|
+
const cleaned = stripAnsi(line.text);
|
|
114
|
+
const display = truncateToWidth(cleaned, innerWidth - 2);
|
|
115
|
+
if (line.type === "stderr") {
|
|
116
|
+
lines.push(padLine(warning(display)));
|
|
117
|
+
} else {
|
|
118
|
+
lines.push(padLine(display));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
lines.push(padLine(dim("(no output yet)")));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Pad to MAX_LOG_LINES for stable height.
|
|
126
|
+
const renderedLogLines = lines.length - 1; // minus header
|
|
127
|
+
for (let i = renderedLogLines; i < MAX_LOG_LINES; i++) {
|
|
128
|
+
lines.push(padLine(""));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Footer hint
|
|
132
|
+
lines.push(renderPanelRule(width, theme));
|
|
133
|
+
lines.push(padLine(dim("Run /process:stream to dismiss")));
|
|
134
|
+
lines.push(renderPanelRule(width, theme));
|
|
135
|
+
|
|
136
|
+
this.cachedLines = lines;
|
|
137
|
+
this.cachedWidth = width;
|
|
138
|
+
return this.cachedLines;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
dispose(): void {
|
|
142
|
+
if (this.timer) {
|
|
143
|
+
clearInterval(this.timer);
|
|
144
|
+
this.timer = null;
|
|
145
|
+
}
|
|
146
|
+
this.unsubscribe?.();
|
|
147
|
+
this.unsubscribe = null;
|
|
148
|
+
}
|
|
149
|
+
}
|