@mjakl/pi-processes 0.8.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 +137 -0
- package/package.json +68 -0
- package/src/commands/index.ts +10 -0
- package/src/commands/processes/command.ts +31 -0
- package/src/commands/processes/index.ts +1 -0
- package/src/components/log-file-viewer.ts +317 -0
- package/src/components/processes-component.ts +458 -0
- package/src/config.ts +125 -0
- package/src/constants/index.ts +11 -0
- package/src/constants/types.ts +65 -0
- package/src/hooks/background-blocker.ts +295 -0
- package/src/hooks/cleanup.ts +8 -0
- package/src/hooks/index.ts +24 -0
- package/src/hooks/message-renderer.ts +83 -0
- package/src/hooks/process-end.ts +86 -0
- package/src/hooks/status-widget.ts +65 -0
- package/src/index.ts +25 -0
- package/src/manager.ts +513 -0
- package/src/tools/actions/clear.ts +20 -0
- package/src/tools/actions/index.ts +48 -0
- package/src/tools/actions/kill.ts +107 -0
- package/src/tools/actions/list.ts +43 -0
- package/src/tools/actions/logs.ts +85 -0
- package/src/tools/actions/output.ts +186 -0
- package/src/tools/actions/start.ts +70 -0
- package/src/tools/index.ts +347 -0
- package/src/tools/tool-rendering.ts +164 -0
- package/src/utils/ansi.ts +36 -0
- package/src/utils/command-executor.ts +56 -0
- package/src/utils/format.ts +42 -0
- package/src/utils/index.ts +3 -0
- package/src/utils/process-group.ts +22 -0
- package/src/utils/shell-utils.ts +133 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentToolResult,
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
Theme,
|
|
5
|
+
ToolRenderResultOptions,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
8
|
+
import { type Static, Type } from "typebox";
|
|
9
|
+
import type { ProcessesDetails } from "../constants";
|
|
10
|
+
import type { ProcessManager } from "../manager";
|
|
11
|
+
import { formatRuntime, hasAnsi, stripAnsi, truncateCmd } from "../utils";
|
|
12
|
+
import { executeAction } from "./actions";
|
|
13
|
+
import { ToolBody, ToolCallHeader, ToolFooter } from "./tool-rendering";
|
|
14
|
+
|
|
15
|
+
const ProcessesParams = Type.Object({
|
|
16
|
+
action: Type.Union(
|
|
17
|
+
[
|
|
18
|
+
Type.Literal("start"),
|
|
19
|
+
Type.Literal("list"),
|
|
20
|
+
Type.Literal("output"),
|
|
21
|
+
Type.Literal("logs"),
|
|
22
|
+
Type.Literal("kill"),
|
|
23
|
+
Type.Literal("clear"),
|
|
24
|
+
],
|
|
25
|
+
{
|
|
26
|
+
description:
|
|
27
|
+
"Action: start (run command), list (show all), output (get recent output), logs (get log file paths), kill (terminate or force-kill), clear (remove finished)",
|
|
28
|
+
},
|
|
29
|
+
),
|
|
30
|
+
command: Type.Optional(
|
|
31
|
+
Type.String({ description: "Command to run (required for start)" }),
|
|
32
|
+
),
|
|
33
|
+
name: Type.Optional(
|
|
34
|
+
Type.String({
|
|
35
|
+
description:
|
|
36
|
+
"Friendly name for the process (required for start, e.g. 'backend-dev', 'test-runner')",
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
39
|
+
id: Type.Optional(
|
|
40
|
+
Type.String({
|
|
41
|
+
description:
|
|
42
|
+
"Exact process ID or exact friendly name to match (required for output/kill/logs).",
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
45
|
+
force: Type.Optional(
|
|
46
|
+
Type.Boolean({
|
|
47
|
+
description:
|
|
48
|
+
"Force-kill the process with SIGKILL for kill action. Use after a normal terminate times out, or when you need an immediate hard stop.",
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
continueAfterStart: Type.Optional(
|
|
52
|
+
Type.Boolean({
|
|
53
|
+
description:
|
|
54
|
+
"For start only. Leave unset/false when the next step is to wait for process completion; process start will end this agent turn and the extension will resume you on exit. Set true only when you have immediate, specific, non-polling work to do after starting.",
|
|
55
|
+
}),
|
|
56
|
+
),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
type ProcessesParamsType = Static<typeof ProcessesParams>;
|
|
60
|
+
|
|
61
|
+
export function setupProcessesTools(pi: ExtensionAPI, manager: ProcessManager) {
|
|
62
|
+
pi.registerTool<typeof ProcessesParams, ProcessesDetails>({
|
|
63
|
+
name: "process",
|
|
64
|
+
label: "Process",
|
|
65
|
+
description: `Manage background processes.
|
|
66
|
+
|
|
67
|
+
Actions: start, list, output, logs, kill, clear.
|
|
68
|
+
- start requires 'name' and 'command'
|
|
69
|
+
- output/logs/kill require 'id' (exact process ID or exact friendly name)
|
|
70
|
+
- kill supports optional 'force=true' for SIGKILL
|
|
71
|
+
- start supports optional 'continueAfterStart=true' only when there is immediate non-polling work to do
|
|
72
|
+
|
|
73
|
+
Important behavior:
|
|
74
|
+
- By default, 'start' ends the current agent turn. If the next step is waiting, call 'start' by itself and wait for the automatic process-end notification instead of calling 'list', 'output', or 'logs' repeatedly.
|
|
75
|
+
- Set 'continueAfterStart=true' only when you have specific useful work to do immediately after starting the process; do not use it to poll.
|
|
76
|
+
- This tool is event-driven: the agent is notified automatically when a process exits, fails, or is externally killed.
|
|
77
|
+
- Tool-triggered kills never notify.
|
|
78
|
+
- Use 'output' or 'logs' only on demand: when the user asks, when you need a one-off diagnostic snapshot, or when investigating a problem.
|
|
79
|
+
|
|
80
|
+
Preferred pattern: start the process once, let the turn stop, and resume from the automatic notification instead of polling.`,
|
|
81
|
+
promptSnippet:
|
|
82
|
+
"Start and manage background processes without blocking the conversation; process start waits for notifications by default",
|
|
83
|
+
promptGuidelines: [
|
|
84
|
+
"Use the process tool instead of bash for dev servers, watch mode, log tails, port-forwards, or commands that should keep running.",
|
|
85
|
+
"After process start, do not call process list/output/logs just to wait. If the next step is waiting, call process start by itself; by default it ends the turn and the extension will resume the agent when the process exits.",
|
|
86
|
+
"Set process continueAfterStart=true only when there is immediate, specific, non-polling work to do after start.",
|
|
87
|
+
"Use process output or process logs only for a one-off inspection, explicit user request, or debugging.",
|
|
88
|
+
],
|
|
89
|
+
|
|
90
|
+
parameters: ProcessesParams,
|
|
91
|
+
|
|
92
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
93
|
+
return executeAction(params, manager, ctx);
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
renderCall(args: ProcessesParamsType, theme: Theme) {
|
|
97
|
+
const longArgs: Array<{ label?: string; value: string }> = [];
|
|
98
|
+
const optionArgs: Array<{ label: string; value: string }> = [];
|
|
99
|
+
let mainArg: string | undefined;
|
|
100
|
+
|
|
101
|
+
if (args.action === "start") {
|
|
102
|
+
if (args.name) {
|
|
103
|
+
mainArg = `"${args.name}"`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (args.command) {
|
|
107
|
+
if (!mainArg && args.command.length <= 60) {
|
|
108
|
+
mainArg = args.command;
|
|
109
|
+
} else if (args.command.length <= 60) {
|
|
110
|
+
optionArgs.push({ label: "command", value: args.command });
|
|
111
|
+
} else {
|
|
112
|
+
longArgs.push({ label: "command", value: args.command });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (
|
|
118
|
+
(args.action === "output" ||
|
|
119
|
+
args.action === "kill" ||
|
|
120
|
+
args.action === "logs") &&
|
|
121
|
+
args.id
|
|
122
|
+
) {
|
|
123
|
+
mainArg = args.id;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (args.action === "kill" && args.force) {
|
|
127
|
+
optionArgs.push({ label: "force", value: "true" });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (args.action === "start" && args.continueAfterStart) {
|
|
131
|
+
optionArgs.push({ label: "continueAfterStart", value: "true" });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return new ToolCallHeader(
|
|
135
|
+
{
|
|
136
|
+
toolName: "Process",
|
|
137
|
+
action: args.action,
|
|
138
|
+
mainArg,
|
|
139
|
+
optionArgs,
|
|
140
|
+
longArgs,
|
|
141
|
+
},
|
|
142
|
+
theme,
|
|
143
|
+
);
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
renderResult(
|
|
147
|
+
result: AgentToolResult<ProcessesDetails>,
|
|
148
|
+
options: ToolRenderResultOptions,
|
|
149
|
+
theme: Theme,
|
|
150
|
+
) {
|
|
151
|
+
const { details } = result;
|
|
152
|
+
|
|
153
|
+
if (!details) {
|
|
154
|
+
const text = result.content[0];
|
|
155
|
+
return new Text(
|
|
156
|
+
text?.type === "text" && text.text ? text.text : "No result",
|
|
157
|
+
0,
|
|
158
|
+
0,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const fields: Array<
|
|
163
|
+
{ label: string; value: string; showCollapsed?: boolean } | Text
|
|
164
|
+
> = [];
|
|
165
|
+
|
|
166
|
+
if (!details.success) {
|
|
167
|
+
fields.push({
|
|
168
|
+
label: "Error",
|
|
169
|
+
value: theme.fg("error", details.message),
|
|
170
|
+
showCollapsed: true,
|
|
171
|
+
});
|
|
172
|
+
} else if (details.action === "start" && details.process) {
|
|
173
|
+
const process = details.process;
|
|
174
|
+
fields.push({
|
|
175
|
+
label: "Status",
|
|
176
|
+
value:
|
|
177
|
+
theme.fg("success", "Started") +
|
|
178
|
+
` ${theme.fg("accent", `"${process.name}"`)} (${process.id}, PID: ${process.pid})`,
|
|
179
|
+
showCollapsed: true,
|
|
180
|
+
});
|
|
181
|
+
} else if (details.action === "output" && details.output) {
|
|
182
|
+
const lines: string[] = [theme.fg("muted", details.message)];
|
|
183
|
+
let hadAnsi = false;
|
|
184
|
+
|
|
185
|
+
if (details.output.stdout.length > 0) {
|
|
186
|
+
lines.push("", theme.fg("accent", "stdout:"));
|
|
187
|
+
for (const line of details.output.stdout.slice(-20)) {
|
|
188
|
+
if (!hadAnsi && hasAnsi(line)) hadAnsi = true;
|
|
189
|
+
lines.push(stripAnsi(line));
|
|
190
|
+
}
|
|
191
|
+
if (details.output.stdout.length > 20) {
|
|
192
|
+
lines.push(
|
|
193
|
+
theme.fg(
|
|
194
|
+
"muted",
|
|
195
|
+
`... (${details.output.stdout.length - 20} more lines)`,
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (details.output.stderr.length > 0) {
|
|
202
|
+
lines.push("", theme.fg("warning", "stderr:"));
|
|
203
|
+
for (const line of details.output.stderr.slice(-10)) {
|
|
204
|
+
if (!hadAnsi && hasAnsi(line)) hadAnsi = true;
|
|
205
|
+
lines.push(theme.fg("warning", stripAnsi(line)));
|
|
206
|
+
}
|
|
207
|
+
if (details.output.stderr.length > 10) {
|
|
208
|
+
lines.push(
|
|
209
|
+
theme.fg(
|
|
210
|
+
"muted",
|
|
211
|
+
`... (${details.output.stderr.length - 10} more lines)`,
|
|
212
|
+
),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (hadAnsi) {
|
|
218
|
+
lines.push(
|
|
219
|
+
"",
|
|
220
|
+
theme.fg("muted", "ANSI escape codes were stripped from output"),
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
fields.push(new Text(lines.join("\n"), 0, 0));
|
|
225
|
+
|
|
226
|
+
// Collapsed summary
|
|
227
|
+
const previewSource =
|
|
228
|
+
details.output.stdout.length > 0
|
|
229
|
+
? details.output.stdout
|
|
230
|
+
: details.output.stderr;
|
|
231
|
+
const preview = previewSource
|
|
232
|
+
.slice(-2)
|
|
233
|
+
.map((l) => stripAnsi(l))
|
|
234
|
+
.join("\n");
|
|
235
|
+
fields.push({
|
|
236
|
+
label: "Output",
|
|
237
|
+
value: preview
|
|
238
|
+
? `${theme.fg("muted", preview)}`
|
|
239
|
+
: theme.fg("muted", "(empty)"),
|
|
240
|
+
showCollapsed: true,
|
|
241
|
+
});
|
|
242
|
+
} else if (
|
|
243
|
+
details.action === "list" &&
|
|
244
|
+
details.processes &&
|
|
245
|
+
details.processes.length > 0
|
|
246
|
+
) {
|
|
247
|
+
const lines: string[] = [
|
|
248
|
+
theme.fg("success", `${details.processes.length} process(es):`),
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
for (const process of details.processes) {
|
|
252
|
+
let status: string;
|
|
253
|
+
switch (process.status) {
|
|
254
|
+
case "running":
|
|
255
|
+
status = theme.fg("accent", "running");
|
|
256
|
+
break;
|
|
257
|
+
case "terminating":
|
|
258
|
+
status = theme.fg("warning", "terminating");
|
|
259
|
+
break;
|
|
260
|
+
case "terminate_timeout":
|
|
261
|
+
status = theme.fg("error", "terminate_timeout");
|
|
262
|
+
break;
|
|
263
|
+
case "killed":
|
|
264
|
+
status = theme.fg("warning", "killed");
|
|
265
|
+
break;
|
|
266
|
+
case "exited":
|
|
267
|
+
status = process.success
|
|
268
|
+
? theme.fg("success", "exit(0)")
|
|
269
|
+
: theme.fg("error", `exit(${process.exitCode ?? "?"})`);
|
|
270
|
+
break;
|
|
271
|
+
default:
|
|
272
|
+
status = theme.fg("muted", process.status);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
lines.push(
|
|
276
|
+
` ${process.id} ${theme.fg("accent", `"${process.name}"`)}: ${truncateCmd(process.command)} [${status}] ${formatRuntime(process.startTime, process.endTime)}`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
fields.push(new Text(lines.join("\n"), 0, 0));
|
|
281
|
+
|
|
282
|
+
// Collapsed summary: first 3 processes
|
|
283
|
+
const summary = details.processes
|
|
284
|
+
.slice(0, 3)
|
|
285
|
+
.map((p) => {
|
|
286
|
+
const s =
|
|
287
|
+
p.status === "running"
|
|
288
|
+
? theme.fg("accent", "running")
|
|
289
|
+
: p.status === "exited" && p.success
|
|
290
|
+
? theme.fg("success", "exit(0)")
|
|
291
|
+
: p.status === "exited"
|
|
292
|
+
? theme.fg("error", `exit(${p.exitCode ?? "?"})`)
|
|
293
|
+
: theme.fg("muted", p.status);
|
|
294
|
+
return `${theme.fg("accent", `"${p.name}"`)} [${s}]`;
|
|
295
|
+
})
|
|
296
|
+
.join(", ");
|
|
297
|
+
const more =
|
|
298
|
+
details.processes.length > 3
|
|
299
|
+
? theme.fg("muted", ` +${details.processes.length - 3} more`)
|
|
300
|
+
: "";
|
|
301
|
+
fields.push({
|
|
302
|
+
label: "Processes",
|
|
303
|
+
value: summary + more,
|
|
304
|
+
showCollapsed: true,
|
|
305
|
+
});
|
|
306
|
+
} else if (details.action === "logs" && details.logFiles) {
|
|
307
|
+
fields.push(
|
|
308
|
+
new Text(
|
|
309
|
+
[
|
|
310
|
+
theme.fg("success", "Log files:"),
|
|
311
|
+
` stdout: ${theme.fg("accent", details.logFiles.stdoutFile)}`,
|
|
312
|
+
` stderr: ${theme.fg("accent", details.logFiles.stderrFile)}`,
|
|
313
|
+
` combined: ${theme.fg("accent", details.logFiles.combinedFile)}`,
|
|
314
|
+
].join("\n"),
|
|
315
|
+
0,
|
|
316
|
+
0,
|
|
317
|
+
),
|
|
318
|
+
);
|
|
319
|
+
} else {
|
|
320
|
+
fields.push({
|
|
321
|
+
label: "Result",
|
|
322
|
+
value: details.message,
|
|
323
|
+
showCollapsed: true,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const footerItems: Array<{
|
|
328
|
+
label: string;
|
|
329
|
+
value: string;
|
|
330
|
+
tone: "accent" | "success" | "error" | "warning" | "muted";
|
|
331
|
+
}> = [];
|
|
332
|
+
if (!details.success) {
|
|
333
|
+
footerItems.push({
|
|
334
|
+
label: "status",
|
|
335
|
+
value: "error",
|
|
336
|
+
tone: "error",
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
const footer =
|
|
340
|
+
footerItems.length > 0
|
|
341
|
+
? new ToolFooter(theme, { items: footerItems })
|
|
342
|
+
: undefined;
|
|
343
|
+
|
|
344
|
+
return new ToolBody({ fields, footer }, options, theme);
|
|
345
|
+
},
|
|
346
|
+
});
|
|
347
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Theme,
|
|
3
|
+
ToolRenderResultOptions,
|
|
4
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
6
|
+
import { Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
|
|
8
|
+
type Tone = "muted" | "accent" | "success" | "warning" | "error" | "dim";
|
|
9
|
+
|
|
10
|
+
export interface ToolCallHeaderOptionArg {
|
|
11
|
+
label: string;
|
|
12
|
+
value: string;
|
|
13
|
+
tone?: Tone;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ToolCallHeaderLongArg {
|
|
17
|
+
label?: string;
|
|
18
|
+
value: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ToolCallHeaderConfig {
|
|
22
|
+
toolName: string;
|
|
23
|
+
action?: string;
|
|
24
|
+
mainArg?: string;
|
|
25
|
+
optionArgs?: ToolCallHeaderOptionArg[];
|
|
26
|
+
longArgs?: ToolCallHeaderLongArg[];
|
|
27
|
+
showColon?: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class ToolCallHeader implements Component {
|
|
31
|
+
constructor(
|
|
32
|
+
private config: ToolCallHeaderConfig,
|
|
33
|
+
private theme: Theme,
|
|
34
|
+
) {}
|
|
35
|
+
|
|
36
|
+
handleInput(_data: string): void {}
|
|
37
|
+
|
|
38
|
+
invalidate(): void {}
|
|
39
|
+
|
|
40
|
+
render(width: number): string[] {
|
|
41
|
+
const title =
|
|
42
|
+
(this.config.showColon ?? Boolean(this.config.action))
|
|
43
|
+
? `${this.config.toolName}:`
|
|
44
|
+
: this.config.toolName;
|
|
45
|
+
const parts = [this.theme.fg("toolTitle", this.theme.bold(title))];
|
|
46
|
+
|
|
47
|
+
if (this.config.action) {
|
|
48
|
+
parts.push(this.theme.fg("accent", this.config.action));
|
|
49
|
+
}
|
|
50
|
+
if (this.config.mainArg) {
|
|
51
|
+
parts.push(this.theme.fg("accent", this.config.mainArg));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
for (const option of this.config.optionArgs ?? []) {
|
|
55
|
+
const label = option.label.trim().toLowerCase();
|
|
56
|
+
const tone = option.tone ?? "dim";
|
|
57
|
+
parts.push(
|
|
58
|
+
`${this.theme.fg("muted", `${label}=`)}${this.theme.fg(tone, option.value)}`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const lines = [parts.join(" ")];
|
|
63
|
+
for (const arg of this.config.longArgs ?? []) {
|
|
64
|
+
if (!arg.value) continue;
|
|
65
|
+
const label = arg.label
|
|
66
|
+
? `${this.theme.fg("muted", `${arg.label.trim().toLowerCase()}:`)} `
|
|
67
|
+
: "";
|
|
68
|
+
lines.push(`${label}${this.theme.fg("dim", arg.value)}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return new Text(lines.join("\n"), 0, 0).render(width);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type ToolBodyField =
|
|
76
|
+
| { label: string; value: string; showCollapsed?: boolean }
|
|
77
|
+
| (Component & { showCollapsed?: boolean });
|
|
78
|
+
|
|
79
|
+
export interface ToolBodyConfig {
|
|
80
|
+
fields: ToolBodyField[];
|
|
81
|
+
footer?: Component;
|
|
82
|
+
includeSpacerBeforeFooter?: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class ToolBody implements Component {
|
|
86
|
+
constructor(
|
|
87
|
+
private config: ToolBodyConfig,
|
|
88
|
+
private options: ToolRenderResultOptions,
|
|
89
|
+
private theme: Theme,
|
|
90
|
+
) {}
|
|
91
|
+
|
|
92
|
+
handleInput(_data: string): void {}
|
|
93
|
+
|
|
94
|
+
invalidate(): void {}
|
|
95
|
+
|
|
96
|
+
render(width: number): string[] {
|
|
97
|
+
const fields = this.options.expanded
|
|
98
|
+
? this.config.fields
|
|
99
|
+
: this.config.fields.filter((field) => field.showCollapsed === true);
|
|
100
|
+
const lines: string[] = [];
|
|
101
|
+
|
|
102
|
+
for (const field of fields) {
|
|
103
|
+
if (isComponent(field)) {
|
|
104
|
+
lines.push(...field.render(width));
|
|
105
|
+
} else {
|
|
106
|
+
lines.push(
|
|
107
|
+
...new Text(
|
|
108
|
+
`${this.theme.fg("muted", `${field.label}: `)}${field.value}`,
|
|
109
|
+
0,
|
|
110
|
+
0,
|
|
111
|
+
).render(width),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (this.config.footer) {
|
|
117
|
+
if (this.config.includeSpacerBeforeFooter ?? true) lines.push("");
|
|
118
|
+
lines.push(...this.config.footer.render(width));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return lines;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface ToolFooterItem {
|
|
126
|
+
label?: string;
|
|
127
|
+
value: string;
|
|
128
|
+
tone?: Exclude<Tone, "dim">;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface ToolFooterConfig {
|
|
132
|
+
items: ToolFooterItem[];
|
|
133
|
+
separator?: " - " | " | ";
|
|
134
|
+
truncate?: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export class ToolFooter implements Component {
|
|
138
|
+
constructor(
|
|
139
|
+
private theme: Theme,
|
|
140
|
+
private config: ToolFooterConfig,
|
|
141
|
+
) {}
|
|
142
|
+
|
|
143
|
+
handleInput(_data: string): void {}
|
|
144
|
+
|
|
145
|
+
invalidate(): void {}
|
|
146
|
+
|
|
147
|
+
render(width: number): string[] {
|
|
148
|
+
const line = this.config.items
|
|
149
|
+
.filter((item) => item.value.length > 0)
|
|
150
|
+
.map((item) => {
|
|
151
|
+
const text = item.label ? `${item.label}: ${item.value}` : item.value;
|
|
152
|
+
return this.theme.fg(item.tone ?? "muted", text);
|
|
153
|
+
})
|
|
154
|
+
.join(this.config.separator ?? " - ");
|
|
155
|
+
|
|
156
|
+
return [
|
|
157
|
+
(this.config.truncate ?? true) ? truncateToWidth(line, width) : line,
|
|
158
|
+
];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isComponent(field: ToolBodyField): field is Component {
|
|
163
|
+
return "render" in field && typeof field.render === "function";
|
|
164
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip ANSI escape codes from a string.
|
|
3
|
+
*
|
|
4
|
+
* Removes:
|
|
5
|
+
* - All CSI sequences (\x1b[...X) - SGR, cursor movement, erase, scroll, etc.
|
|
6
|
+
* - OSC 8 hyperlinks (\x1b]8;;URL\x07)
|
|
7
|
+
* - APC sequences (\x1b_...\x07 or \x1b_...\x1b\\)
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Check if a string contains ANSI escape codes.
|
|
11
|
+
*/
|
|
12
|
+
export function hasAnsi(str: string): boolean {
|
|
13
|
+
return str.includes(String.fromCodePoint(0x001b));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function stripAnsi(str: string): string {
|
|
17
|
+
// ESC = \u001b, BEL = \u0007
|
|
18
|
+
const ESC = String.fromCodePoint(0x001b);
|
|
19
|
+
const BEL = String.fromCodePoint(0x0007);
|
|
20
|
+
|
|
21
|
+
if (!str.includes(ESC)) {
|
|
22
|
+
return str;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Strip all CSI sequences (ESC[...X where X is any letter)
|
|
26
|
+
let clean = str.replace(new RegExp(`${ESC}\\[[0-9;]*[A-Za-z]`, "gu"), "");
|
|
27
|
+
// Strip OSC 8 hyperlinks: ESC]8;;URL<BEL> and ESC]8;;<BEL>
|
|
28
|
+
clean = clean.replace(new RegExp(`${ESC}\\]8;;[^${BEL}]*${BEL}`, "gu"), "");
|
|
29
|
+
// Strip APC sequences: ESC_...<BEL> or ESC_...<ESC>\\ (used for cursor marker)
|
|
30
|
+
clean = clean.replace(
|
|
31
|
+
new RegExp(`${ESC}_[^${BEL}${ESC}]*(?:${BEL}|${ESC}\\\\)`, "gu"),
|
|
32
|
+
"",
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return clean;
|
|
36
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { isAbsolute } from "node:path";
|
|
4
|
+
|
|
5
|
+
interface ResolveShellExecutableOptions {
|
|
6
|
+
configuredShell?: string;
|
|
7
|
+
knownPaths: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DEFAULT_KNOWN_SHELL_PATHS = [
|
|
11
|
+
"/run/current-system/sw/bin/bash",
|
|
12
|
+
"/bin/bash",
|
|
13
|
+
"/usr/bin/bash",
|
|
14
|
+
"/usr/local/bin/bash",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
function isExistingAbsolutePath(shell: string | undefined): shell is string {
|
|
18
|
+
return typeof shell === "string" && isAbsolute(shell) && existsSync(shell);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveShellExecutable({
|
|
22
|
+
configuredShell,
|
|
23
|
+
knownPaths,
|
|
24
|
+
}: ResolveShellExecutableOptions): string {
|
|
25
|
+
if (isExistingAbsolutePath(configuredShell)) {
|
|
26
|
+
return configuredShell;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
for (const path of knownPaths) {
|
|
30
|
+
if (isExistingAbsolutePath(path)) {
|
|
31
|
+
return path;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
throw new Error(
|
|
36
|
+
"Unable to resolve shell executable. Checked configured shell and known shell paths.",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function spawnCommand(
|
|
41
|
+
command: string,
|
|
42
|
+
cwd: string,
|
|
43
|
+
configuredShell?: string,
|
|
44
|
+
): ChildProcess {
|
|
45
|
+
const shellExecutable = resolveShellExecutable({
|
|
46
|
+
configuredShell,
|
|
47
|
+
knownPaths: DEFAULT_KNOWN_SHELL_PATHS,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return spawn(shellExecutable, ["-lc", command], {
|
|
51
|
+
cwd,
|
|
52
|
+
env: process.env,
|
|
53
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
54
|
+
detached: true,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ProcessInfo } from "../constants";
|
|
2
|
+
|
|
3
|
+
export function formatRuntime(
|
|
4
|
+
startTime: number,
|
|
5
|
+
endTime: number | null,
|
|
6
|
+
): string {
|
|
7
|
+
const end = endTime ?? Date.now();
|
|
8
|
+
const ms = end - startTime;
|
|
9
|
+
const seconds = Math.floor(ms / 1000);
|
|
10
|
+
const minutes = Math.floor(seconds / 60);
|
|
11
|
+
const hours = Math.floor(minutes / 60);
|
|
12
|
+
|
|
13
|
+
if (hours > 0) {
|
|
14
|
+
return `${hours}h ${minutes % 60}m`;
|
|
15
|
+
}
|
|
16
|
+
if (minutes > 0) {
|
|
17
|
+
return `${minutes}m ${seconds % 60}s`;
|
|
18
|
+
}
|
|
19
|
+
return `${seconds}s`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatStatus(proc: ProcessInfo): string {
|
|
23
|
+
switch (proc.status) {
|
|
24
|
+
case "running":
|
|
25
|
+
return "running";
|
|
26
|
+
case "terminating":
|
|
27
|
+
return "terminating";
|
|
28
|
+
case "terminate_timeout":
|
|
29
|
+
return "terminate_timeout";
|
|
30
|
+
case "killed":
|
|
31
|
+
return "killed";
|
|
32
|
+
case "exited":
|
|
33
|
+
return proc.success ? "exit(0)" : `exit(${proc.exitCode ?? "?"})`;
|
|
34
|
+
default:
|
|
35
|
+
return proc.status;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function truncateCmd(cmd: string, max = 40): string {
|
|
40
|
+
if (cmd.length <= max) return cmd;
|
|
41
|
+
return `${cmd.slice(0, max - 3)}...`;
|
|
42
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if a process group is still alive.
|
|
3
|
+
* Uses signal 0 to test existence without actually sending a signal.
|
|
4
|
+
*/
|
|
5
|
+
export function isProcessGroupAlive(pgid: number): boolean {
|
|
6
|
+
try {
|
|
7
|
+
process.kill(-pgid, 0);
|
|
8
|
+
return true;
|
|
9
|
+
} catch (error) {
|
|
10
|
+
const err = error as NodeJS.ErrnoException;
|
|
11
|
+
// EPERM: exists, but we can't signal it
|
|
12
|
+
return err.code === "EPERM";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Send a signal to an entire process group.
|
|
18
|
+
* Negative PID targets the process group.
|
|
19
|
+
*/
|
|
20
|
+
export function killProcessGroup(pgid: number, signal: NodeJS.Signals): void {
|
|
21
|
+
process.kill(-pgid, signal);
|
|
22
|
+
}
|