@aliou/pi-processes 0.4.7 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aliou/pi-processes",
3
- "version": "0.4.7",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "private": false,
@@ -30,18 +30,20 @@
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",
@@ -50,12 +52,16 @@
50
52
  "peerDependenciesMeta": {
51
53
  "@mariozechner/pi-coding-agent": {
52
54
  "optional": true
55
+ },
56
+ "@mariozechner/pi-tui": {
57
+ "optional": true
53
58
  }
54
59
  },
55
60
  "scripts": {
56
61
  "typecheck": "tsc --noEmit",
57
62
  "lint": "biome check",
58
63
  "format": "biome check --write",
64
+ "check:lockfile": "pnpm install --frozen-lockfile --ignore-scripts",
59
65
  "changeset": "changeset",
60
66
  "version": "changeset version",
61
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 { type Component, matchesKey } from "@mariozechner/pi-tui";
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 padLine = createPanelPadder(width);
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 { type Component, matchesKey, visibleWidth } from "@mariozechner/pi-tui";
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 padLine = createPanelPadder(width);
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
- const idWidth = 9;
229
- const nameWidth = 15;
230
- const statusWidth = 18;
231
- const timeWidth = 8;
232
- const sizeWidth = 8;
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
- // Reserve space for scroll suffix in the command column
241
- const cmdWidth = Math.max(
242
- 10,
243
- innerWidth -
244
- prefixWidth -
245
- idWidth -
246
- nameWidth -
247
- statusWidth -
248
- timeWidth -
249
- sizeWidth -
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
- const footer = footerLeft + " ".repeat(footerGap) + footerRight;
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
+ }
@@ -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(pi: ExtensionAPI, manager: ProcessManager) {
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
 
@@ -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
- if (currentLen + needed > effectiveMax && includedCount > 0) {
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 remaining = allProcs.length - includedCount;
86
- if (remaining > 0) {
87
- parts.push(theme.fg("dim", `+${remaining} more`));
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
- return [prefix + parts.join(separator)];
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 { update: updateWidget } = setupProcessesHooks(pi, manager);
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 import("node:fs")>();
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
+ }