@aliou/pi-processes 0.9.4 → 0.10.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.
Files changed (171) hide show
  1. package/CONTRIBUTING.md +40 -42
  2. package/README.md +54 -84
  3. package/extensions/process-tabs.ts +26 -0
  4. package/extensions/processes/client.ts +194 -0
  5. package/extensions/processes/commands/clear.ts +29 -0
  6. package/extensions/processes/commands/kill.ts +152 -0
  7. package/extensions/processes/commands/overview.ts +102 -0
  8. package/extensions/processes/components/overview-component.ts +729 -0
  9. package/extensions/processes/components/overview-panel.ts +177 -0
  10. package/extensions/processes/config/defaults.ts +28 -0
  11. package/extensions/processes/config/index.ts +13 -0
  12. package/extensions/processes/config/loader.ts +62 -0
  13. package/extensions/processes/config/migrations/000-import-legacy-process-config.ts +113 -0
  14. package/extensions/processes/config/migrations/001-v0-9-4-to-v0-10-0-config.ts +184 -0
  15. package/extensions/processes/config/migrations/002-stamp-v0-10-0-config-version.ts +21 -0
  16. package/extensions/processes/config/migrations/index.ts +25 -0
  17. package/extensions/processes/config/schema.ts +9 -0
  18. package/extensions/processes/config/types.ts +72 -0
  19. package/extensions/processes/constants.ts +1 -0
  20. package/extensions/processes/handlers/commands.ts +113 -0
  21. package/extensions/processes/handlers/kill-process.ts +31 -0
  22. package/extensions/processes/handlers/notifications.ts +147 -0
  23. package/extensions/processes/handlers/requests.ts +114 -0
  24. package/extensions/processes/handlers/subscriptions.ts +144 -0
  25. package/extensions/processes/hooks/background-blocker.ts +201 -0
  26. package/extensions/processes/hooks/cleanup.ts +43 -0
  27. package/extensions/processes/hooks/event-bridge.ts +32 -0
  28. package/extensions/processes/i18n/index.ts +3 -0
  29. package/extensions/processes/i18n/messages.ts +27 -0
  30. package/extensions/processes/i18n/translator.ts +49 -0
  31. package/extensions/processes/index.ts +109 -0
  32. package/extensions/processes/message-renderer.ts +117 -0
  33. package/extensions/processes/notification-sender.ts +43 -0
  34. package/extensions/processes/notifications/classify.ts +27 -0
  35. package/extensions/processes/notifications/log-matchers.ts +146 -0
  36. package/extensions/processes/notifications/registry.ts +225 -0
  37. package/extensions/processes/notifications/render-content.ts +122 -0
  38. package/extensions/processes/notifications/service.ts +336 -0
  39. package/extensions/processes/notifications/types.ts +11 -0
  40. package/extensions/processes/settings/apply-setting-change.ts +30 -0
  41. package/extensions/processes/settings/build-sections.ts +420 -0
  42. package/extensions/processes/settings/index.ts +42 -0
  43. package/extensions/processes/tools/clear/index.ts +21 -0
  44. package/extensions/processes/tools/clear/render.ts +40 -0
  45. package/extensions/processes/tools/components/index.ts +4 -0
  46. package/extensions/processes/tools/components/process-action-header.ts +31 -0
  47. package/extensions/processes/tools/components/process-action-title.ts +17 -0
  48. package/extensions/processes/tools/components/tool-layout.ts +70 -0
  49. package/extensions/processes/tools/components/watch.ts +53 -0
  50. package/extensions/processes/tools/index.ts +265 -0
  51. package/extensions/processes/tools/list/index.ts +157 -0
  52. package/extensions/processes/tools/list/render.ts +211 -0
  53. package/extensions/processes/tools/notify.ts +191 -0
  54. package/extensions/processes/tools/output/index.ts +307 -0
  55. package/extensions/processes/tools/output/render.ts +229 -0
  56. package/extensions/processes/tools/schema.ts +263 -0
  57. package/extensions/processes/tools/start/index.ts +64 -0
  58. package/extensions/processes/tools/start/render.ts +67 -0
  59. package/extensions/processes/tools/stop/index.ts +35 -0
  60. package/extensions/processes/tools/stop/render.ts +43 -0
  61. package/extensions/processes/tools/update/index.ts +320 -0
  62. package/extensions/processes/tools/update/render.ts +251 -0
  63. package/extensions/processes/tools/utils.ts +112 -0
  64. package/extensions/processes/tools/watch-format.ts +14 -0
  65. package/extensions/processes/tools/write/index.ts +75 -0
  66. package/extensions/processes/tools/write/render.ts +68 -0
  67. package/extensions/processes/utils/truncate.ts +285 -0
  68. package/extensions/processes-dock/client.ts +60 -0
  69. package/extensions/processes-dock/commands/dock.ts +70 -0
  70. package/extensions/processes-dock/commands/pin.ts +166 -0
  71. package/extensions/processes-dock/components/log-dock-component.ts +375 -0
  72. package/extensions/processes-dock/dock-state.ts +33 -0
  73. package/extensions/processes-dock/index.ts +28 -0
  74. package/extensions/processes-dock/logs-client.ts +5 -0
  75. package/extensions/processes-dock/widget/setup.ts +520 -0
  76. package/extensions/processes-dock/widget/status.ts +149 -0
  77. package/extensions/processes-dock/widget/types.ts +16 -0
  78. package/extensions/processes-logs/client.ts +51 -0
  79. package/extensions/processes-logs/commands/logs.ts +108 -0
  80. package/extensions/processes-logs/completions.ts +31 -0
  81. package/extensions/processes-logs/components/log-file-viewer.ts +274 -0
  82. package/extensions/processes-logs/components/log-overlay-component.ts +685 -0
  83. package/extensions/processes-logs/index.ts +27 -0
  84. package/extensions/processes-logs/logs-client.ts +101 -0
  85. package/extensions/shared/line-buffer.ts +35 -0
  86. package/extensions/shared/output-payload.ts +28 -0
  87. package/extensions/shared/ui.ts +146 -0
  88. package/package.json +47 -20
  89. package/schema.json +134 -0
  90. package/skills/pi-processes/SKILL.md +496 -25
  91. package/src/get-manager.ts +15 -0
  92. package/src/manager/index.ts +182 -0
  93. package/src/manager/internal-types.ts +130 -0
  94. package/src/manager/limits.ts +15 -0
  95. package/src/manager/process-log-store.ts +422 -0
  96. package/src/manager/process-output.ts +345 -0
  97. package/src/manager/process-registry.ts +78 -0
  98. package/src/manager/process-runtime-controller.ts +499 -0
  99. package/src/protocol/broadcasts.ts +14 -0
  100. package/src/protocol/channels.ts +30 -0
  101. package/src/protocol/commands.ts +24 -0
  102. package/src/protocol/index.ts +34 -0
  103. package/src/protocol/logs.ts +23 -0
  104. package/src/protocol/notifications.ts +48 -0
  105. package/src/protocol/requests.ts +76 -0
  106. package/src/types.ts +66 -0
  107. package/src/utils/ansi.ts +2 -4
  108. package/src/utils/format.ts +24 -32
  109. package/src/utils/index.ts +5 -1
  110. package/src/utils/is-record.ts +3 -0
  111. package/src/utils/match-line.ts +32 -0
  112. package/src/utils/platform.ts +13 -0
  113. package/src/utils/shorten-path.ts +11 -0
  114. package/src/utils/signals.ts +22 -0
  115. package/src/commands/clear/command.ts +0 -14
  116. package/src/commands/clear/index.ts +0 -1
  117. package/src/commands/completions.ts +0 -38
  118. package/src/commands/dock/command.ts +0 -29
  119. package/src/commands/dock/index.ts +0 -1
  120. package/src/commands/index.ts +0 -33
  121. package/src/commands/kill/command.ts +0 -69
  122. package/src/commands/kill/index.ts +0 -1
  123. package/src/commands/logs/command.ts +0 -47
  124. package/src/commands/logs/index.ts +0 -1
  125. package/src/commands/pick-process.ts +0 -34
  126. package/src/commands/pin/command.ts +0 -34
  127. package/src/commands/pin/index.ts +0 -1
  128. package/src/commands/processes/command.ts +0 -39
  129. package/src/commands/processes/index.ts +0 -1
  130. package/src/commands/settings/apply-setting-change.ts +0 -72
  131. package/src/commands/settings/build-sections.ts +0 -160
  132. package/src/commands/settings/command.ts +0 -20
  133. package/src/commands/settings/index.ts +0 -1
  134. package/src/components/log-dock-component.test.ts +0 -29
  135. package/src/components/log-dock-component.ts +0 -236
  136. package/src/components/log-file-viewer.ts +0 -317
  137. package/src/components/log-overlay-component.ts +0 -546
  138. package/src/components/panel-helpers.ts +0 -37
  139. package/src/components/process-picker-component.ts +0 -166
  140. package/src/components/processes-component.ts +0 -485
  141. package/src/components/status-format.ts +0 -38
  142. package/src/config.ts +0 -107
  143. package/src/constants/index.ts +0 -16
  144. package/src/constants/types.ts +0 -106
  145. package/src/hooks/background-blocker.ts +0 -66
  146. package/src/hooks/cleanup.ts +0 -10
  147. package/src/hooks/index.ts +0 -32
  148. package/src/hooks/message-renderer.ts +0 -115
  149. package/src/hooks/process-end.ts +0 -69
  150. package/src/hooks/process-watch.ts +0 -85
  151. package/src/hooks/utils.ts +0 -23
  152. package/src/hooks/widget/index.ts +0 -2
  153. package/src/hooks/widget/setup.ts +0 -167
  154. package/src/hooks/widget/status-widget.ts +0 -105
  155. package/src/hooks/widget/types.ts +0 -20
  156. package/src/index.ts +0 -35
  157. package/src/manager.test.ts +0 -331
  158. package/src/manager.ts +0 -758
  159. package/src/tools/actions/clear.ts +0 -20
  160. package/src/tools/actions/debug.ts +0 -148
  161. package/src/tools/actions/index.ts +0 -171
  162. package/src/tools/actions/kill.ts +0 -89
  163. package/src/tools/actions/list.ts +0 -188
  164. package/src/tools/actions/logs.ts +0 -117
  165. package/src/tools/actions/output.ts +0 -269
  166. package/src/tools/actions/start.ts +0 -245
  167. package/src/tools/actions/write.ts +0 -113
  168. package/src/tools/index.ts +0 -207
  169. package/src/utils/ansi.test.ts +0 -22
  170. package/src/utils/command-executor.test.ts +0 -48
  171. package/src/utils/keybindings.ts +0 -71
