@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,520 @@
1
+ import type {
2
+ EventBus,
3
+ ExtensionContext,
4
+ Theme,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ CHANNELS,
8
+ type CommandPinPayload,
9
+ type ProcessProtocolNotificationPayload,
10
+ } from "../../../src/protocol";
11
+ import { LIVE_STATUSES, type ProcessInfo } from "../../../src/types";
12
+ import { isRecord } from "../../../src/utils/is-record";
13
+ import { buildDroppedOutputLine, trimToBudget } from "../../shared/line-buffer";
14
+ import { isOutputChangedPayload } from "../../shared/output-payload";
15
+ import {
16
+ type ProcessLogLine,
17
+ requestCombinedOutput,
18
+ requestConfig,
19
+ requestProcess,
20
+ requestProcessList,
21
+ } from "../client";
22
+ import { renderLogDock } from "../components/log-dock-component";
23
+ import { createDockState } from "../dock-state";
24
+ import { connectToProcessLogs, type LogsConnection } from "../logs-client";
25
+ import { renderStatusWidget } from "./status";
26
+ import type { DockActions, DockState } from "./types";
27
+
28
+ const DOCK_WIDGET_KEY = "processes-dock";
29
+ const STATUS_WIDGET_KEY = "processes-status";
30
+ const MAX_NOTIFY_MARKERS_PER_PROCESS = 100;
31
+ const MAX_PREVIEW_PROCESSES = 8;
32
+ const REFRESH_THROTTLE_MS = 125;
33
+
34
+ interface NotifyMatchMark {
35
+ line: string;
36
+ timestamp: number;
37
+ }
38
+
39
+ export interface DockController {
40
+ actions: DockActions;
41
+ refresh: () => void;
42
+ dispose: () => void;
43
+ }
44
+
45
+ export function setupDockWidgets(
46
+ ctx: ExtensionContext,
47
+ events: EventBus,
48
+ ): DockController | null {
49
+ if (!ctx.hasUI) return null;
50
+
51
+ const config = requestConfig(events);
52
+ const state = createDockState({
53
+ visibility: config.widget.dockDefaultState,
54
+ followEnabled: config.follow.enabledByDefault,
55
+ });
56
+
57
+ const notifyMarkers = new Map<string, NotifyMatchMark[]>();
58
+ const previews = new Map<string, ProcessLogLine | null>();
59
+ const disposers: Array<() => void> = [];
60
+
61
+ let disposed = false;
62
+ let processes: ProcessInfo[] = [];
63
+ let pinnedLines: ProcessLogLine[] = [];
64
+ let processLogStream: Array<{ processId: string; line: ProcessLogLine }> = [];
65
+ let pinnedConnection: LogsConnection | null = null;
66
+ let pinnedConnectionId: string | null = null;
67
+ let hasSeenRunningProcess = false;
68
+ let pendingRefresh: NodeJS.Timeout | null = null;
69
+ let pendingRender: NodeJS.Timeout | null = null;
70
+
71
+ const render = () => {
72
+ // A direct render supersedes any pending throttled render.
73
+ if (pendingRender) {
74
+ clearTimeout(pendingRender);
75
+ pendingRender = null;
76
+ }
77
+ if (disposed) return;
78
+ // Re-read config live: /ps:settings can change dock height and the
79
+ // startup snapshot may predate the persisted merge.
80
+ const liveConfig = requestConfig(events);
81
+ const current = state.getState();
82
+ const pinned = selectPinnedProcess(processes, current);
83
+ const pinnedId = pinned?.id ?? null;
84
+
85
+ if (current.visibility === "closed") {
86
+ ctx.ui.setWidget(DOCK_WIDGET_KEY, undefined, {
87
+ placement: "aboveEditor",
88
+ });
89
+ return;
90
+ }
91
+
92
+ const markerLines = new Set(
93
+ (pinnedId ? notifyMarkers.get(pinnedId) : undefined)?.map(
94
+ (mark) => mark.line,
95
+ ) ?? [],
96
+ );
97
+ const notifyCounts = new Map(
98
+ [...notifyMarkers.entries()].map(([processId, marks]) => [
99
+ processId,
100
+ marks.length,
101
+ ]),
102
+ );
103
+
104
+ ctx.ui.setWidget(
105
+ DOCK_WIDGET_KEY,
106
+ (_tui, theme: Theme) => ({
107
+ render: (width: number) =>
108
+ renderLogDock(
109
+ {
110
+ processes,
111
+ pinnedProcess: pinned,
112
+ pinnedLines,
113
+ processLogStream,
114
+ previews,
115
+ notifyLines: markerLines,
116
+ notifyCounts,
117
+ state: current,
118
+ },
119
+ theme,
120
+ width,
121
+ liveConfig.widget.dockHeight,
122
+ ),
123
+ invalidate: () => undefined,
124
+ }),
125
+ { placement: "aboveEditor" },
126
+ );
127
+ };
128
+
129
+ const renderStatus = () => {
130
+ if (disposed) return;
131
+ // Re-read config live so toggling showStatusWidget in /ps:settings takes
132
+ // effect without a restart.
133
+ const liveConfig = requestConfig(events);
134
+ if (!liveConfig.widget.showStatusWidget) {
135
+ ctx.ui.setWidget(STATUS_WIDGET_KEY, undefined, {
136
+ placement: "belowEditor",
137
+ });
138
+ return;
139
+ }
140
+ if (processes.length === 0) {
141
+ ctx.ui.setWidget(STATUS_WIDGET_KEY, undefined, {
142
+ placement: "belowEditor",
143
+ });
144
+ return;
145
+ }
146
+ // The width is only known at render time, so hand Pi a factory that
147
+ // renders against the actual belowEditor column count instead of baking a
148
+ // static width from process.stdout.columns (which drifts on resize / splits).
149
+ ctx.ui.setWidget(
150
+ STATUS_WIDGET_KEY,
151
+ (_tui, theme: Theme) => ({
152
+ render: (width: number) => renderStatusWidget(processes, theme, width),
153
+ invalidate: () => undefined,
154
+ }),
155
+ { placement: "belowEditor" },
156
+ );
157
+ };
158
+
159
+ const hardRefresh = () => {
160
+ if (disposed) return;
161
+ processes = sortProcesses(requestProcessList(events));
162
+ const liveIds = new Set(processes.map((process) => process.id));
163
+ for (const id of previews.keys()) {
164
+ if (!liveIds.has(id)) previews.delete(id);
165
+ }
166
+ for (const id of notifyMarkers.keys()) {
167
+ if (!liveIds.has(id)) notifyMarkers.delete(id);
168
+ }
169
+ if (processes.some((process) => process.status === "running")) {
170
+ hasSeenRunningProcess = true;
171
+ }
172
+
173
+ const current = state.getState();
174
+ if (current.focusedProcessId) {
175
+ const pinned = selectPinnedProcess(processes, current);
176
+ if (!pinned) {
177
+ state.actions.setFocus(null);
178
+ }
179
+ }
180
+
181
+ for (const process of processes.slice(0, MAX_PREVIEW_PROCESSES)) {
182
+ const lines = requestCombinedOutput(events, process.id, 1);
183
+ previews.set(process.id, lines.at(-1) ?? null);
184
+ }
185
+
186
+ seedProcessLogStream();
187
+ syncPinnedConnection();
188
+
189
+ if (
190
+ processes.length === 0 ||
191
+ (!state.getState().focusedProcessId &&
192
+ hasSeenRunningProcess &&
193
+ processes.every((process) => !LIVE_STATUSES.has(process.status)))
194
+ ) {
195
+ state.actions.close();
196
+ }
197
+
198
+ render();
199
+ renderStatus();
200
+ };
201
+
202
+ const scheduleRefresh = () => {
203
+ if (disposed || pendingRefresh) return;
204
+ pendingRefresh = setTimeout(() => {
205
+ pendingRefresh = null;
206
+ hardRefresh();
207
+ }, REFRESH_THROTTLE_MS);
208
+ };
209
+
210
+ // Throttle the widget re-render on high-frequency output events. State
211
+ // (previews, processLogStream, pinnedLines) is updated immediately by the
212
+ // callers; only the setWidget call is coalesced so a 10/s output stream
213
+ // does not rebuild the widget tree 10 times a second. User-initiated
214
+ // actions call render() directly, which cancels a pending scheduled render.
215
+ const scheduleRender = () => {
216
+ if (disposed || pendingRender) return;
217
+ pendingRender = setTimeout(() => {
218
+ pendingRender = null;
219
+ render();
220
+ }, REFRESH_THROTTLE_MS);
221
+ };
222
+
223
+ const seedProcessLogStream = () => {
224
+ const current = state.getState();
225
+ if (current.visibility === "closed" || current.focusedProcessId) {
226
+ processLogStream = [];
227
+ return;
228
+ }
229
+ if (processLogStream.length > 0) return;
230
+
231
+ processLogStream = trimToBudget(
232
+ processes
233
+ .filter((process) => LIVE_STATUSES.has(process.status))
234
+ .flatMap((process) =>
235
+ requestCombinedOutput(events, process.id, 4).map((line) => ({
236
+ processId: process.id,
237
+ line,
238
+ })),
239
+ ),
240
+ config.output.maxOutputLines,
241
+ config.output.maxOutputBytes,
242
+ (entry) => entry.line.text,
243
+ );
244
+ };
245
+
246
+ const syncPinnedConnection = () => {
247
+ const current = state.getState();
248
+ const pinned = selectPinnedProcess(processes, current);
249
+ const shouldStream = current.visibility === "expanded" && pinned;
250
+
251
+ if (!shouldStream) {
252
+ pinnedConnection?.unsubscribe();
253
+ pinnedConnection = null;
254
+ pinnedConnectionId = null;
255
+ pinnedLines = trimToBudget(
256
+ pinned
257
+ ? requestCombinedOutput(
258
+ events,
259
+ pinned.id,
260
+ config.output.defaultTailLines,
261
+ )
262
+ : [],
263
+ config.output.maxOutputLines,
264
+ config.output.maxOutputBytes,
265
+ );
266
+ return;
267
+ }
268
+
269
+ if (pinnedConnection && pinnedConnectionId === pinned.id) return;
270
+
271
+ pinnedConnection?.unsubscribe();
272
+ pinnedConnection = null;
273
+ pinnedConnectionId = null;
274
+
275
+ const connection = connectToProcessLogs(events, pinned.id, {
276
+ tailLines: config.output.defaultTailLines,
277
+ });
278
+ if (isLogsConnectionError(connection)) {
279
+ pinnedLines = trimToBudget(
280
+ requestCombinedOutput(
281
+ events,
282
+ pinned.id,
283
+ config.output.defaultTailLines,
284
+ ),
285
+ config.output.maxOutputLines,
286
+ config.output.maxOutputBytes,
287
+ );
288
+ return;
289
+ }
290
+
291
+ pinnedConnection = connection;
292
+ pinnedConnectionId = pinned.id;
293
+ pinnedLines = trimToBudget(
294
+ connection.initialLines,
295
+ config.output.maxOutputLines,
296
+ config.output.maxOutputBytes,
297
+ );
298
+
299
+ connection.onChunk((lines: ProcessLogLine[]) => {
300
+ if (disposed) return;
301
+ const id = pinnedConnectionId;
302
+ if (!id) return;
303
+ pinnedLines.push(...lines);
304
+ pinnedLines = trimToBudget(
305
+ pinnedLines,
306
+ config.output.maxOutputLines,
307
+ config.output.maxOutputBytes,
308
+ );
309
+ const last = lines.at(-1);
310
+ if (last) previews.set(id, last);
311
+ scheduleRender();
312
+ });
313
+ };
314
+
315
+ const actions: DockActions = {
316
+ getFocusedProcessId: state.actions.getFocusedProcessId,
317
+ isFollowEnabled: state.actions.isFollowEnabled,
318
+ setFocus: (processId) => {
319
+ state.actions.setFocus(processId);
320
+ seedProcessLogStream();
321
+ syncPinnedConnection();
322
+ render();
323
+ },
324
+ expand: () => {
325
+ state.actions.expand();
326
+ seedProcessLogStream();
327
+ syncPinnedConnection();
328
+ render();
329
+ },
330
+ collapse: () => {
331
+ state.actions.collapse();
332
+ seedProcessLogStream();
333
+ syncPinnedConnection();
334
+ render();
335
+ },
336
+ close: () => {
337
+ state.actions.close();
338
+ seedProcessLogStream();
339
+ syncPinnedConnection();
340
+ render();
341
+ },
342
+ };
343
+
344
+ const handleStarted = () => {
345
+ if (config.widget.dockDefaultState === "expanded") state.actions.expand();
346
+ else if (config.widget.dockDefaultState === "collapsed") {
347
+ state.actions.collapse();
348
+ }
349
+ scheduleRefresh();
350
+ };
351
+
352
+ const handleOutputChanged = (payload: unknown) => {
353
+ if (!isOutputChangedPayload(payload)) {
354
+ scheduleRefresh();
355
+ return;
356
+ }
357
+ if (
358
+ (!payload.appendedText || payload.appendedText.length === 0) &&
359
+ !payload.droppedLines
360
+ ) {
361
+ scheduleRefresh();
362
+ return;
363
+ }
364
+
365
+ const appended = [
366
+ ...(payload.droppedLines
367
+ ? [buildDroppedOutputLine(payload.droppedLines)]
368
+ : []),
369
+ ...(payload.appendedText ?? []),
370
+ ];
371
+ for (const line of appended) {
372
+ previews.set(payload.id, line);
373
+ processLogStream.push({ processId: payload.id, line });
374
+ }
375
+ processLogStream = trimToBudget(
376
+ processLogStream,
377
+ config.output.maxOutputLines,
378
+ config.output.maxOutputBytes,
379
+ (entry) => entry.line.text,
380
+ );
381
+ scheduleRender();
382
+ };
383
+
384
+ const handlePin = (payload: unknown) => {
385
+ const command = payload as CommandPinPayload;
386
+ if (!isCommandPinPayload(command)) return;
387
+ // COMMAND_PIN can arrive before the dock's throttled CHANGED refresh has
388
+ // run. Refresh the local snapshot first so expand/pin renders immediately
389
+ // against the current process list.
390
+ processes = sortProcesses(requestProcessList(events));
391
+ // id: null unpins the dock (mirrors `/ps:pin clear`).
392
+ if (command.id === null) {
393
+ actions.setFocus(null);
394
+ actions.expand();
395
+ render();
396
+ safeReply(command.reply, { ok: true });
397
+ return;
398
+ }
399
+ const process = requestProcess(events, command.id);
400
+ if (!process) {
401
+ safeReply(command.reply, { ok: false, error: "Process not found" });
402
+ return;
403
+ }
404
+ if (
405
+ !LIVE_STATUSES.has(process.status) &&
406
+ actions.getFocusedProcessId() !== process.id
407
+ ) {
408
+ safeReply(command.reply, {
409
+ ok: false,
410
+ error: "Only running processes can be pinned",
411
+ });
412
+ return;
413
+ }
414
+ actions.setFocus(process.id);
415
+ actions.expand();
416
+ render();
417
+ safeReply(command.reply, { ok: true });
418
+ };
419
+
420
+ disposers.push(events.on(CHANNELS.STARTED, handleStarted));
421
+ disposers.push(events.on(CHANNELS.ENDED, scheduleRefresh));
422
+ disposers.push(events.on(CHANNELS.CHANGED, scheduleRefresh));
423
+ disposers.push(events.on(CHANNELS.OUTPUT_CHANGED, handleOutputChanged));
424
+ disposers.push(
425
+ events.on(CHANNELS.COMMAND_PIN, (payload) => {
426
+ handlePin(payload);
427
+ }),
428
+ );
429
+ disposers.push(
430
+ events.on(CHANNELS.NOTIFICATION, (payload) => {
431
+ if (!isLogMatchNotification(payload)) return;
432
+ const list = notifyMarkers.get(payload.processId) ?? [];
433
+ list.push({ line: payload.logMatch.line, timestamp: payload.timestamp });
434
+ if (list.length > MAX_NOTIFY_MARKERS_PER_PROCESS) {
435
+ list.splice(0, list.length - MAX_NOTIFY_MARKERS_PER_PROCESS);
436
+ }
437
+ notifyMarkers.set(payload.processId, list);
438
+ render();
439
+ }),
440
+ );
441
+
442
+ hardRefresh();
443
+
444
+ return {
445
+ actions,
446
+ refresh: hardRefresh,
447
+ dispose: () => {
448
+ if (disposed) return;
449
+ disposed = true;
450
+ if (pendingRefresh) clearTimeout(pendingRefresh);
451
+ if (pendingRender) clearTimeout(pendingRender);
452
+ pinnedConnection?.unsubscribe();
453
+ for (const dispose of disposers.splice(0)) dispose();
454
+ ctx.ui.setWidget(DOCK_WIDGET_KEY, undefined, {
455
+ placement: "aboveEditor",
456
+ });
457
+ ctx.ui.setWidget(STATUS_WIDGET_KEY, undefined, {
458
+ placement: "belowEditor",
459
+ });
460
+ },
461
+ };
462
+ }
463
+
464
+ function selectPinnedProcess(
465
+ processes: ProcessInfo[],
466
+ state: DockState,
467
+ ): ProcessInfo | null {
468
+ return state.focusedProcessId
469
+ ? (processes.find((process) => process.id === state.focusedProcessId) ??
470
+ null)
471
+ : null;
472
+ }
473
+
474
+ function sortProcesses(processes: ProcessInfo[]): ProcessInfo[] {
475
+ return [...processes].sort((a, b) => {
476
+ const aLive = LIVE_STATUSES.has(a.status) ? 1 : 0;
477
+ const bLive = LIVE_STATUSES.has(b.status) ? 1 : 0;
478
+ if (bLive !== aLive) return bLive - aLive;
479
+ return b.startTime - a.startTime;
480
+ });
481
+ }
482
+
483
+ function isLogsConnectionError(
484
+ connection: LogsConnection | { ok: false; error: string },
485
+ ): connection is { ok: false; error: string } {
486
+ return "ok" in connection && connection.ok === false;
487
+ }
488
+
489
+ function isLogMatchNotification(
490
+ payload: unknown,
491
+ ): payload is ProcessProtocolNotificationPayload & {
492
+ kind: "log_match";
493
+ logMatch: NonNullable<ProcessProtocolNotificationPayload["logMatch"]>;
494
+ } {
495
+ return (
496
+ isRecord(payload) &&
497
+ payload.kind === "log_match" &&
498
+ typeof payload.processId === "string" &&
499
+ typeof payload.timestamp === "number" &&
500
+ isRecord(payload.logMatch) &&
501
+ typeof payload.logMatch.line === "string"
502
+ );
503
+ }
504
+
505
+ function isCommandPinPayload(payload: unknown): payload is CommandPinPayload {
506
+ return (
507
+ isRecord(payload) &&
508
+ (typeof payload.id === "string" || payload.id === null) &&
509
+ typeof payload.reply === "function"
510
+ );
511
+ }
512
+
513
+ function safeReply<T>(reply: (result: T) => void, result: T): void {
514
+ try {
515
+ reply(result);
516
+ } catch {
517
+ // Reply callbacks are owned by the requester.
518
+ return;
519
+ }
520
+ }
@@ -0,0 +1,149 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+
4
+ import { LIVE_STATUSES, type ProcessInfo } from "../../../src/types";
5
+ import { statusColor, statusDot } from "../../shared/ui";
6
+
7
+ const MAX_PROCESS_NAME = 20;
8
+ const DEFAULT_MAX_WIDTH = 200;
9
+
10
+ /**
11
+ * Color a process name by its status, matching the dot's color so the dot
12
+ * and the name always agree.
13
+ */
14
+ function formatProcessName(process: ProcessInfo, theme: Theme): string {
15
+ const trimmed =
16
+ process.name.length > MAX_PROCESS_NAME
17
+ ? `${process.name.slice(0, MAX_PROCESS_NAME - 3)}...`
18
+ : process.name;
19
+ return theme.fg(statusColor(process), trimmed);
20
+ }
21
+
22
+ /**
23
+ * Render one process as `dot name`. The dot glyph carries the status, so the
24
+ * trailing state word is dropped — it only duplicated what the dot already
25
+ * encodes and wasted columns.
26
+ */
27
+ function formatProcessLabel(process: ProcessInfo, theme: Theme): string {
28
+ const dot = statusDot(process, true, theme);
29
+ const name = formatProcessName(process, theme);
30
+ return `${dot} ${name}`;
31
+ }
32
+
33
+ /**
34
+ * Build the summary token for exited-success processes: `✓ N done`.
35
+ * Uses a synthetic exited-success ProcessInfo so statusDot agrees with the
36
+ * rest of the UI.
37
+ */
38
+ function formatDoneSummary(count: number, theme: Theme): string {
39
+ const summary: ProcessInfo = {
40
+ id: "_summary",
41
+ name: "",
42
+ status: "exited",
43
+ success: true,
44
+ exitCode: 0,
45
+ } as ProcessInfo;
46
+ return `${statusDot(summary, false, theme)} ${theme.fg("dim", `${count} done`)}`;
47
+ }
48
+
49
+ /**
50
+ * Partition processes into:
51
+ * - individual: live, failed, killed (shown one-by-one)
52
+ * - exitedSuccess: clean exits (collapsed into one `✓ N done` token)
53
+ */
54
+ function partitionForStatusLine(processes: ProcessInfo[]): {
55
+ individual: ProcessInfo[];
56
+ exitedSuccess: ProcessInfo[];
57
+ } {
58
+ const individual: ProcessInfo[] = [];
59
+ const exitedSuccess: ProcessInfo[] = [];
60
+
61
+ // Live first, then failed/killed, ordered naturally.
62
+ const live = processes.filter((p) => LIVE_STATUSES.has(p.status));
63
+ const finished = processes.filter((p) => !LIVE_STATUSES.has(p.status));
64
+ finished.sort((a, b) => (b.endTime ?? 0) - (a.endTime ?? 0));
65
+
66
+ for (const p of [...live, ...finished]) {
67
+ if (p.status === "exited" && p.success) {
68
+ exitedSuccess.push(p);
69
+ } else {
70
+ individual.push(p);
71
+ }
72
+ }
73
+
74
+ return { individual, exitedSuccess };
75
+ }
76
+
77
+ /**
78
+ * Render the single-line status widget shown below the editor.
79
+ *
80
+ * Lists managed processes (dot + name). Live and failed processes are shown
81
+ * individually; successfully-exited processes collapse into a single
82
+ * `✓ N done` summary. The dot glyph encodes status; the name is colored by
83
+ * status tone. Returns an empty array when there are no processes so the
84
+ * caller can clear the widget.
85
+ */
86
+ export function renderStatusWidget(
87
+ processes: ProcessInfo[],
88
+ theme: Theme,
89
+ maxWidth: number = DEFAULT_MAX_WIDTH,
90
+ ): string[] {
91
+ if (processes.length === 0) return [];
92
+
93
+ const { individual, exitedSuccess } = partitionForStatusLine(processes);
94
+
95
+ const prefix = theme.fg("dim", "ps: ");
96
+ const prefixLen = visibleWidth(prefix);
97
+ const separator = theme.fg("dim", " ");
98
+ const separatorLen = visibleWidth(separator);
99
+
100
+ // Build the full ordered list of display tokens: individual processes
101
+ // followed by the done-summary (if any).
102
+ const tokens: string[] = [];
103
+ for (const process of individual) {
104
+ tokens.push(formatProcessLabel(process, theme));
105
+ }
106
+ if (exitedSuccess.length > 0) {
107
+ tokens.push(formatDoneSummary(exitedSuccess.length, theme));
108
+ }
109
+
110
+ // Fit tokens to width, with "+N more" overflow.
111
+ const parts: string[] = [];
112
+ let currentLen = prefixLen;
113
+ let includedCount = 0;
114
+
115
+ for (const token of tokens) {
116
+ const tokenLen = visibleWidth(token);
117
+ const remaining = tokens.length - includedCount - 1;
118
+ const needed = includedCount > 0 ? separatorLen + tokenLen : tokenLen;
119
+ const reservedForSuffix =
120
+ remaining > 0 ? separatorLen + visibleWidth(`+${remaining} more`) : 0;
121
+
122
+ if (
123
+ currentLen + needed + reservedForSuffix > maxWidth &&
124
+ includedCount > 0
125
+ ) {
126
+ const hiddenCount = tokens.length - includedCount;
127
+ if (hiddenCount > 0) {
128
+ parts.push(theme.fg("dim", `+${hiddenCount} more`));
129
+ }
130
+ break;
131
+ }
132
+
133
+ parts.push(token);
134
+ currentLen += needed;
135
+ includedCount++;
136
+ }
137
+
138
+ // Width too small for even one entry: show the first token anyway.
139
+ if (includedCount === 0) {
140
+ parts.push(tokens[0] as string);
141
+ }
142
+
143
+ if (parts.length === 0) return [];
144
+
145
+ const line = prefix + parts.join(separator);
146
+ return [
147
+ visibleWidth(line) > maxWidth ? truncateToWidth(line, maxWidth) : line,
148
+ ];
149
+ }
@@ -0,0 +1,16 @@
1
+ export type DockVisibility = "closed" | "collapsed" | "expanded";
2
+
3
+ export interface DockState {
4
+ visibility: DockVisibility;
5
+ followEnabled: boolean;
6
+ focusedProcessId: string | null;
7
+ }
8
+
9
+ export interface DockActions {
10
+ getFocusedProcessId: () => string | null;
11
+ isFollowEnabled: () => boolean;
12
+ setFocus: (processId: string | null) => void;
13
+ expand: () => void;
14
+ collapse: () => void;
15
+ close: () => void;
16
+ }