@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.
@@ -0,0 +1,11 @@
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";
@@ -0,0 +1,65 @@
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/index.ts CHANGED
@@ -10,7 +10,9 @@ export function setupProcessesHooks(pi: ExtensionAPI, manager: ProcessManager) {
10
10
  setupProcessEndHook(pi, manager);
11
11
 
12
12
  // Set up widget AFTER process-end so it chains onto the existing callback
13
- setupProcessWidget(pi, manager);
13
+ const widget = setupProcessWidget(pi, manager);
14
14
 
15
15
  setupMessageRenderer(pi);
16
+
17
+ return widget;
16
18
  }
package/hooks/widget.ts CHANGED
@@ -3,6 +3,7 @@ import type {
3
3
  ExtensionContext,
4
4
  } from "@mariozechner/pi-coding-agent";
5
5
  import { visibleWidth } from "@mariozechner/pi-tui";
6
+ import { configLoader } from "../config";
6
7
  import type { ProcessInfo } from "../constants";
7
8
  import type { ProcessManager } from "../manager";
8
9
 
@@ -106,6 +107,11 @@ export function setupProcessWidget(pi: ExtensionAPI, manager: ProcessManager) {
106
107
  function updateWidget() {
107
108
  if (!latestContext?.hasUI) return;
108
109
 
110
+ if (!configLoader.getConfig().widget.showStatusWidget) {
111
+ latestContext.ui.setWidget(WIDGET_ID, undefined);
112
+ return;
113
+ }
114
+
109
115
  const processes = manager.list();
110
116
  const maxWidth = process.stdout.columns || 120;
111
117
  const lines = renderWidget(processes, latestContext.ui.theme, maxWidth);
package/index.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import { setupProcessesCommands } from "./commands";
3
+ import { registerProcessesSettings } from "./commands/settings-command";
4
+ import { configLoader } from "./config";
3
5
  import { setupProcessesHooks } from "./hooks";
4
6
  import { ProcessManager } from "./manager";
5
7
  import { setupProcessesTools } from "./tools";
6
8
 
7
- export default function (pi: ExtensionAPI) {
9
+ export default async function (pi: ExtensionAPI) {
8
10
  if (process.platform === "win32") {
9
11
  pi.on("session_start", async (_event, ctx) => {
10
12
  if (!ctx.hasUI) return;
@@ -13,9 +15,13 @@ export default function (pi: ExtensionAPI) {
13
15
  return;
14
16
  }
15
17
 
18
+ await configLoader.load();
16
19
  const manager = new ProcessManager();
17
20
 
18
- setupProcessesHooks(pi, manager);
19
- setupProcessesTools(pi, manager);
21
+ const { update: updateWidget } = setupProcessesHooks(pi, manager);
20
22
  setupProcessesCommands(pi, manager);
23
+ setupProcessesTools(pi, manager);
24
+ registerProcessesSettings(pi, () => {
25
+ updateWidget();
26
+ });
21
27
  }
package/manager.ts CHANGED
@@ -23,6 +23,7 @@ import { isProcessGroupAlive, killProcessGroup } from "./utils";
23
23
  interface ManagedProcess extends ProcessInfo {
24
24
  process: ChildProcess;
25
25
  lastSignalSent: NodeJS.Signals | null;
26
+ combinedFile: string;
26
27
  }
27
28
 
28
29
  export class ProcessManager {
@@ -122,9 +123,11 @@ export class ProcessManager {
122
123
  const id = `proc_${++this.counter}`;
123
124
  const stdoutFile = join(this.logDir, `${id}-stdout.log`);
124
125
  const stderrFile = join(this.logDir, `${id}-stderr.log`);
126
+ const combinedFile = join(this.logDir, `${id}-combined.log`);
125
127
 
126
128
  appendFileSync(stdoutFile, "");
127
129
  appendFileSync(stderrFile, "");
130
+ appendFileSync(combinedFile, "");
128
131
 
129
132
  const child = spawn("/bin/bash", ["-lc", command], {
130
133
  cwd,
@@ -148,6 +151,7 @@ export class ProcessManager {
148
151
  success: null,
149
152
  stdoutFile,
150
153
  stderrFile,
154
+ combinedFile,
151
155
  alertOnSuccess: options?.alertOnSuccess ?? false,
152
156
  alertOnFailure: options?.alertOnFailure ?? true,
153
157
  alertOnKill: options?.alertOnKill ?? false,
@@ -173,6 +177,15 @@ export class ProcessManager {
173
177
  child.stdout?.on("data", (data: Buffer) => {
174
178
  try {
175
179
  appendFileSync(stdoutFile, data);
180
+ const lines = data.toString().split("\n");
181
+ // The last element after split is either empty (if data ended with \n)
182
+ // or a partial line. We write all parts with the prefix and newline.
183
+ const tagged = lines
184
+ .map((line, i) =>
185
+ i < lines.length - 1 ? `1:${line}\n` : line ? `1:${line}\n` : "",
186
+ )
187
+ .join("");
188
+ if (tagged) appendFileSync(combinedFile, tagged);
176
189
  } catch {
177
190
  // Ignore
178
191
  }
@@ -181,6 +194,13 @@ export class ProcessManager {
181
194
  child.stderr?.on("data", (data: Buffer) => {
182
195
  try {
183
196
  appendFileSync(stderrFile, data);
197
+ const lines = data.toString().split("\n");
198
+ const tagged = lines
199
+ .map((line, i) =>
200
+ i < lines.length - 1 ? `2:${line}\n` : line ? `2:${line}\n` : "",
201
+ )
202
+ .join("");
203
+ if (tagged) appendFileSync(combinedFile, tagged);
184
204
  } catch {
185
205
  // Ignore
186
206
  }
@@ -262,6 +282,26 @@ export class ProcessManager {
262
282
  };
263
283
  }
264
284
 
285
+ getCombinedOutput(
286
+ id: string,
287
+ tailLines = 100,
288
+ ): { type: "stdout" | "stderr"; text: string }[] | null {
289
+ const managed = this.processes.get(id);
290
+ if (!managed) return null;
291
+
292
+ const rawLines = this.readTailLines(managed.combinedFile, tailLines);
293
+ return rawLines.map((line) => {
294
+ if (line.startsWith("2:")) {
295
+ return { type: "stderr", text: line.slice(2) };
296
+ }
297
+ // Default to stdout (handles "1:" prefix and any malformed lines).
298
+ return {
299
+ type: "stdout",
300
+ text: line.startsWith("1:") ? line.slice(2) : line,
301
+ };
302
+ });
303
+ }
304
+
265
305
  getFullOutput(id: string): { stdout: string; stderr: string } | null {
266
306
  const managed = this.processes.get(id);
267
307
  if (!managed) return null;
@@ -374,6 +414,7 @@ export class ProcessManager {
374
414
  try {
375
415
  rmSync(managed.stdoutFile, { force: true });
376
416
  rmSync(managed.stderrFile, { force: true });
417
+ rmSync(managed.combinedFile, { force: true });
377
418
  } catch {
378
419
  // Ignore
379
420
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-processes",
3
- "version": "0.3.4",
3
+ "version": "0.4.2",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "keywords": [
@@ -25,12 +25,17 @@
25
25
  "files": [
26
26
  "*.ts",
27
27
  "commands",
28
+ "components",
29
+ "constants",
28
30
  "hooks",
29
31
  "tools",
32
+ "utils",
30
33
  "README.md"
31
34
  ],
32
35
  "dependencies": {
33
- "@sinclair/typebox": "^0.34.41"
36
+ "@sinclair/typebox": "^0.34.41",
37
+ "@aliou/pi-utils-settings": "0.3.0",
38
+ "@aliou/pi-utils-ui": "^0.1.0"
34
39
  },
35
40
  "peerDependencies": {
36
41
  "@mariozechner/pi-coding-agent": ">=0.51.0"
@@ -53,7 +53,7 @@ export async function executeKill(
53
53
  if (result.reason === "timeout") {
54
54
  const message =
55
55
  `SIGTERM timed out for "${proc.name}" (${proc.id}). ` +
56
- "Run /processes and press x on terminate_timeout to force kill (SIGKILL).";
56
+ "Run /process:list and press x on terminate_timeout to force kill (SIGKILL).";
57
57
  return {
58
58
  content: [{ type: "text", text: message }],
59
59
  details: {
@@ -1,8 +1,8 @@
1
+ import { configLoader } from "../../config";
1
2
  import type { ExecuteResult } from "../../constants";
2
3
  import type { ProcessManager } from "../../manager";
3
4
  import { formatStatus, stripAnsi } from "../../utils";
4
5
 
5
- const MAX_LINES = 200;
6
6
  const MAX_BYTES = 50 * 1024; // 50KB
7
7
 
8
8
  interface OutputParams {
@@ -37,7 +37,8 @@ export function executeOutput(
37
37
  };
38
38
  }
39
39
 
40
- const output = manager.getOutput(proc.id);
40
+ const { defaultTailLines } = configLoader.getConfig().output;
41
+ const output = manager.getOutput(proc.id, defaultTailLines);
41
42
  if (!output) {
42
43
  const message = `Could not read output for: ${proc.id}`;
43
44
  return {
@@ -68,7 +69,8 @@ export function executeOutput(
68
69
  }
69
70
 
70
71
  const fullText = outputParts.join("\n");
71
- const contentText = truncateTail(fullText, logFiles);
72
+ const { maxOutputLines } = configLoader.getConfig().output;
73
+ const contentText = truncateTail(fullText, logFiles, maxOutputLines);
72
74
 
73
75
  return {
74
76
  content: [{ type: "text", text: contentText }],
@@ -89,12 +91,13 @@ export function executeOutput(
89
91
  function truncateTail(
90
92
  text: string,
91
93
  logFiles: { stdoutFile: string; stderrFile: string } | null,
94
+ maxLines: number,
92
95
  ): string {
93
96
  const totalBytes = Buffer.byteLength(text, "utf-8");
94
97
  const lines = text.split("\n");
95
98
  const totalLines = lines.length;
96
99
 
97
- if (totalLines <= MAX_LINES && totalBytes <= MAX_BYTES) {
100
+ if (totalLines <= maxLines && totalBytes <= MAX_BYTES) {
98
101
  return text;
99
102
  }
100
103
 
@@ -103,7 +106,7 @@ function truncateTail(
103
106
  let keptBytes = 0;
104
107
  let hitBytes = false;
105
108
 
106
- for (let i = lines.length - 1; i >= 0 && kept.length < MAX_LINES; i--) {
109
+ for (let i = lines.length - 1; i >= 0 && kept.length < maxLines; i--) {
107
110
  const line = lines[i] ?? "";
108
111
  const lineBytes =
109
112
  Buffer.byteLength(line, "utf-8") + (kept.length > 0 ? 1 : 0);
package/tools/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { ToolBody, ToolCallHeader, ToolFooter } from "@aliou/pi-utils-ui";
1
2
  import { StringEnum } from "@mariozechner/pi-ai";
2
3
  import type {
3
4
  AgentToolResult,
@@ -59,8 +60,8 @@ type ProcessesParamsType = Static<typeof ProcessesParams>;
59
60
 
60
61
  export function setupProcessesTools(pi: ExtensionAPI, manager: ProcessManager) {
61
62
  pi.registerTool<typeof ProcessesParams, ProcessesDetails>({
62
- name: "processes",
63
- label: "Processes",
63
+ name: "process",
64
+ label: "Process",
64
65
  description: `Manage background processes. Actions:
65
66
  - start: Run command in background (requires 'name' and 'command')
66
67
  - alertOnSuccess (default: false): Get a turn to react when process completes successfully
@@ -82,36 +83,53 @@ Note: User always sees process updates in the UI. The notify flags control wheth
82
83
  return executeAction(params, manager, ctx);
83
84
  },
84
85
 
85
- renderCall(args: ProcessesParamsType, theme: Theme): Text {
86
- let text = theme.fg("toolTitle", theme.bold("processes "));
87
- text += theme.fg("accent", args.action);
86
+ renderCall(args: ProcessesParamsType, theme: Theme) {
87
+ const longArgs: Array<{ label?: string; value: string }> = [];
88
+ const optionArgs: Array<{ label: string; value: string }> = [];
89
+ let mainArg: string | undefined;
88
90
 
89
- switch (args.action) {
90
- case "start":
91
- if (args.name) {
92
- text += ` ${theme.fg("accent", `"${args.name}"`)}`;
93
- }
94
- if (args.command) {
95
- text += ` ${theme.fg("muted", args.command.slice(0, 40))}`;
96
- }
97
- break;
98
- case "output":
99
- case "kill":
100
- case "logs":
101
- if (args.id) {
102
- text += ` ${theme.fg("muted", args.id)}`;
91
+ if (args.action === "start") {
92
+ if (args.name) {
93
+ mainArg = `"${args.name}"`;
94
+ }
95
+
96
+ if (args.command) {
97
+ if (!mainArg && args.command.length <= 60) {
98
+ mainArg = args.command;
99
+ } else if (args.command.length <= 60) {
100
+ optionArgs.push({ label: "command", value: args.command });
101
+ } else {
102
+ longArgs.push({ label: "command", value: args.command });
103
103
  }
104
- break;
104
+ }
105
+ }
106
+
107
+ if (
108
+ (args.action === "output" ||
109
+ args.action === "kill" ||
110
+ args.action === "logs") &&
111
+ args.id
112
+ ) {
113
+ mainArg = args.id;
105
114
  }
106
115
 
107
- return new Text(text, 0, 0);
116
+ return new ToolCallHeader(
117
+ {
118
+ toolName: "Process",
119
+ action: args.action,
120
+ mainArg,
121
+ optionArgs,
122
+ longArgs,
123
+ },
124
+ theme,
125
+ );
108
126
  },
109
127
 
110
128
  renderResult(
111
129
  result: AgentToolResult<ProcessesDetails>,
112
- _options: ToolRenderResultOptions,
130
+ options: ToolRenderResultOptions,
113
131
  theme: Theme,
114
- ): Text {
132
+ ) {
115
133
  const { details } = result;
116
134
 
117
135
  if (!details) {
@@ -123,34 +141,32 @@ Note: User always sees process updates in the UI. The notify flags control wheth
123
141
  );
124
142
  }
125
143
 
126
- if (!details.success) {
127
- return new Text(theme.fg("error", details.message), 0, 0);
128
- }
129
-
130
- // For start action
131
- if (details.action === "start" && details.process) {
132
- const p = details.process;
133
- return new Text(
134
- theme.fg("success", "\u2713 Started ") +
135
- theme.fg("accent", `"${p.name}"`) +
136
- ` (${p.id}, PID: ${p.pid})`,
137
- 0,
138
- 0,
139
- );
140
- }
141
-
142
- // For output action
143
- if (details.action === "output" && details.output) {
144
- const lines: string[] = [];
145
- lines.push(theme.fg("muted", details.message));
144
+ const fields: Array<
145
+ { label: string; value: string; showCollapsed?: boolean } | Text
146
+ > = [];
146
147
 
148
+ if (!details.success) {
149
+ fields.push({
150
+ label: "Error",
151
+ value: theme.fg("error", details.message),
152
+ showCollapsed: true,
153
+ });
154
+ } else if (details.action === "start" && details.process) {
155
+ const process = details.process;
156
+ fields.push({
157
+ label: "Status",
158
+ value:
159
+ theme.fg("success", "Started") +
160
+ ` ${theme.fg("accent", `"${process.name}"`)} (${process.id}, PID: ${process.pid})`,
161
+ showCollapsed: true,
162
+ });
163
+ } else if (details.action === "output" && details.output) {
164
+ const lines: string[] = [theme.fg("muted", details.message)];
147
165
  let hadAnsi = false;
148
166
 
149
167
  if (details.output.stdout.length > 0) {
150
- lines.push("");
151
- lines.push(theme.fg("accent", "stdout:"));
152
- const stdoutLines = details.output.stdout.slice(-20);
153
- for (const line of stdoutLines) {
168
+ lines.push("", theme.fg("accent", "stdout:"));
169
+ for (const line of details.output.stdout.slice(-20)) {
154
170
  if (!hadAnsi && hasAnsi(line)) hadAnsi = true;
155
171
  lines.push(stripAnsi(line));
156
172
  }
@@ -165,10 +181,8 @@ Note: User always sees process updates in the UI. The notify flags control wheth
165
181
  }
166
182
 
167
183
  if (details.output.stderr.length > 0) {
168
- lines.push("");
169
- lines.push(theme.fg("warning", "stderr:"));
170
- const stderrLines = details.output.stderr.slice(-10);
171
- for (const line of stderrLines) {
184
+ lines.push("", theme.fg("warning", "stderr:"));
185
+ for (const line of details.output.stderr.slice(-10)) {
172
186
  if (!hadAnsi && hasAnsi(line)) hadAnsi = true;
173
187
  lines.push(theme.fg("warning", stripAnsi(line)));
174
188
  }
@@ -183,28 +197,25 @@ Note: User always sees process updates in the UI. The notify flags control wheth
183
197
  }
184
198
 
185
199
  if (hadAnsi) {
186
- lines.push("");
187
200
  lines.push(
201
+ "",
188
202
  theme.fg("muted", "ANSI escape codes were stripped from output"),
189
203
  );
190
204
  }
191
205
 
192
- return new Text(lines.join("\n"), 0, 0);
193
- }
194
-
195
- // For list action
196
- if (
206
+ fields.push(new Text(lines.join("\n"), 0, 0));
207
+ } else if (
197
208
  details.action === "list" &&
198
209
  details.processes &&
199
210
  details.processes.length > 0
200
211
  ) {
201
- const lines: string[] = [];
202
- lines.push(
212
+ const lines: string[] = [
203
213
  theme.fg("success", `${details.processes.length} process(es):`),
204
- );
205
- for (const p of details.processes) {
214
+ ];
215
+
216
+ for (const process of details.processes) {
206
217
  let status: string;
207
- switch (p.status) {
218
+ switch (process.status) {
208
219
  case "running":
209
220
  status = theme.fg("accent", "running");
210
221
  break;
@@ -218,43 +229,52 @@ Note: User always sees process updates in the UI. The notify flags control wheth
218
229
  status = theme.fg("warning", "killed");
219
230
  break;
220
231
  case "exited":
221
- status = p.success
232
+ status = process.success
222
233
  ? theme.fg("success", "exit(0)")
223
- : theme.fg("error", `exit(${p.exitCode ?? "?"})`);
234
+ : theme.fg("error", `exit(${process.exitCode ?? "?"})`);
224
235
  break;
225
236
  default:
226
- status = theme.fg("muted", p.status);
237
+ status = theme.fg("muted", process.status);
227
238
  }
239
+
228
240
  lines.push(
229
- ` ${p.id} ${theme.fg("accent", `"${p.name}"`)}: ${truncateCmd(p.command)} [${status}] ${formatRuntime(p.startTime, p.endTime)}`,
241
+ ` ${process.id} ${theme.fg("accent", `"${process.name}"`)}: ${truncateCmd(process.command)} [${status}] ${formatRuntime(process.startTime, process.endTime)}`,
230
242
  );
231
243
  }
232
- return new Text(lines.join("\n"), 0, 0);
233
- }
234
244
 
235
- // For logs action
236
- if (details.action === "logs" && details.logFiles) {
237
- const lines: string[] = [];
238
- lines.push(theme.fg("success", "Log files:"));
239
- lines.push(
240
- ` stdout: ${theme.fg("accent", details.logFiles.stdoutFile)}`,
245
+ fields.push(new Text(lines.join("\n"), 0, 0));
246
+ } else if (details.action === "logs" && details.logFiles) {
247
+ fields.push(
248
+ new Text(
249
+ [
250
+ theme.fg("success", "Log files:"),
251
+ ` stdout: ${theme.fg("accent", details.logFiles.stdoutFile)}`,
252
+ ` stderr: ${theme.fg("accent", details.logFiles.stderrFile)}`,
253
+ ].join("\n"),
254
+ 0,
255
+ 0,
256
+ ),
241
257
  );
242
- lines.push(
243
- ` stderr: ${theme.fg("accent", details.logFiles.stderrFile)}`,
244
- );
245
- return new Text(lines.join("\n"), 0, 0);
258
+ } else {
259
+ fields.push({
260
+ label: "Result",
261
+ value: details.message,
262
+ showCollapsed: true,
263
+ });
246
264
  }
247
265
 
248
- // For clear action
249
- if (details.action === "clear") {
250
- return new Text(
251
- theme.fg("success", "\u2713 ") + theme.fg("muted", details.message),
252
- 0,
253
- 0,
254
- );
255
- }
266
+ const footer = new ToolFooter(theme, {
267
+ items: [
268
+ { label: "action", value: details.action, tone: "accent" },
269
+ {
270
+ label: "status",
271
+ value: details.success ? "ok" : "error",
272
+ tone: details.success ? "success" : "error",
273
+ },
274
+ ],
275
+ });
256
276
 
257
- return new Text(details.message, 0, 0);
277
+ return new ToolBody({ fields, footer }, options, theme);
258
278
  },
259
279
  });
260
280
  }
package/utils/ansi.ts ADDED
@@ -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,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
+ }