@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,320 @@
1
+ import type { ProcessManager } from "../../../../src/manager";
2
+ import type { ProcessInfo } from "../../../../src/types";
3
+ import { LIVE_STATUSES } from "../../../../src/types";
4
+ import { isRecord } from "../../../../src/utils/is-record";
5
+ import type {
6
+ LogMatcherConfig,
7
+ NotificationRegistry,
8
+ WatchRemoveSpec,
9
+ WatchUpdateResult,
10
+ } from "../../notifications/registry";
11
+ import { normalizeLogMatchItems } from "../notify";
12
+ import type { ProcessesParamsType } from "../schema";
13
+
14
+ export interface UpdateDetails {
15
+ action: "update";
16
+ ok: boolean;
17
+ error?: string;
18
+ process?: ProcessInfo;
19
+ renamed: boolean;
20
+ previousName: string | null;
21
+ watches: {
22
+ mode: "append" | "replace" | "remove" | "clear" | null;
23
+ before: LogMatcherConfig[];
24
+ applied: LogMatcherConfig[];
25
+ count: number;
26
+ items: LogMatcherConfig[];
27
+ };
28
+ }
29
+
30
+ export function executeUpdate(
31
+ params: ProcessesParamsType,
32
+ manager: ProcessManager,
33
+ notifications: NotificationRegistry,
34
+ ): UpdateDetails {
35
+ if (!params.id) {
36
+ return {
37
+ action: "update",
38
+ ok: false,
39
+ error: "process update requires id",
40
+ renamed: false,
41
+ previousName: null,
42
+ watches: { mode: null, before: [], applied: [], count: 0, items: [] },
43
+ };
44
+ }
45
+
46
+ const before = manager.get(params.id);
47
+ if (!before) {
48
+ return {
49
+ action: "update",
50
+ ok: false,
51
+ error: `process not found: ${params.id}`,
52
+ renamed: false,
53
+ previousName: null,
54
+ watches: { mode: null, before: [], applied: [], count: 0, items: [] },
55
+ };
56
+ }
57
+
58
+ if (!LIVE_STATUSES.has(before.status)) {
59
+ return {
60
+ action: "update",
61
+ ok: false,
62
+ error: `process update requires a running process; ${params.id} has status ${before.status}`,
63
+ process: before,
64
+ renamed: false,
65
+ previousName: null,
66
+ watches: { mode: null, before: [], applied: [], count: 0, items: [] },
67
+ };
68
+ }
69
+
70
+ let process = before;
71
+ let renamed = false;
72
+ let previousName: string | null = null;
73
+
74
+ if (params.name !== undefined) {
75
+ const name = params.name.trim();
76
+ if (!name) {
77
+ return {
78
+ action: "update",
79
+ ok: false,
80
+ error: "process update name must be non-empty",
81
+ process: before,
82
+ renamed: false,
83
+ previousName: null,
84
+ watches: { mode: null, before: [], applied: [], count: 0, items: [] },
85
+ };
86
+ }
87
+
88
+ const renamedProcess = manager.rename(params.id, name);
89
+ if (!renamedProcess) {
90
+ return {
91
+ action: "update",
92
+ ok: false,
93
+ error: `process not found: ${params.id}`,
94
+ renamed: false,
95
+ previousName: null,
96
+ watches: { mode: null, before: [], applied: [], count: 0, items: [] },
97
+ };
98
+ }
99
+
100
+ renamed = before.name !== renamedProcess.name;
101
+ if (renamed) {
102
+ previousName = before.name;
103
+ }
104
+ process = renamedProcess;
105
+ }
106
+
107
+ let watchError: string | undefined;
108
+ const watchesBefore =
109
+ notifications.getWatchState(params.id)?.logMatches ?? [];
110
+ let watchesApplied: LogMatcherConfig[] = [];
111
+ const watchResult = applyWatchUpdate(
112
+ params.id,
113
+ params.watches,
114
+ notifications,
115
+ );
116
+
117
+ if (params.watches && !watchResult) {
118
+ watchError = `process update could not update watches for ${params.id}`;
119
+ }
120
+
121
+ if (watchResult) {
122
+ watchesApplied =
123
+ params.watches?.mode === "append" || params.watches?.mode === "replace"
124
+ ? watchResult.logMatches.slice(
125
+ params.watches.mode === "append" ? watchesBefore.length : 0,
126
+ )
127
+ : [];
128
+ }
129
+
130
+ const currentWatches =
131
+ watchResult?.logMatches ??
132
+ notifications.getWatchState(params.id)?.logMatches ??
133
+ [];
134
+
135
+ if (watchError) {
136
+ return {
137
+ action: "update",
138
+ ok: false,
139
+ error: watchError,
140
+ process,
141
+ renamed,
142
+ previousName,
143
+ watches: {
144
+ mode: params.watches?.mode ?? null,
145
+ before: watchesBefore,
146
+ applied: [],
147
+ count: currentWatches.length,
148
+ items: currentWatches,
149
+ },
150
+ };
151
+ }
152
+
153
+ return {
154
+ action: "update",
155
+ ok: true,
156
+ process,
157
+ renamed,
158
+ previousName,
159
+ watches: {
160
+ mode: params.watches?.mode ?? null,
161
+ before: watchesBefore,
162
+ applied: watchesApplied,
163
+ count: currentWatches.length,
164
+ items: currentWatches,
165
+ },
166
+ };
167
+ }
168
+
169
+ function applyWatchUpdate(
170
+ processId: string,
171
+ watches: ProcessesParamsType["watches"],
172
+ notifications: NotificationRegistry,
173
+ ): WatchUpdateResult | null {
174
+ if (!watches) return null;
175
+
176
+ const { mode } = watches;
177
+
178
+ if (mode === "clear") {
179
+ return notifications.clearWatches(processId);
180
+ }
181
+
182
+ const items = watches.items;
183
+ if (!items || items.length === 0) {
184
+ throw new Error(
185
+ `process update watches.items is required for mode=${mode}`,
186
+ );
187
+ }
188
+
189
+ if (mode === "append" || mode === "replace") {
190
+ const normalized = normalizeLogMatchItems(items, {
191
+ actionLabel: "process update",
192
+ pathPrefix: "watches.items",
193
+ });
194
+
195
+ return mode === "append"
196
+ ? notifications.appendWatches(processId, normalized)
197
+ : notifications.replaceWatches(processId, normalized);
198
+ }
199
+
200
+ if (mode === "remove") {
201
+ const specs = normalizeWatchRemoveSpecs(items);
202
+ return notifications.removeWatches(processId, specs);
203
+ }
204
+
205
+ throw new Error(`unsupported watch update mode: ${String(mode)}`);
206
+ }
207
+
208
+ interface RawRemoveItem {
209
+ index?: number;
210
+ pattern?: string;
211
+ mode?: string;
212
+ stream?: string;
213
+ repeat?: boolean;
214
+ on?: string;
215
+ }
216
+
217
+ function normalizeWatchRemoveSpecs(items: unknown[]): WatchRemoveSpec[] {
218
+ return items.map((raw, index) => {
219
+ if (!isRecord(raw)) {
220
+ throw new Error(
221
+ `process update watches.items[${index}] must be an object`,
222
+ );
223
+ }
224
+
225
+ const item = raw as RawRemoveItem;
226
+
227
+ if (item.index !== undefined) {
228
+ if (
229
+ typeof item.index !== "number" ||
230
+ !Number.isInteger(item.index) ||
231
+ item.index < 0
232
+ ) {
233
+ throw new Error(
234
+ `process update watches.items[${index}].index must be a non-negative integer`,
235
+ );
236
+ }
237
+
238
+ return { index: item.index };
239
+ }
240
+
241
+ if (item.pattern !== undefined) {
242
+ if (typeof item.pattern !== "string") {
243
+ throw new Error(
244
+ `process update watches.items[${index}].pattern must be a string`,
245
+ );
246
+ }
247
+
248
+ if (item.pattern.trim().length === 0) {
249
+ throw new Error(
250
+ `process update watches.items[${index}].pattern must not be empty`,
251
+ );
252
+ }
253
+
254
+ if (item.mode === "regex") {
255
+ try {
256
+ new RegExp(item.pattern);
257
+ } catch (error) {
258
+ const message =
259
+ error instanceof Error ? error.message : String(error);
260
+ throw new Error(
261
+ `process update watches.items[${index}].pattern is not a valid regular expression: ${message}`,
262
+ );
263
+ }
264
+ }
265
+
266
+ return {
267
+ pattern: item.pattern,
268
+ mode: item.mode as WatchRemoveSpec["mode"],
269
+ stream: item.stream as WatchRemoveSpec["stream"],
270
+ repeat: item.repeat,
271
+ on: item.on as WatchRemoveSpec["on"],
272
+ };
273
+ }
274
+
275
+ throw new Error(
276
+ `process update watches.items[${index}] requires either index or pattern for remove mode`,
277
+ );
278
+ });
279
+ }
280
+
281
+ export function formatUpdateDetails(details: UpdateDetails): string {
282
+ if (!details.ok) {
283
+ return details.error ?? "process update failed";
284
+ }
285
+
286
+ const parts: string[] = [];
287
+
288
+ if (details.renamed && details.previousName && details.process) {
289
+ parts.push(
290
+ `Renamed process from "${details.previousName}" to "${details.process.name}" (${details.process.id}).`,
291
+ );
292
+ } else if (details.process) {
293
+ parts.push(
294
+ `Updated process ${details.process.name} (${details.process.id}).`,
295
+ );
296
+ }
297
+
298
+ const { mode, applied, before, count } = details.watches;
299
+
300
+ if (mode === "append") {
301
+ parts.push(
302
+ `Appended ${applied.length} watch${applied.length === 1 ? "" : "es"}; ${count} active.`,
303
+ );
304
+ } else if (mode === "replace") {
305
+ parts.push(
306
+ `Replaced ${before.length} with ${applied.length} watch${applied.length === 1 ? "" : "es"}; ${count} active.`,
307
+ );
308
+ } else if (mode === "remove") {
309
+ const removed = before.length - count;
310
+ parts.push(
311
+ `Removed ${removed} watch${removed === 1 ? "" : "es"}; ${count} active.`,
312
+ );
313
+ } else if (mode === "clear") {
314
+ parts.push(
315
+ `Cleared ${before.length} watch${before.length === 1 ? "" : "es"}; ${count} active.`,
316
+ );
317
+ }
318
+
319
+ return parts.join(" ") || "Updated process.";
320
+ }
@@ -0,0 +1,251 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Container, Text } from "@earendil-works/pi-tui";
3
+ import type { LogMatcherConfig } from "../../notifications/registry";
4
+ import { buildMatcherLine, ProcessActionHeader } from "../components";
5
+ import type { ProcessesParamsType } from "../schema";
6
+ import { buildField } from "../utils";
7
+ import type { UpdateDetails } from ".";
8
+
9
+ export function buildHeader(
10
+ args: ProcessesParamsType,
11
+ theme: Theme,
12
+ options?: { expanded?: boolean },
13
+ ): Container {
14
+ return new ProcessActionHeader(args, theme, {
15
+ action: "update",
16
+ expanded: options?.expanded,
17
+ suffix: args.id ? theme.fg("accent", `(${args.id})`) : undefined,
18
+ });
19
+ }
20
+
21
+ export function buildExpanded(details: UpdateDetails, theme: Theme): Container {
22
+ const container = new Container();
23
+
24
+ if (!details.ok) {
25
+ container.addChild(
26
+ new Text(theme.fg("error", details.error ?? "update failed"), 0, 0),
27
+ );
28
+ return container;
29
+ }
30
+
31
+ if (details.renamed && details.previousName && details.process) {
32
+ container.addChild(
33
+ new Text(
34
+ `${theme.fg("muted", "renamed:")} ${details.previousName} -> ${theme.fg("accent", details.process.name)}`,
35
+ 0,
36
+ 0,
37
+ ),
38
+ );
39
+ }
40
+
41
+ if (details.watches.mode) {
42
+ container.addChild(buildWatchSection(details, theme));
43
+ }
44
+
45
+ return container;
46
+ }
47
+
48
+ export function buildCollapsed(
49
+ details: UpdateDetails,
50
+ theme: Theme,
51
+ ): Container {
52
+ const container = new Container();
53
+
54
+ if (!details.ok) {
55
+ container.addChild(
56
+ new Text(theme.fg("error", details.error ?? "update failed"), 0, 0),
57
+ );
58
+ return container;
59
+ }
60
+
61
+ if (details.renamed && details.previousName && details.process) {
62
+ container.addChild(
63
+ buildField(
64
+ "renamed",
65
+ `${details.previousName} -> ${details.process.name}`,
66
+ theme,
67
+ ),
68
+ );
69
+ }
70
+
71
+ if (details.watches.mode) {
72
+ container.addChild(buildWatchSummary(details, theme));
73
+ }
74
+
75
+ return container;
76
+ }
77
+
78
+ export function buildFooter(
79
+ _details: UpdateDetails,
80
+ _options: { expanded?: boolean },
81
+ _theme: Theme,
82
+ ): Container | null {
83
+ return null;
84
+ }
85
+
86
+ // --- Collapsed summary ---
87
+
88
+ function buildWatchSummary(details: UpdateDetails, theme: Theme): Container {
89
+ const container = new Container();
90
+ const { mode, applied, before, count } = details.watches;
91
+
92
+ if (mode === "append") {
93
+ container.addChild(
94
+ buildField(
95
+ "watches",
96
+ `${theme.fg("success", `appended ${applied.length}`)}; ${count} active`,
97
+ theme,
98
+ ),
99
+ );
100
+ } else if (mode === "replace") {
101
+ container.addChild(
102
+ buildField(
103
+ "watches",
104
+ `${theme.fg("accent", `replaced ${before.length} with ${applied.length}`)}; ${count} active`,
105
+ theme,
106
+ ),
107
+ );
108
+ } else if (mode === "remove") {
109
+ const removed = before.length - count;
110
+ container.addChild(
111
+ buildField(
112
+ "watches",
113
+ `${theme.fg("error", `removed ${removed}`)}; ${count} active`,
114
+ theme,
115
+ ),
116
+ );
117
+ } else if (mode === "clear") {
118
+ container.addChild(
119
+ buildField(
120
+ "watches",
121
+ `${theme.fg("error", `cleared ${before.length}`)}; ${count} active`,
122
+ theme,
123
+ ),
124
+ );
125
+ }
126
+
127
+ return container;
128
+ }
129
+
130
+ // --- Expanded watch details ---
131
+
132
+ function buildWatchSection(details: UpdateDetails, theme: Theme): Container {
133
+ const container = new Container();
134
+ const { mode, applied, before, count } = details.watches;
135
+
136
+ // Summary line
137
+ if (mode === "append") {
138
+ container.addChild(
139
+ new Text(
140
+ theme.fg(
141
+ "muted",
142
+ `appended ${applied.length} watch${applied.length === 1 ? "" : "es"}; ${count} active`,
143
+ ),
144
+ 0,
145
+ 0,
146
+ ),
147
+ );
148
+ } else if (mode === "replace") {
149
+ container.addChild(
150
+ new Text(
151
+ theme.fg(
152
+ "muted",
153
+ `replaced ${before.length} with ${applied.length}; ${count} active`,
154
+ ),
155
+ 0,
156
+ 0,
157
+ ),
158
+ );
159
+ } else if (mode === "remove") {
160
+ const removed = before.length - count;
161
+ container.addChild(
162
+ new Text(
163
+ theme.fg(
164
+ "muted",
165
+ `removed ${removed} watch${removed === 1 ? "" : "es"}; ${count} active`,
166
+ ),
167
+ 0,
168
+ 0,
169
+ ),
170
+ );
171
+ } else if (mode === "clear") {
172
+ container.addChild(
173
+ new Text(
174
+ theme.fg(
175
+ "muted",
176
+ `cleared ${before.length} watch${before.length === 1 ? "" : "es"}; ${count} active`,
177
+ ),
178
+ 0,
179
+ 0,
180
+ ),
181
+ );
182
+ }
183
+
184
+ if (mode === "append") {
185
+ for (const matcher of applied) {
186
+ container.addChild(
187
+ buildMatcherLine(matcher, theme, theme.fg("success", "+ ")),
188
+ );
189
+ }
190
+ }
191
+
192
+ if (mode === "replace") {
193
+ for (const matcher of before) {
194
+ container.addChild(
195
+ buildMatcherLine(matcher, theme, theme.fg("error", "- ")),
196
+ );
197
+ }
198
+ for (const matcher of details.watches.items) {
199
+ container.addChild(
200
+ buildMatcherLine(matcher, theme, theme.fg("success", "+ ")),
201
+ );
202
+ }
203
+ }
204
+
205
+ if (mode === "remove") {
206
+ for (const matcher of findRemovedMatchers(before, details.watches.items)) {
207
+ container.addChild(
208
+ buildMatcherLine(matcher, theme, theme.fg("error", "- ")),
209
+ );
210
+ }
211
+ }
212
+
213
+ if (mode === "clear") {
214
+ for (const matcher of before) {
215
+ container.addChild(
216
+ buildMatcherLine(matcher, theme, theme.fg("error", "- ")),
217
+ );
218
+ }
219
+ }
220
+
221
+ return container;
222
+ }
223
+
224
+ export function findRemovedMatchers(
225
+ before: LogMatcherConfig[],
226
+ after: LogMatcherConfig[],
227
+ ): LogMatcherConfig[] {
228
+ const remaining = new Map<string, number>();
229
+ for (const matcher of after) {
230
+ const key = matcherKey(matcher);
231
+ remaining.set(key, (remaining.get(key) ?? 0) + 1);
232
+ }
233
+
234
+ return before.filter((matcher) => {
235
+ const key = matcherKey(matcher);
236
+ const count = remaining.get(key) ?? 0;
237
+ if (count === 0) return true;
238
+ remaining.set(key, count - 1);
239
+ return false;
240
+ });
241
+ }
242
+
243
+ function matcherKey(matcher: LogMatcherConfig): string {
244
+ return JSON.stringify([
245
+ matcher.pattern,
246
+ matcher.mode ?? "literal",
247
+ matcher.stream ?? "both",
248
+ matcher.repeat ?? false,
249
+ matcher.on ?? "turn",
250
+ ]);
251
+ }
@@ -0,0 +1,112 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Container, Text } from "@earendil-works/pi-tui";
3
+
4
+ import type { ProcessInfo } from "../../../src/types";
5
+ import {
6
+ formatRuntime,
7
+ formatStatus,
8
+ formatTimestamp,
9
+ shortenPath,
10
+ } from "../../../src/utils";
11
+ import { statusColor } from "../../shared/ui";
12
+ import { truncateToWidth } from "../utils/truncate";
13
+
14
+ export interface RenderOptions {
15
+ expanded?: boolean;
16
+ }
17
+
18
+ export function buildField(
19
+ label: string,
20
+ value: string | number | null,
21
+ theme: Theme,
22
+ ): Text {
23
+ return new Text(
24
+ `${theme.fg("muted", `${label}:`)} ${String(value ?? "-")}`,
25
+ 0,
26
+ 0,
27
+ );
28
+ }
29
+
30
+ export function buildCommandField(
31
+ command: string,
32
+ theme: Theme,
33
+ options?: { truncate?: boolean },
34
+ ): Text {
35
+ const value = options?.truncate
36
+ ? truncateToWidth(command, 80, theme.fg("accent", "…"))
37
+ : command;
38
+ return buildField("command", theme.fg("accent", `\`${value}\``), theme);
39
+ }
40
+
41
+ export function buildProcessDetails(
42
+ process: ProcessInfo,
43
+ theme: Theme,
44
+ options?: { runtime?: boolean },
45
+ ): Container {
46
+ const container = new Container();
47
+ container.addChild(buildField("id", process.id, theme));
48
+ container.addChild(buildField("name", process.name, theme));
49
+ container.addChild(
50
+ buildField("status", formatColoredProcessStatus(process, theme), theme),
51
+ );
52
+ container.addChild(buildField("pid", process.pid, theme));
53
+ if (options?.runtime !== false) {
54
+ container.addChild(
55
+ buildField("duration", formatProcessRuntime(process), theme),
56
+ );
57
+ }
58
+ container.addChild(
59
+ buildField("started", formatTimestamp(process.startTime), theme),
60
+ );
61
+ container.addChild(buildField("cwd", shortenPath(process.cwd), theme));
62
+ container.addChild(buildCommandField(process.command, theme));
63
+ container.addChild(buildField("stdout", process.stdoutFile, theme));
64
+ container.addChild(buildField("stderr", process.stderrFile, theme));
65
+ return container;
66
+ }
67
+
68
+ export function buildProcessSummaryRow(
69
+ process: ProcessInfo,
70
+ theme: Theme,
71
+ ): Text {
72
+ return new Text(
73
+ [
74
+ process.name,
75
+ theme.fg("accent", process.id),
76
+ `pid ${process.pid}`,
77
+ formatColoredProcessStatus(process, theme),
78
+ ].join(" "),
79
+ 0,
80
+ 0,
81
+ );
82
+ }
83
+
84
+ export function buildCompactProcessLine(
85
+ process: ProcessInfo,
86
+ theme: Theme,
87
+ ): Text {
88
+ return buildField("process", `${process.id} / pid ${process.pid}`, theme);
89
+ }
90
+
91
+ export function formatProcessRuntime(
92
+ process: ProcessInfo,
93
+ now?: number,
94
+ ): string {
95
+ if (process.startTime <= 0) return "-";
96
+ return formatRuntime(process.startTime, process.endTime, now);
97
+ }
98
+
99
+ export function formatColoredProcessStatus(
100
+ process: ProcessInfo,
101
+ theme: Theme,
102
+ ): string {
103
+ return theme.fg(statusColor(process), formatStatus(process));
104
+ }
105
+
106
+ export function plural(noun: string, count: number): string {
107
+ return count === 1 ? noun : `${noun}s`;
108
+ }
109
+
110
+ export function formatCount(count: number, singular: string): string {
111
+ return `${count} ${plural(singular, count)}`;
112
+ }
@@ -0,0 +1,14 @@
1
+ import type { LogMatcherConfig } from "../notifications/registry";
2
+
3
+ export function formatMatcherForModel(matcher: LogMatcherConfig): string {
4
+ const mode = matcher.mode ?? "literal";
5
+ const stream = matcher.stream ?? "both";
6
+ const repeat = matcher.repeat ? " (repeat)" : "";
7
+ const on = matcher.on ?? "turn";
8
+
9
+ return `[${stream}] ${mode} ${on}${repeat} ${JSON.stringify(matcher.pattern)}`;
10
+ }
11
+
12
+ export function formatPatternsForModel(matchers: LogMatcherConfig[]): string {
13
+ return JSON.stringify(matchers.map((matcher) => matcher.pattern));
14
+ }