@aliou/pi-processes 0.4.6 → 0.5.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 +30 -23
- package/package.json +14 -3
- package/src/commands/settings-command.ts +22 -0
- package/src/components/process-picker-component.ts +14 -2
- package/src/components/processes-component.ts +41 -20
- package/src/config.ts +10 -0
- package/src/hooks/background-blocker.ts +66 -0
- package/src/hooks/index.ts +11 -1
- package/src/hooks/widget.ts +32 -6
- package/src/index.ts +2 -1
- package/src/utils/command-executor.test.ts +2 -1
- package/src/utils/shell-utils.ts +133 -0
package/README.md
CHANGED
|
@@ -20,8 +20,8 @@ pi install git:github.com/aliou/pi-processes
|
|
|
20
20
|
|
|
21
21
|
## Features
|
|
22
22
|
|
|
23
|
-
- **Tool**: `
|
|
24
|
-
- **
|
|
23
|
+
- **Tool**: `process` with actions: `start`, `list`, `output`, `logs`, `kill`, `clear`
|
|
24
|
+
- **Commands**: `/process:list` (interactive panel), `/process:stream` (stream logs), `/process:logs` (log paths), `/process:kill` (kill process), `/process:clear` (clear finished)
|
|
25
25
|
- Auto-cleanup on session exit
|
|
26
26
|
- File-based logging (logs written to temp files, not memory)
|
|
27
27
|
- Friendly process names (auto-inferred or custom)
|
|
@@ -31,48 +31,55 @@ pi install git:github.com/aliou/pi-processes
|
|
|
31
31
|
### Tool (for agent)
|
|
32
32
|
|
|
33
33
|
```
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
34
|
+
process start "pnpm dev" name="backend-dev"
|
|
35
|
+
process start "pnpm build" name="build" alertOnSuccess=true
|
|
36
|
+
process start "pnpm test" alertOnFailure=true
|
|
37
|
+
process list
|
|
38
|
+
process output id="backend"
|
|
39
|
+
process logs id="proc_1"
|
|
40
|
+
process kill id="backend"
|
|
41
|
+
process clear
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
**
|
|
45
|
-
- `
|
|
46
|
-
- `
|
|
47
|
-
- `
|
|
44
|
+
**Alert parameters** (for `start` action):
|
|
45
|
+
- `alertOnSuccess` (default: false) - Get a turn to react when process completes successfully. Use for builds/tests where you need confirmation.
|
|
46
|
+
- `alertOnFailure` (default: true) - Get a turn to react when process fails/crashes. Use to be alerted of unexpected failures.
|
|
47
|
+
- `alertOnKill` (default: false) - Get a turn to react if killed by external signal. Note: killing via tool never triggers a turn.
|
|
48
48
|
|
|
49
49
|
**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.
|
|
50
50
|
|
|
51
|
-
Note: User always sees
|
|
51
|
+
Note: User always sees process updates in the UI. The alert flags control whether the agent gets a turn to react (e.g. check results, fix code, restart).
|
|
52
52
|
|
|
53
|
-
###
|
|
53
|
+
### Commands (interactive)
|
|
54
54
|
|
|
55
|
-
Run `/
|
|
55
|
+
Run `/process:list` to open the panel:
|
|
56
56
|
- `j/k` - select process
|
|
57
57
|
- `J/K` - scroll logs
|
|
58
|
+
- `enter` - stream logs for selected process
|
|
58
59
|
- `x` - kill selected process
|
|
59
60
|
- `c` - clear finished processes
|
|
60
61
|
- `q` - quit
|
|
61
62
|
|
|
63
|
+
Other commands:
|
|
64
|
+
- `/process:stream [id|name]` - stream logs from a running process
|
|
65
|
+
- `/process:logs [id|name]` - show log file paths
|
|
66
|
+
- `/process:kill [id|name]` - kill a running process
|
|
67
|
+
- `/process:clear` - clear finished processes
|
|
68
|
+
|
|
62
69
|
## Test Scripts
|
|
63
70
|
|
|
64
|
-
Test scripts in `test/` directory:
|
|
71
|
+
Test scripts in `src/test/` directory:
|
|
65
72
|
|
|
66
73
|
```bash
|
|
67
|
-
./test/test-output.sh # Continuous output (80 chars/sec)
|
|
68
|
-
./test/test-exit-success.sh 5 # Exits successfully after 5s
|
|
69
|
-
./test/test-exit-failure.sh 5 # Exits with code 1 after 5s
|
|
70
|
-
./test/test-exit-crash.sh 5 # Exits with code 137 after 5s
|
|
74
|
+
./src/test/test-output.sh # Continuous output (80 chars/sec)
|
|
75
|
+
./src/test/test-exit-success.sh 5 # Exits successfully after 5s
|
|
76
|
+
./src/test/test-exit-failure.sh 5 # Exits with code 1 after 5s
|
|
77
|
+
./src/test/test-exit-crash.sh 5 # Exits with code 137 after 5s
|
|
71
78
|
```
|
|
72
79
|
|
|
73
80
|
## Future Improvements
|
|
74
81
|
|
|
75
|
-
- [ ] **Expandable log view**: Allow toggling between collapsed (current fixed height) and expanded (full height) log view in the `/
|
|
82
|
+
- [ ] **Expandable log view**: Allow toggling between collapsed (current fixed height) and expanded (full height) log view in the `/process:list` panel.
|
|
76
83
|
|
|
77
84
|
- [ ] **Copy log file path**: Add keyboard shortcut to copy the stdout/stderr log file path to clipboard for easy access.
|
|
78
85
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aliou/pi-processes",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -30,27 +30,38 @@
|
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@aliou/pi-utils-settings": "^0.4.0",
|
|
32
32
|
"@aliou/pi-utils-ui": "^0.1.0",
|
|
33
|
+
"@aliou/sh": "^0.1.0",
|
|
33
34
|
"@sinclair/typebox": "^0.34.41"
|
|
34
35
|
},
|
|
35
36
|
"peerDependencies": {
|
|
36
|
-
"@mariozechner/pi-coding-agent": ">=0.51.0"
|
|
37
|
+
"@mariozechner/pi-coding-agent": ">=0.51.0",
|
|
38
|
+
"@mariozechner/pi-tui": ">=0.51.0"
|
|
37
39
|
},
|
|
38
40
|
"devDependencies": {
|
|
41
|
+
"@aliou/biome-plugins": "^0.3.2",
|
|
39
42
|
"@aliou/pi-evals": "^0.3.0",
|
|
40
43
|
"@biomejs/biome": "^2.3.13",
|
|
41
44
|
"@changesets/cli": "^2.27.11",
|
|
42
45
|
"@mariozechner/pi-ai": "0.52.7",
|
|
43
46
|
"@mariozechner/pi-coding-agent": "0.52.7",
|
|
44
|
-
"@mariozechner/pi-tui": "0.52.7",
|
|
45
47
|
"@types/node": "^25.0.10",
|
|
46
48
|
"husky": "^9.1.7",
|
|
47
49
|
"typescript": "^5.9.3",
|
|
48
50
|
"vitest": "^4.0.18"
|
|
49
51
|
},
|
|
52
|
+
"peerDependenciesMeta": {
|
|
53
|
+
"@mariozechner/pi-coding-agent": {
|
|
54
|
+
"optional": true
|
|
55
|
+
},
|
|
56
|
+
"@mariozechner/pi-tui": {
|
|
57
|
+
"optional": true
|
|
58
|
+
}
|
|
59
|
+
},
|
|
50
60
|
"scripts": {
|
|
51
61
|
"typecheck": "tsc --noEmit",
|
|
52
62
|
"lint": "biome check",
|
|
53
63
|
"format": "biome check --write",
|
|
64
|
+
"check:lockfile": "pnpm install --frozen-lockfile --ignore-scripts",
|
|
54
65
|
"changeset": "changeset",
|
|
55
66
|
"version": "changeset version",
|
|
56
67
|
"release": "pnpm changeset publish"
|
|
@@ -93,6 +93,23 @@ export function registerProcessesSettings(
|
|
|
93
93
|
},
|
|
94
94
|
],
|
|
95
95
|
},
|
|
96
|
+
{
|
|
97
|
+
label: "Interception",
|
|
98
|
+
items: [
|
|
99
|
+
{
|
|
100
|
+
id: "interception.blockBackgroundCommands",
|
|
101
|
+
label: "Block background commands",
|
|
102
|
+
description:
|
|
103
|
+
"Block bash background commands (&, nohup, disown, setsid) and guide the model to use the process tool",
|
|
104
|
+
currentValue:
|
|
105
|
+
(tabConfig?.interception?.blockBackgroundCommands ??
|
|
106
|
+
resolved.interception.blockBackgroundCommands)
|
|
107
|
+
? "on"
|
|
108
|
+
: "off",
|
|
109
|
+
values: ["on", "off"],
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
},
|
|
96
113
|
{
|
|
97
114
|
label: "Widget",
|
|
98
115
|
items: [
|
|
@@ -114,6 +131,11 @@ export function registerProcessesSettings(
|
|
|
114
131
|
onSettingChange: (id, newValue, config) => {
|
|
115
132
|
const updated = structuredClone(config);
|
|
116
133
|
// Boolean fields.
|
|
134
|
+
if (id === "interception.blockBackgroundCommands") {
|
|
135
|
+
if (!updated.interception) updated.interception = {};
|
|
136
|
+
updated.interception.blockBackgroundCommands = newValue === "on";
|
|
137
|
+
return updated;
|
|
138
|
+
}
|
|
117
139
|
if (id === "widget.showStatusWidget") {
|
|
118
140
|
if (!updated.widget) updated.widget = {};
|
|
119
141
|
updated.widget.showStatusWidget = newValue === "on";
|
|
@@ -4,7 +4,12 @@ import {
|
|
|
4
4
|
renderPanelTitleLine,
|
|
5
5
|
} from "@aliou/pi-utils-ui";
|
|
6
6
|
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type Component,
|
|
9
|
+
matchesKey,
|
|
10
|
+
truncateToWidth,
|
|
11
|
+
visibleWidth,
|
|
12
|
+
} from "@mariozechner/pi-tui";
|
|
8
13
|
import type { ProcessInfo } from "../constants";
|
|
9
14
|
import type { ProcessManager } from "../manager";
|
|
10
15
|
import { statusIcon, statusLabel } from "./status-format";
|
|
@@ -111,7 +116,14 @@ export class ProcessPickerComponent implements Component {
|
|
|
111
116
|
const dim = (s: string) => theme.fg("dim", s);
|
|
112
117
|
const accent = (s: string) => theme.fg("accent", s);
|
|
113
118
|
|
|
114
|
-
const
|
|
119
|
+
const innerWidth = width - 2;
|
|
120
|
+
const basePadLine = createPanelPadder(width);
|
|
121
|
+
const padLine = (content: string): string =>
|
|
122
|
+
basePadLine(
|
|
123
|
+
visibleWidth(content) > innerWidth
|
|
124
|
+
? truncateToWidth(content, innerWidth)
|
|
125
|
+
: content,
|
|
126
|
+
);
|
|
115
127
|
|
|
116
128
|
const lines: string[] = [];
|
|
117
129
|
const processes = this.getProcesses();
|
|
@@ -4,7 +4,12 @@ import {
|
|
|
4
4
|
renderPanelTitleLine,
|
|
5
5
|
} from "@aliou/pi-utils-ui";
|
|
6
6
|
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type Component,
|
|
9
|
+
matchesKey,
|
|
10
|
+
truncateToWidth,
|
|
11
|
+
visibleWidth,
|
|
12
|
+
} from "@mariozechner/pi-tui";
|
|
8
13
|
import { configLoader } from "../config";
|
|
9
14
|
import type { ProcessInfo } from "../constants";
|
|
10
15
|
import type { ProcessManager } from "../manager";
|
|
@@ -214,7 +219,13 @@ export class ProcessesComponent implements Component {
|
|
|
214
219
|
const processes = this.manager.list();
|
|
215
220
|
const innerWidth = width - 2;
|
|
216
221
|
|
|
217
|
-
const
|
|
222
|
+
const basePadLine = createPanelPadder(width);
|
|
223
|
+
const padLine = (content: string): string =>
|
|
224
|
+
basePadLine(
|
|
225
|
+
visibleWidth(content) > innerWidth
|
|
226
|
+
? truncateToWidth(content, innerWidth)
|
|
227
|
+
: content,
|
|
228
|
+
);
|
|
218
229
|
|
|
219
230
|
lines.push(renderPanelTitleLine("Background Processes", width, theme));
|
|
220
231
|
|
|
@@ -225,11 +236,18 @@ export class ProcessesComponent implements Component {
|
|
|
225
236
|
lines.push(padLine(""));
|
|
226
237
|
} else {
|
|
227
238
|
const prefixWidth = 2;
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const
|
|
232
|
-
const
|
|
239
|
+
|
|
240
|
+
// Responsive column widths based on available space
|
|
241
|
+
// Minimum widths: id=6, name=8, cmd=4, status=10, time=4, size=4 = ~40 chars minimum
|
|
242
|
+
const minTotalWidth = 40;
|
|
243
|
+
const scaleFactor =
|
|
244
|
+
innerWidth < minTotalWidth ? innerWidth / minTotalWidth : 1;
|
|
245
|
+
|
|
246
|
+
const idWidth = Math.max(6, Math.floor(9 * scaleFactor));
|
|
247
|
+
const nameWidth = Math.max(8, Math.floor(15 * scaleFactor));
|
|
248
|
+
const statusWidth = Math.max(10, Math.floor(18 * scaleFactor));
|
|
249
|
+
const timeWidth = Math.max(4, Math.floor(8 * scaleFactor));
|
|
250
|
+
const sizeWidth = Math.max(4, Math.floor(8 * scaleFactor));
|
|
233
251
|
|
|
234
252
|
const hasProcessScroll = processes.length > maxVisibleProcesses;
|
|
235
253
|
const headerSuffixText = hasProcessScroll
|
|
@@ -237,18 +255,16 @@ export class ProcessesComponent implements Component {
|
|
|
237
255
|
: "";
|
|
238
256
|
const headerSuffixLen = hasProcessScroll ? headerSuffixText.length : 0;
|
|
239
257
|
|
|
240
|
-
//
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
headerSuffixLen,
|
|
251
|
-
);
|
|
258
|
+
// Calculate command column width based on remaining space
|
|
259
|
+
const fixedWidth =
|
|
260
|
+
prefixWidth +
|
|
261
|
+
idWidth +
|
|
262
|
+
nameWidth +
|
|
263
|
+
statusWidth +
|
|
264
|
+
timeWidth +
|
|
265
|
+
sizeWidth +
|
|
266
|
+
headerSuffixLen;
|
|
267
|
+
const cmdWidth = Math.max(4, innerWidth - fixedWidth);
|
|
252
268
|
|
|
253
269
|
lines.push(padLine(""));
|
|
254
270
|
const header =
|
|
@@ -409,7 +425,12 @@ export class ProcessesComponent implements Component {
|
|
|
409
425
|
const footerLeftLen = visibleWidth(footerLeft);
|
|
410
426
|
const footerRightLen = visibleWidth(footerRight);
|
|
411
427
|
const footerGap = Math.max(2, innerWidth - footerLeftLen - footerRightLen);
|
|
412
|
-
|
|
428
|
+
let footer = footerLeft + " ".repeat(footerGap) + footerRight;
|
|
429
|
+
|
|
430
|
+
// Truncate footer if it exceeds inner width (e.g., on very narrow terminals)
|
|
431
|
+
if (footerLeftLen + footerGap + footerRightLen > innerWidth) {
|
|
432
|
+
footer = truncateToWidth(footer, innerWidth);
|
|
433
|
+
}
|
|
413
434
|
|
|
414
435
|
lines.push(padLine(footer));
|
|
415
436
|
lines.push(renderPanelRule(width, theme));
|
package/src/config.ts
CHANGED
|
@@ -28,6 +28,10 @@ export interface ProcessesConfig {
|
|
|
28
28
|
/** Show the status widget below the editor. */
|
|
29
29
|
showStatusWidget?: boolean;
|
|
30
30
|
};
|
|
31
|
+
interception?: {
|
|
32
|
+
/** Block background bash commands (&, nohup, disown, setsid) and guide the model to use the process tool. */
|
|
33
|
+
blockBackgroundCommands?: boolean;
|
|
34
|
+
};
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
export interface ResolvedProcessesConfig {
|
|
@@ -45,6 +49,9 @@ export interface ResolvedProcessesConfig {
|
|
|
45
49
|
widget: {
|
|
46
50
|
showStatusWidget: boolean;
|
|
47
51
|
};
|
|
52
|
+
interception: {
|
|
53
|
+
blockBackgroundCommands: boolean;
|
|
54
|
+
};
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
const DEFAULT_CONFIG: ResolvedProcessesConfig = {
|
|
@@ -60,6 +67,9 @@ const DEFAULT_CONFIG: ResolvedProcessesConfig = {
|
|
|
60
67
|
widget: {
|
|
61
68
|
showStatusWidget: true,
|
|
62
69
|
},
|
|
70
|
+
interception: {
|
|
71
|
+
blockBackgroundCommands: false,
|
|
72
|
+
},
|
|
63
73
|
};
|
|
64
74
|
|
|
65
75
|
export const configLoader = new ConfigLoader<
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blocks background bash commands (e.g. `cmd &`, `nohup cmd`) and guides
|
|
3
|
+
* the model to use the process tool instead.
|
|
4
|
+
*
|
|
5
|
+
* Opt-in via config: `interception.blockBackgroundCommands`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { parse } from "@aliou/sh";
|
|
9
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
10
|
+
import { walkCommands, wordToString } from "../utils/shell-utils";
|
|
11
|
+
|
|
12
|
+
const BACKGROUND_CMD_NAMES = new Set(["nohup", "disown", "setsid"]);
|
|
13
|
+
const BACKGROUND_PATTERN = /&\s*$/;
|
|
14
|
+
|
|
15
|
+
export function setupBackgroundBlocker(pi: ExtensionAPI): void {
|
|
16
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
17
|
+
if (event.toolName !== "bash") return;
|
|
18
|
+
|
|
19
|
+
const command = String(event.input.command ?? "");
|
|
20
|
+
|
|
21
|
+
let hasBackground = false;
|
|
22
|
+
try {
|
|
23
|
+
const { ast } = parse(command);
|
|
24
|
+
|
|
25
|
+
// Check statement-level background flag (cmd &)
|
|
26
|
+
for (const stmt of ast.body) {
|
|
27
|
+
if (stmt.background) {
|
|
28
|
+
hasBackground = true;
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Check for nohup/disown/setsid as command names
|
|
34
|
+
if (!hasBackground) {
|
|
35
|
+
walkCommands(ast, (cmd) => {
|
|
36
|
+
const name = cmd.words?.[0] ? wordToString(cmd.words[0]) : undefined;
|
|
37
|
+
if (name && BACKGROUND_CMD_NAMES.has(name)) {
|
|
38
|
+
hasBackground = true;
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Fallback to regex on parse failure
|
|
46
|
+
hasBackground = BACKGROUND_PATTERN.test(command);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (hasBackground) {
|
|
50
|
+
ctx.ui?.notify(
|
|
51
|
+
"Blocked background command. Use the process tool instead.",
|
|
52
|
+
"warning",
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
block: true,
|
|
57
|
+
reason:
|
|
58
|
+
"Background commands (&, nohup, disown, setsid) are not supported in bash. " +
|
|
59
|
+
'Use the "process" tool with action "start" to run commands in the background. ' +
|
|
60
|
+
'Example: process({ action: "start", name: "my-server", command: "npm run dev" })',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return;
|
|
65
|
+
});
|
|
66
|
+
}
|
package/src/hooks/index.ts
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { ResolvedProcessesConfig } from "../config";
|
|
2
3
|
import type { ProcessManager } from "../manager";
|
|
4
|
+
import { setupBackgroundBlocker } from "./background-blocker";
|
|
3
5
|
import { setupCleanupHook } from "./cleanup";
|
|
4
6
|
import { setupMessageRenderer } from "./message-renderer";
|
|
5
7
|
import { setupProcessEndHook } from "./process-end";
|
|
6
8
|
import { setupProcessWidget } from "./widget";
|
|
7
9
|
|
|
8
|
-
export function setupProcessesHooks(
|
|
10
|
+
export function setupProcessesHooks(
|
|
11
|
+
pi: ExtensionAPI,
|
|
12
|
+
manager: ProcessManager,
|
|
13
|
+
config: ResolvedProcessesConfig,
|
|
14
|
+
) {
|
|
9
15
|
setupCleanupHook(pi, manager);
|
|
10
16
|
setupProcessEndHook(pi, manager);
|
|
11
17
|
|
|
18
|
+
if (config.interception.blockBackgroundCommands) {
|
|
19
|
+
setupBackgroundBlocker(pi);
|
|
20
|
+
}
|
|
21
|
+
|
|
12
22
|
// Set up widget AFTER process-end so it chains onto the existing callback
|
|
13
23
|
const widget = setupProcessWidget(pi, manager);
|
|
14
24
|
|
package/src/hooks/widget.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type {
|
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
ExtensionContext,
|
|
4
4
|
} from "@mariozechner/pi-coding-agent";
|
|
5
|
-
import { visibleWidth } from "@mariozechner/pi-tui";
|
|
5
|
+
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
6
6
|
import { configLoader } from "../config";
|
|
7
7
|
import type { ProcessInfo } from "../constants";
|
|
8
8
|
import type { ProcessManager } from "../manager";
|
|
@@ -75,16 +75,28 @@ function renderWidget(
|
|
|
75
75
|
for (const proc of allProcs) {
|
|
76
76
|
const formatted = formatProcessStatus(proc, theme);
|
|
77
77
|
const formattedLen = visibleWidth(formatted);
|
|
78
|
+
const remaining = allProcs.length - includedCount - 1;
|
|
78
79
|
|
|
79
80
|
// Check if adding this part would exceed the width
|
|
80
81
|
const needed =
|
|
81
82
|
includedCount > 0 ? separatorLen + formattedLen : formattedLen;
|
|
82
83
|
|
|
83
|
-
|
|
84
|
+
// If there are more processes after this one, reserve space for the
|
|
85
|
+
// overflow suffix (separator + "+N more") so the final line fits.
|
|
86
|
+
let reservedForSuffix = 0;
|
|
87
|
+
if (remaining > 0) {
|
|
88
|
+
const suffixText = `+${remaining} more`;
|
|
89
|
+
reservedForSuffix = separatorLen + visibleWidth(suffixText);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (
|
|
93
|
+
currentLen + needed + reservedForSuffix > effectiveMax &&
|
|
94
|
+
includedCount > 0
|
|
95
|
+
) {
|
|
84
96
|
// Show how many are hidden
|
|
85
|
-
const
|
|
86
|
-
if (
|
|
87
|
-
parts.push(theme.fg("dim", `+${
|
|
97
|
+
const hiddenCount = allProcs.length - includedCount;
|
|
98
|
+
if (hiddenCount > 0) {
|
|
99
|
+
parts.push(theme.fg("dim", `+${hiddenCount} more`));
|
|
88
100
|
}
|
|
89
101
|
break;
|
|
90
102
|
}
|
|
@@ -94,11 +106,25 @@ function renderWidget(
|
|
|
94
106
|
includedCount++;
|
|
95
107
|
}
|
|
96
108
|
|
|
109
|
+
// Edge case: the very first element was too wide and the loop's overflow
|
|
110
|
+
// check was skipped (it only fires when includedCount > 0). The suffix
|
|
111
|
+
// reservation already shrinks the budget, but a single process that fills
|
|
112
|
+
// the whole line still slips through. Show it truncated rather than nothing.
|
|
113
|
+
if (includedCount === 0 && allProcs.length > 0) {
|
|
114
|
+
const formatted = formatProcessStatus(allProcs[0], theme);
|
|
115
|
+
parts.push(formatted);
|
|
116
|
+
}
|
|
117
|
+
|
|
97
118
|
if (parts.length === 0) {
|
|
98
119
|
return [];
|
|
99
120
|
}
|
|
100
121
|
|
|
101
|
-
|
|
122
|
+
const line = prefix + parts.join(separator);
|
|
123
|
+
return [
|
|
124
|
+
visibleWidth(line) > effectiveMax
|
|
125
|
+
? truncateToWidth(line, effectiveMax)
|
|
126
|
+
: line,
|
|
127
|
+
];
|
|
102
128
|
}
|
|
103
129
|
|
|
104
130
|
export function setupProcessWidget(pi: ExtensionAPI, manager: ProcessManager) {
|
package/src/index.ts
CHANGED
|
@@ -20,7 +20,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
20
20
|
getConfiguredShellPath: () => configLoader.getConfig().execution.shellPath,
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
const
|
|
23
|
+
const config = configLoader.getConfig();
|
|
24
|
+
const { update: updateWidget } = setupProcessesHooks(pi, manager, config);
|
|
24
25
|
setupProcessesCommands(pi, manager);
|
|
25
26
|
setupProcessesTools(pi, manager);
|
|
26
27
|
registerProcessesSettings(pi, () => {
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import type * as nodeFs from "node:fs";
|
|
1
2
|
import { existsSync } from "node:fs";
|
|
2
3
|
import { describe, expect, it, vi } from "vitest";
|
|
3
4
|
import { resolveShellExecutable } from "./command-executor";
|
|
4
5
|
|
|
5
6
|
vi.mock("node:fs", async (importOriginal) => {
|
|
6
|
-
const actual = await importOriginal<typeof
|
|
7
|
+
const actual = await importOriginal<typeof nodeFs>();
|
|
7
8
|
return { ...actual, existsSync: vi.fn() };
|
|
8
9
|
});
|
|
9
10
|
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Shell AST helpers. Duplicated from pi-toolchain since cross-extension imports are not allowed.
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
Command,
|
|
5
|
+
Program,
|
|
6
|
+
SimpleCommand,
|
|
7
|
+
Statement,
|
|
8
|
+
Word,
|
|
9
|
+
WordPart,
|
|
10
|
+
} from "@aliou/sh";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolve a Word node to its literal string value.
|
|
14
|
+
* Concatenates Literal, SglQuoted, and simple DblQuoted parts.
|
|
15
|
+
* For parts containing parameter expansions, command substitutions, etc.,
|
|
16
|
+
* includes the raw text representation (e.g. `$VAR`).
|
|
17
|
+
*/
|
|
18
|
+
export function wordToString(word: Word): string {
|
|
19
|
+
return word.parts.map(partToString).join("");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function partToString(part: WordPart): string {
|
|
23
|
+
switch (part.type) {
|
|
24
|
+
case "Literal":
|
|
25
|
+
return part.value;
|
|
26
|
+
case "SglQuoted":
|
|
27
|
+
return part.value;
|
|
28
|
+
case "DblQuoted":
|
|
29
|
+
return part.parts.map(partToString).join("");
|
|
30
|
+
case "ParamExp":
|
|
31
|
+
return part.short
|
|
32
|
+
? `$${part.param.value}`
|
|
33
|
+
: `\${${part.param.value}${part.op ?? ""}${part.value ? wordToString(part.value) : ""}}`;
|
|
34
|
+
case "CmdSubst":
|
|
35
|
+
return "$(...)";
|
|
36
|
+
case "ArithExp":
|
|
37
|
+
return `$((${part.expr}))`;
|
|
38
|
+
case "ProcSubst":
|
|
39
|
+
return `${part.op}(...)`;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Walk the AST and call `callback` for every SimpleCommand found at any
|
|
45
|
+
* nesting depth. Returns early if callback returns `true`.
|
|
46
|
+
*/
|
|
47
|
+
export function walkCommands(
|
|
48
|
+
node: Program,
|
|
49
|
+
callback: (cmd: SimpleCommand) => boolean | undefined,
|
|
50
|
+
): void {
|
|
51
|
+
for (const stmt of node.body) {
|
|
52
|
+
if (walkStatement(stmt, callback)) return;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function walkStatement(
|
|
57
|
+
stmt: Statement,
|
|
58
|
+
callback: (cmd: SimpleCommand) => boolean | undefined,
|
|
59
|
+
): boolean {
|
|
60
|
+
return walkCommand(stmt.command, callback);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function walkStatements(
|
|
64
|
+
stmts: Statement[],
|
|
65
|
+
callback: (cmd: SimpleCommand) => boolean | undefined,
|
|
66
|
+
): boolean {
|
|
67
|
+
for (const stmt of stmts) {
|
|
68
|
+
if (walkStatement(stmt, callback)) return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function walkCommand(
|
|
74
|
+
cmd: Command,
|
|
75
|
+
callback: (cmd: SimpleCommand) => boolean | undefined,
|
|
76
|
+
): boolean {
|
|
77
|
+
switch (cmd.type) {
|
|
78
|
+
case "SimpleCommand":
|
|
79
|
+
return callback(cmd) === true;
|
|
80
|
+
|
|
81
|
+
case "Pipeline":
|
|
82
|
+
return walkStatements(cmd.commands, callback);
|
|
83
|
+
|
|
84
|
+
case "Logical":
|
|
85
|
+
return (
|
|
86
|
+
walkStatement(cmd.left, callback) || walkStatement(cmd.right, callback)
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
case "Subshell":
|
|
90
|
+
case "Block":
|
|
91
|
+
return walkStatements(cmd.body, callback);
|
|
92
|
+
|
|
93
|
+
case "IfClause":
|
|
94
|
+
return (
|
|
95
|
+
walkStatements(cmd.cond, callback) ||
|
|
96
|
+
walkStatements(cmd.then, callback) ||
|
|
97
|
+
(cmd.else ? walkStatements(cmd.else, callback) : false)
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
case "ForClause":
|
|
101
|
+
case "SelectClause":
|
|
102
|
+
case "WhileClause":
|
|
103
|
+
return (
|
|
104
|
+
("cond" in cmd && cmd.cond
|
|
105
|
+
? walkStatements(cmd.cond, callback)
|
|
106
|
+
: false) || walkStatements(cmd.body, callback)
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
case "CaseClause":
|
|
110
|
+
for (const item of cmd.items) {
|
|
111
|
+
if (walkStatements(item.body, callback)) return true;
|
|
112
|
+
}
|
|
113
|
+
return false;
|
|
114
|
+
|
|
115
|
+
case "FunctionDecl":
|
|
116
|
+
return walkStatements(cmd.body, callback);
|
|
117
|
+
|
|
118
|
+
case "TimeClause":
|
|
119
|
+
return walkStatement(cmd.command, callback);
|
|
120
|
+
|
|
121
|
+
case "CoprocClause":
|
|
122
|
+
return walkStatement(cmd.body, callback);
|
|
123
|
+
|
|
124
|
+
case "CStyleLoop":
|
|
125
|
+
return walkStatements(cmd.body, callback);
|
|
126
|
+
|
|
127
|
+
case "TestClause":
|
|
128
|
+
case "ArithCmd":
|
|
129
|
+
case "DeclClause":
|
|
130
|
+
case "LetClause":
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|