@@ -0,0 +1,265 @@
1
+ import {
2
+ type AgentToolResult,
3
+ defineTool,
4
+ type ExtensionAPI,
5
+ type ExtensionContext,
6
+ type Theme,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { type Component, Container, Text } from "@earendil-works/pi-tui";
9
+
10
+ import type { ProcessManager } from "../../../src/manager";
11
+ import type { NotificationRegistry } from "../notifications/registry";
12
+ import { type ClearDetails, executeClear, formatClearDetails } from "./clear";
13
+ import * as clearRender from "./clear/render";
14
+ import { ToolLayout } from "./components";
15
+ import { executeList, formatListDetails, type ListDetails } from "./list";
16
+ import * as listRender from "./list/render";
17
+ import { executeOutput, type OutputDetails } from "./output";
18
+ import * as outputRender from "./output/render";
19
+ import { ProcessesParams, type ProcessesParamsType } from "./schema";
20
+ import { executeStart, formatStartDetails, type StartDetails } from "./start";
21
+ import * as startRender from "./start/render";
22
+ import { executeStop, formatStopDetails, type StopDetails } from "./stop";
23
+ import * as stopRender from "./stop/render";
24
+ import {
25
+ executeUpdate,
26
+ formatUpdateDetails,
27
+ type UpdateDetails,
28
+ } from "./update";
29
+ import * as updateRender from "./update/render";
30
+ import { executeWrite, formatWriteDetails, type WriteDetails } from "./write";
31
+ import * as writeRender from "./write/render";
32
+
33
+ type ProcessDetails =
34
+ | StartDetails
35
+ | ListDetails
36
+ | StopDetails
37
+ | OutputDetails
38
+ | WriteDetails
39
+ | UpdateDetails
40
+ | ClearDetails;
41
+
42
+ export function registerProcessTool(
43
+ pi: ExtensionAPI,
44
+ manager: ProcessManager,
45
+ notifications: NotificationRegistry,
46
+ ): void {
47
+ pi.registerTool(
48
+ defineTool({
49
+ name: "process",
50
+ label: "Process",
51
+ description:
52
+ "Start, list, stop, write to stdin, update, clear, and inspect output of long-running background processes.",
53
+ promptSnippet:
54
+ "Manage long-running background processes: start, list, stop, write to stdin, update watches, clear finished entries, and inspect recent output. After starting a process, do not wait - notifications bring you back on exit and on log matches.",
55
+ promptGuidelines: [
56
+ "process tool: use process start for long-running commands (dev servers, watchers, builds) instead of shell background patterns like &, nohup, or setsid; give each process a specific name and check process list first when a duplicate would be noisy.",
57
+ "process tool: after process start, do not sleep, poll, or hold your turn. End your turn or move on. The process notifies you on exit (success, failure, killed) and on notify.logMatches matches, which brings you back.",
58
+ "process tool: use notify.logMatches to get brought back on readiness or error signals instead of polling process output. If a watch is too noisy, use process update (watches.mode append/replace/remove/clear) to fix it without restarting.",
59
+ "process tool: for the full lifecycle (start, list, output, update, write, stop, clear), notify options, use cases, and noisy-watch handling, read the pi-processes skill.",
60
+ ],
61
+ parameters: ProcessesParams,
62
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
63
+ if (params.action === "output") {
64
+ const result = executeOutput(params, manager);
65
+ return {
66
+ content: [{ type: "text", text: result.content }],
67
+ details: result.details,
68
+ };
69
+ }
70
+
71
+ const details = await execute(params, manager, ctx, notifications);
72
+ return {
73
+ content: [{ type: "text", text: formatDetails(details) }],
74
+ details,
75
+ };
76
+ },
77
+ renderCall: renderProcessCall,
78
+ renderResult: renderProcessResult,
79
+ }),
80
+ );
81
+ }
82
+
83
+ async function execute(
84
+ params: ProcessesParamsType,
85
+ manager: ProcessManager,
86
+ ctx: ExtensionContext,
87
+ notifications: NotificationRegistry,
88
+ ): Promise<ProcessDetails> {
89
+ switch (params.action) {
90
+ case "start":
91
+ return executeStart(params, manager, ctx, notifications);
92
+ case "list":
93
+ return executeList(manager, params, notifications);
94
+ case "stop":
95
+ return executeStop(params, manager, notifications);
96
+ case "write":
97
+ return executeWrite(params, manager);
98
+ case "update":
99
+ return executeUpdate(params, manager, notifications);
100
+ case "clear":
101
+ return executeClear(manager);
102
+ default:
103
+ throw new Error(`unsupported process action: ${String(params.action)}`);
104
+ }
105
+ }
106
+
107
+ function renderProcessCall(
108
+ args: ProcessesParamsType,
109
+ theme: Theme,
110
+ context?: { expanded?: boolean },
111
+ ): Component {
112
+ switch (args.action) {
113
+ case "start":
114
+ return new ToolLayout().setHeader(
115
+ startRender.buildHeader(args, theme, context),
116
+ );
117
+ case "list":
118
+ return new ToolLayout().setHeader(listRender.buildHeader(args, theme));
119
+ case "stop":
120
+ return new ToolLayout().setHeader(
121
+ stopRender.buildHeader(args, theme, context),
122
+ );
123
+ case "output":
124
+ return new ToolLayout().setHeader(
125
+ outputRender.buildHeader(args, theme, context),
126
+ );
127
+ case "write":
128
+ return new ToolLayout().setHeader(
129
+ writeRender.buildHeader(args, theme, context),
130
+ );
131
+ case "update":
132
+ return new ToolLayout().setHeader(
133
+ updateRender.buildHeader(args, theme, context),
134
+ );
135
+ case "clear":
136
+ return new ToolLayout().setHeader(clearRender.buildHeader(args, theme));
137
+ default:
138
+ return fallbackContainer(theme);
139
+ }
140
+ }
141
+
142
+ function renderProcessResult(
143
+ result: AgentToolResult<ProcessDetails>,
144
+ options: { expanded?: boolean; isPartial?: boolean },
145
+ theme: Theme,
146
+ context?: { isError?: boolean },
147
+ ): Component {
148
+ if (options.isPartial) {
149
+ return fallbackContainer(theme, "process running...");
150
+ }
151
+
152
+ if (context?.isError) {
153
+ return fallbackContainer(theme, "process failed");
154
+ }
155
+
156
+ const details = result.details as ProcessDetails | undefined;
157
+
158
+ if (!details) {
159
+ return fallbackContainer(theme, "process completed");
160
+ }
161
+
162
+ const contentText = getContentText(result);
163
+
164
+ return new ToolLayout()
165
+ .withSectionSpacing(options.expanded)
166
+ .setBody(buildBody(details, contentText, options, theme))
167
+ .setFooter(buildFooter(details, contentText, options, theme));
168
+ }
169
+
170
+ function getContentText(
171
+ result: AgentToolResult<ProcessDetails>,
172
+ ): string | undefined {
173
+ const first = result.content[0];
174
+ if (first?.type === "text" && typeof first.text === "string") {
175
+ return first.text;
176
+ }
177
+ return undefined;
178
+ }
179
+
180
+ function buildBody(
181
+ details: ProcessDetails,
182
+ contentText: string | undefined,
183
+ options: { expanded?: boolean },
184
+ theme: Theme,
185
+ ): Container {
186
+ switch (details.action) {
187
+ case "start":
188
+ return options.expanded
189
+ ? startRender.buildExpanded(details, theme)
190
+ : startRender.buildCollapsed(details, theme);
191
+ case "list":
192
+ return options.expanded
193
+ ? listRender.buildExpanded(details, theme)
194
+ : listRender.buildCollapsed(details, theme);
195
+ case "output":
196
+ return options.expanded
197
+ ? outputRender.buildExpanded(contentText ?? "", details, theme)
198
+ : outputRender.buildCollapsed(contentText ?? "", details, theme);
199
+ case "update":
200
+ return options.expanded
201
+ ? updateRender.buildExpanded(details, theme)
202
+ : updateRender.buildCollapsed(details, theme);
203
+ case "write":
204
+ return options.expanded
205
+ ? writeRender.buildExpanded(details, theme)
206
+ : writeRender.buildCollapsed(details, theme);
207
+ case "stop":
208
+ return options.expanded
209
+ ? stopRender.buildExpanded(details, theme)
210
+ : stopRender.buildCollapsed(details, theme);
211
+ case "clear":
212
+ return options.expanded
213
+ ? clearRender.buildExpanded(details, theme)
214
+ : clearRender.buildCollapsed(details, theme);
215
+ }
216
+ }
217
+
218
+ function buildFooter(
219
+ details: ProcessDetails,
220
+ _contentText: string | undefined,
221
+ options: { expanded?: boolean },
222
+ theme: Theme,
223
+ ): Container | null {
224
+ switch (details.action) {
225
+ case "start":
226
+ return startRender.buildFooter(details, options, theme);
227
+ case "list":
228
+ return listRender.buildFooter(details, options, theme);
229
+ case "stop":
230
+ return stopRender.buildFooter(details, options, theme);
231
+ case "output":
232
+ return outputRender.buildFooter(details, options, theme);
233
+ case "update":
234
+ return updateRender.buildFooter(details, options, theme);
235
+ case "write":
236
+ return writeRender.buildFooter(details, options, theme);
237
+ case "clear":
238
+ return clearRender.buildFooter(details, options, theme);
239
+ }
240
+ }
241
+
242
+ function formatDetails(details: ProcessDetails): string {
243
+ switch (details.action) {
244
+ case "start":
245
+ return formatStartDetails(details);
246
+ case "list":
247
+ return formatListDetails(details);
248
+ case "stop":
249
+ return formatStopDetails(details);
250
+ case "update":
251
+ return formatUpdateDetails(details);
252
+ case "write":
253
+ return formatWriteDetails(details);
254
+ case "clear":
255
+ return formatClearDetails(details);
256
+ case "output":
257
+ throw new Error("output action uses pre-formatted content");
258
+ }
259
+ }
260
+
261
+ function fallbackContainer(theme: Theme, message = "process"): Container {
262
+ const container = new Container();
263
+ container.addChild(new Text(theme.fg("muted", message), 0, 0));
264
+ return container;
265
+ }
@@ -0,0 +1,157 @@
1
+ import type { ProcessManager } from "../../../../src/manager";
2
+ import { LIVE_STATUSES, type ProcessInfo } from "../../../../src/types";
3
+ import type {
4
+ LogMatcherConfig,
5
+ NotificationRegistry,
6
+ } from "../../notifications/registry";
7
+ import type {
8
+ ProcessesParamsType,
9
+ ProcessListSort,
10
+ ProcessListStatusFilter,
11
+ } from "../schema";
12
+ import { formatProcessRuntime } from "../utils";
13
+ import { formatPatternsForModel } from "../watch-format";
14
+
15
+ /**
16
+ * A process plus its duration computed when the list was produced.
17
+ *
18
+ * For stopped processes this is the actual run duration (endTime - startTime).
19
+ * For running processes it is measured against the list call's reference time.
20
+ */
21
+ export interface ListProcess extends ProcessInfo {
22
+ duration: string;
23
+ watches: LogMatcherConfig[];
24
+ }
25
+
26
+ export interface ListDetails {
27
+ action: "list";
28
+ processes: ListProcess[];
29
+ filters: {
30
+ limit: number | null;
31
+ sortBy: ProcessListSort;
32
+ statuses: ProcessListStatusFilter[];
33
+ };
34
+ counts: ProcessListCounts;
35
+ }
36
+
37
+ export interface ProcessListCounts {
38
+ running: number;
39
+ exited: number;
40
+ failed: number;
41
+ killed: number;
42
+ total: number;
43
+ }
44
+
45
+ export function executeList(
46
+ manager: ProcessManager,
47
+ params: ProcessesParamsType,
48
+ notifications: NotificationRegistry,
49
+ ): ListDetails {
50
+ const filters = resolveListFilters(params);
51
+ const allProcesses = manager.list();
52
+ const filtered = allProcesses
53
+ .filter((process) => matchesStatusFilter(process, filters.statuses))
54
+ .sort((a, b) => compareProcesses(a, b, filters.sortBy));
55
+
56
+ const limited =
57
+ filters.limit === null ? filtered : filtered.slice(0, filters.limit);
58
+
59
+ // Capture the reference time after listing so every process in `limited`
60
+ // has startTime <= now (avoids negative durations for just-spawned ones).
61
+ const now = Date.now();
62
+ const processes: ListProcess[] = limited.map((process) => ({
63
+ ...process,
64
+ duration: formatProcessRuntime(process, now),
65
+ watches: LIVE_STATUSES.has(process.status)
66
+ ? (notifications.getWatchState(process.id)?.logMatches ?? [])
67
+ : [],
68
+ }));
69
+
70
+ return {
71
+ action: "list",
72
+ processes,
73
+ filters,
74
+ counts: countProcesses(processes),
75
+ };
76
+ }
77
+
78
+ export function formatListDetails(details: ListDetails): string {
79
+ if (details.processes.length === 0) {
80
+ return "No matching background processes.";
81
+ }
82
+
83
+ return details.processes
84
+ .map(
85
+ (process) =>
86
+ `${process.id}\t${process.status}\t${process.name}\t${process.command}\t${process.stdoutFile}\t${process.stderrFile}\tpatterns=${formatPatternsForModel(process.watches)}`,
87
+ )
88
+ .join("\n");
89
+ }
90
+
91
+ function resolveListFilters(
92
+ params: ProcessesParamsType,
93
+ ): ListDetails["filters"] {
94
+ return {
95
+ limit: params.limit && params.limit > 0 ? Math.floor(params.limit) : null,
96
+ sortBy: params.sortBy ?? "startTime_desc",
97
+ statuses:
98
+ params.statuses && params.statuses.length > 0 ? params.statuses : ["all"],
99
+ };
100
+ }
101
+
102
+ function matchesStatusFilter(
103
+ process: ProcessInfo,
104
+ filters: ProcessListStatusFilter[],
105
+ ): boolean {
106
+ if (filters.includes("all")) return true;
107
+
108
+ return filters.some((filter) => {
109
+ if (filter === "finished") {
110
+ return process.status === "exited" && process.success === true;
111
+ }
112
+
113
+ if (filter === "failed") {
114
+ return (
115
+ process.status === "terminate_timeout" ||
116
+ (process.status === "exited" && process.success === false)
117
+ );
118
+ }
119
+
120
+ return process.status === filter;
121
+ });
122
+ }
123
+
124
+ function compareProcesses(
125
+ a: ProcessInfo,
126
+ b: ProcessInfo,
127
+ sortBy: ProcessListSort,
128
+ ): number {
129
+ switch (sortBy) {
130
+ case "startTime_asc":
131
+ return a.startTime - b.startTime;
132
+ case "name_asc":
133
+ return a.name.localeCompare(b.name);
134
+ case "name_desc":
135
+ return b.name.localeCompare(a.name);
136
+ case "status_asc":
137
+ return a.status.localeCompare(b.status);
138
+ case "startTime_desc":
139
+ return b.startTime - a.startTime;
140
+ }
141
+ }
142
+
143
+ function countProcesses(processes: ProcessInfo[]): ProcessListCounts {
144
+ return {
145
+ running: processes.filter((process) => process.status === "running").length,
146
+ exited: processes.filter(
147
+ (process) => process.status === "exited" && process.success === true,
148
+ ).length,
149
+ failed: processes.filter(
150
+ (process) =>
151
+ process.status === "terminate_timeout" ||
152
+ (process.status === "exited" && process.success === false),
153
+ ).length,
154
+ killed: processes.filter((process) => process.status === "killed").length,
155
+ total: processes.length,
156
+ };
157
+ }
@@ -0,0 +1,211 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Container, Text, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { formatTimestamp, shortenPath } from "../../../../src/utils";
4
+ import { LinesComponent } from "../../../shared/ui";
5
+ import { truncateToWidth } from "../../utils/truncate";
6
+ import { formatPatternForDisplay, ProcessActionTitle } from "../components";
7
+ import type {
8
+ ProcessesParamsType,
9
+ ProcessListSort,
10
+ ProcessListStatusFilter,
11
+ } from "../schema";
12
+ import { formatColoredProcessStatus, formatCount } from "../utils";
13
+ import type { ListDetails, ListProcess, ProcessListCounts } from ".";
14
+
15
+ type CountStatusItem = {
16
+ filter: ProcessListStatusFilter;
17
+ countKey: keyof Pick<
18
+ ProcessListCounts,
19
+ "running" | "exited" | "failed" | "killed"
20
+ >;
21
+ label: string;
22
+ tone: "success" | "warning" | "error" | "muted";
23
+ };
24
+
25
+ const COUNT_STATUS_ITEMS: CountStatusItem[] = [
26
+ {
27
+ filter: "running",
28
+ countKey: "running",
29
+ label: "running",
30
+ tone: "success",
31
+ },
32
+ {
33
+ filter: "finished",
34
+ countKey: "exited",
35
+ label: "exited",
36
+ tone: "muted",
37
+ },
38
+ {
39
+ filter: "failed",
40
+ countKey: "failed",
41
+ label: "failed",
42
+ tone: "error",
43
+ },
44
+ {
45
+ filter: "killed",
46
+ countKey: "killed",
47
+ label: "killed",
48
+ tone: "warning",
49
+ },
50
+ ];
51
+
52
+ export function buildHeader(
53
+ args: ProcessesParamsType,
54
+ theme: Theme,
55
+ ): Container {
56
+ const header = new Container();
57
+ header.addChild(
58
+ new ProcessActionTitle("list", theme, formatListHeaderSuffix(args, theme)),
59
+ );
60
+ return header;
61
+ }
62
+
63
+ export function buildExpanded(details: ListDetails, theme: Theme): Container {
64
+ const container = new Container();
65
+ container.addChild(
66
+ new LinesComponent((width) =>
67
+ formatExpandedProcessLines(details.processes, theme, width),
68
+ ),
69
+ );
70
+ return container;
71
+ }
72
+
73
+ export function buildCollapsed(details: ListDetails, theme: Theme): Container {
74
+ const container = new Container();
75
+
76
+ for (const process of details.processes.slice(0, 2)) {
77
+ const parts = [
78
+ process.name,
79
+ theme.fg("accent", process.id),
80
+ `pid ${process.pid}`,
81
+ formatColoredProcessStatus(process, theme),
82
+ theme.fg(
83
+ "muted",
84
+ `${process.watches.length} ${process.watches.length === 1 ? "watch" : "watches"}`,
85
+ ),
86
+ ];
87
+ container.addChild(new Text(parts.join(" "), 0, 0));
88
+ }
89
+
90
+ return container;
91
+ }
92
+
93
+ export function buildFooter(
94
+ details: ListDetails,
95
+ _options: { expanded?: boolean },
96
+ theme: Theme,
97
+ ): Container {
98
+ const footer = new Container();
99
+ footer.addChild(new Text(formatCounts(details, theme), 0, 0));
100
+ return footer;
101
+ }
102
+
103
+ function formatListHeaderSuffix(
104
+ args: ProcessesParamsType,
105
+ theme: Theme,
106
+ ): string {
107
+ const statuses = normalizeStatuses(args.statuses);
108
+ const parts = [
109
+ `${theme.fg("muted", "sorted")} ${theme.fg("accent", formatSort(args.sortBy ?? "startTime_desc"))}`,
110
+ ];
111
+
112
+ if (args.limit) {
113
+ parts.unshift(
114
+ `${theme.fg("muted", "limit")} ${theme.fg("accent", String(args.limit))}`,
115
+ );
116
+ }
117
+
118
+ if (!statuses.includes("all")) {
119
+ parts.push(
120
+ `${theme.fg("muted", "statuses")} ${theme.fg("accent", statuses.join(", "))}`,
121
+ );
122
+ }
123
+
124
+ return parts.join(theme.fg("dim", " / "));
125
+ }
126
+
127
+ function normalizeStatuses(
128
+ statuses: ProcessListStatusFilter[] | undefined,
129
+ ): ProcessListStatusFilter[] {
130
+ return statuses && statuses.length > 0 ? statuses : ["all"];
131
+ }
132
+
133
+ function formatSort(sort: ProcessListSort): string {
134
+ switch (sort) {
135
+ case "startTime_desc":
136
+ return "newest first";
137
+ case "startTime_asc":
138
+ return "oldest first";
139
+ case "name_asc":
140
+ return "name A-Z";
141
+ case "name_desc":
142
+ return "name Z-A";
143
+ case "status_asc":
144
+ return "status A-Z";
145
+ }
146
+ }
147
+
148
+ function formatCounts(details: ListDetails, theme: Theme): string {
149
+ if (details.processes.length === 0) {
150
+ return "no process running";
151
+ }
152
+
153
+ const statuses = details.filters.statuses;
154
+ const showAll = statuses.includes("all");
155
+
156
+ return COUNT_STATUS_ITEMS.filter(
157
+ (item) => showAll || statuses.includes(item.filter),
158
+ )
159
+ .map((item) =>
160
+ theme.fg(
161
+ item.tone,
162
+ `${formatCount(details.counts[item.countKey], "process")} ${item.label}`,
163
+ ),
164
+ )
165
+ .join(theme.fg("dim", " / "));
166
+ }
167
+
168
+ export function formatExpandedProcessLines(
169
+ processes: ListProcess[],
170
+ theme: Theme,
171
+ width: number,
172
+ ): string[] {
173
+ if (processes.length === 0) {
174
+ return ["No matching background processes."];
175
+ }
176
+
177
+ const lines: string[] = [];
178
+
179
+ for (const process of processes) {
180
+ if (lines.length > 0) lines.push("");
181
+
182
+ const summary = [
183
+ theme.bold(theme.fg("text", formatPatternForDisplay(process.name))),
184
+ `${theme.fg("muted", "pid:")} ${process.pid}`,
185
+ formatColoredProcessStatus(process, theme),
186
+ theme.fg("muted", process.duration),
187
+ theme.fg("muted", `(${formatTimestamp(process.startTime)})`),
188
+ theme.fg("accent", process.id),
189
+ ].join(" ");
190
+ lines.push(fitStyledLine(summary, width));
191
+
192
+ const command = `${theme.fg("muted", " $")} ${theme.fg("text", formatPatternForDisplay(process.command))}`;
193
+ lines.push(fitStyledLine(command, width));
194
+ const cwd = `${theme.fg("muted", " cwd")} ${theme.fg("text", formatPatternForDisplay(shortenPath(process.cwd)))}`;
195
+ lines.push(fitStyledLine(cwd, width));
196
+
197
+ for (const matcher of process.watches) {
198
+ const stream = matcher.stream ?? "both";
199
+ const watch = `${theme.fg("muted", " ↳")} ${theme.fg("muted", `[${stream}]`)} ${theme.fg("accent", formatPatternForDisplay(matcher.pattern))}`;
200
+ lines.push(fitStyledLine(watch, width));
201
+ }
202
+ }
203
+
204
+ return lines;
205
+ }
206
+
207
+ function fitStyledLine(line: string, width: number): string {
208
+ if (width <= 0) return "";
209
+ if (visibleWidth(line) <= width) return line;
210
+ return `${truncateToWidth(line, width)}\u001b[0m`;
211
+ }