@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,729 @@
1
+ import { Stack } from "@aliou/pi-utils-ui";
2
+ import type { EventBus, Theme } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ type Component,
5
+ Input,
6
+ Key,
7
+ matchesKey,
8
+ type TUI,
9
+ truncateToWidth,
10
+ visibleWidth,
11
+ } from "@earendil-works/pi-tui";
12
+ import { CHANNELS, type ProcessProtocolConfig } from "../../../src/protocol";
13
+ import { LIVE_STATUSES, type ProcessInfo } from "../../../src/types";
14
+ import { formatRuntime, truncateCmd } from "../../../src/utils/format";
15
+ import { buildDroppedOutputLine, trimToBudget } from "../../shared/line-buffer";
16
+ import { isOutputChangedPayload } from "../../shared/output-payload";
17
+ import { LineComponent, LinesComponent, statusColor } from "../../shared/ui";
18
+ import {
19
+ type ProcessLogLine,
20
+ requestClear,
21
+ requestCombinedOutput,
22
+ requestKill,
23
+ requestPin,
24
+ requestProcessList,
25
+ } from "../client";
26
+ import { OverviewPanel } from "./overview-panel";
27
+
28
+ /** Sort mode for the overview list. */
29
+ export type OverviewSort = "status" | "started" | "name";
30
+
31
+ /** Filter mode for the overview list. */
32
+ export type OverviewFilter = "all" | "running" | "finished";
33
+
34
+ export const OVERVIEW_SORTS: OverviewSort[] = ["status", "started", "name"];
35
+ export const OVERVIEW_FILTERS: OverviewFilter[] = [
36
+ "all",
37
+ "running",
38
+ "finished",
39
+ ];
40
+
41
+ interface OverviewOptions {
42
+ events: EventBus;
43
+ tui: TUI;
44
+ theme: Theme;
45
+ config: ProcessProtocolConfig;
46
+ initialProcessId?: string;
47
+ onClose: () => void;
48
+ }
49
+
50
+ const MAX_NAME_WIDTH = 16;
51
+ const MAX_ID_WIDTH = 10;
52
+ const STATUS_WIDTH = 10;
53
+ const RUNTIME_WIDTH = 6;
54
+ const PREVIEW_LOG_PREFIX = " ";
55
+ const MAX_PREVIEW_LINES = 2000;
56
+ const PIN_REPLY_TIMEOUT_MS = 200;
57
+ const MIN_OVERVIEW_WIDTH = 40;
58
+ const PREVIEW_HEIGHT = 8;
59
+
60
+ type Mode = "normal" | "filter-typing";
61
+
62
+ /**
63
+ * Full-screen overview of managed processes. Replaces the editor while open.
64
+ *
65
+ * Uses `pi.events` exclusively (no direct manager access) so it can be split
66
+ * out of the core extension later without changes. Selection survives list
67
+ * refreshes by remembering the selected process id and re-finding its index.
68
+ */
69
+ export class OverviewComponent implements Component {
70
+ private processes: ProcessInfo[] = [];
71
+ private selectedIndex = 0;
72
+ private viewOffset = 0;
73
+ private previewLines: ProcessLogLine[] = [];
74
+ private previewOffset = 0;
75
+ private mode: Mode = "normal";
76
+ private readonly filterInput = new Input();
77
+ private sort: OverviewSort = "status";
78
+ private filter: OverviewFilter = "all";
79
+ private quickFilter = "";
80
+ private pinUnavailable = false;
81
+ private pinnedId: string | null = null;
82
+ private hasAnyProcesses = false;
83
+ private totalProcessCount = 0;
84
+ private runningProcessCount = 0;
85
+ private readonly disposers: Array<() => void> = [];
86
+ private disposed = false;
87
+
88
+ constructor(private readonly opts: OverviewOptions) {
89
+ this.configureFilterInput();
90
+ this.refresh(opts.initialProcessId);
91
+ this.disposers.push(
92
+ opts.events.on(CHANNELS.CHANGED, () => this.handleChanged()),
93
+ );
94
+ this.disposers.push(
95
+ opts.events.on(CHANNELS.OUTPUT_CHANGED, (payload) =>
96
+ this.handleOutputChanged(payload),
97
+ ),
98
+ );
99
+ }
100
+
101
+ render(width: number): string[] {
102
+ const tooSmall = width < MIN_OVERVIEW_WIDTH;
103
+ const panel = new OverviewPanel({
104
+ title: tooSmall ? undefined : "Processes",
105
+ headerLeft: tooSmall ? undefined : this.renderHeaderLeft(),
106
+ headerRight: tooSmall ? undefined : this.renderHeaderRight(),
107
+ body: tooSmall ? this.buildTooSmallBody(width) : this.buildBody(),
108
+ footer: tooSmall ? undefined : this.buildFooter(),
109
+ padding: 0,
110
+ borderStyle: (text) => this.opts.theme.fg("dim", text),
111
+ titleStyle: (text) =>
112
+ this.opts.theme.fg("accent", this.opts.theme.bold(text)),
113
+ metaStyle: (text) => this.opts.theme.fg("dim", text),
114
+ });
115
+ return panel.render(width);
116
+ }
117
+
118
+ handleInput(data: string): void {
119
+ if (this.mode === "filter-typing") {
120
+ this.filterInput.handleInput?.(data);
121
+ this.requestRender();
122
+ return;
123
+ }
124
+
125
+ if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
126
+ this.close();
127
+ return;
128
+ }
129
+ if (data === "q" || data === "Q") {
130
+ this.close();
131
+ return;
132
+ }
133
+ if (matchesKey(data, Key.down) || data === "j") this.moveSelection(1);
134
+ else if (matchesKey(data, Key.up) || data === "k") this.moveSelection(-1);
135
+ else if (data === "J") this.scrollPreview(1);
136
+ else if (data === "K") this.scrollPreview(-1);
137
+ else if (data === "g") {
138
+ this.previewOffset = 0;
139
+ this.requestRender();
140
+ } else if (data === "G") {
141
+ this.previewOffset = Math.max(0, this.previewLines.length - 1);
142
+ this.requestRender();
143
+ } else if (data === "x") this.killSelected();
144
+ else if (data === "c") this.clearFinished();
145
+ else if (data === "s") this.cycleSort();
146
+ else if (data === "f") this.cycleFilter();
147
+ else if (data === "/") this.startQuickFilter();
148
+ else if (matchesKey(data, Key.enter)) void this.pinSelected();
149
+ }
150
+
151
+ invalidate(): void {}
152
+
153
+ dispose(): void {
154
+ if (this.disposed) return;
155
+ this.disposed = true;
156
+ for (const dispose of this.disposers.splice(0)) dispose();
157
+ }
158
+
159
+ // --- refresh / state ---
160
+
161
+ private refresh(preferredId?: string): void {
162
+ const all = requestProcessList(this.opts.events);
163
+ this.hasAnyProcesses = all.length > 0;
164
+ this.totalProcessCount = all.length;
165
+ this.runningProcessCount = all.filter((process) =>
166
+ LIVE_STATUSES.has(process.status),
167
+ ).length;
168
+ this.processes = this.applyView(all);
169
+ if (this.processes.length === 0) {
170
+ this.selectedIndex = 0;
171
+ this.viewOffset = 0;
172
+ this.previewLines = [];
173
+ this.previewOffset = 0;
174
+ return;
175
+ }
176
+ const keepId = preferredId ?? this.processes[this.selectedIndex]?.id;
177
+ const nextIndex = keepId
178
+ ? this.processes.findIndex((process) => process.id === keepId)
179
+ : -1;
180
+ this.selectedIndex =
181
+ nextIndex >= 0
182
+ ? nextIndex
183
+ : Math.min(this.selectedIndex, this.processes.length - 1);
184
+ this.clampViewOffset();
185
+ this.refreshPreview();
186
+ }
187
+
188
+ private handleChanged(): void {
189
+ this.refresh();
190
+ this.requestRender();
191
+ }
192
+
193
+ private handleOutputChanged(payload: unknown): void {
194
+ if (!isOutputChangedPayload(payload)) return;
195
+ const selected = this.selectedProcess();
196
+ if (!selected || selected.id !== payload.id) return;
197
+ const appended = [
198
+ ...(payload.droppedLines
199
+ ? [buildDroppedOutputLine(payload.droppedLines)]
200
+ : []),
201
+ ...(payload.appendedText ?? []),
202
+ ];
203
+ if (appended.length === 0) return;
204
+
205
+ const follow = this.isFollowingTail();
206
+ const oldLength = this.previewLines.length;
207
+ this.previewLines.push(...appended);
208
+ const combinedLength = this.previewLines.length;
209
+ this.previewLines = trimToBudget(
210
+ this.previewLines,
211
+ MAX_PREVIEW_LINES,
212
+ this.opts.config.output.maxOutputBytes,
213
+ );
214
+ if (follow) {
215
+ this.previewOffset = Math.max(
216
+ 0,
217
+ this.previewLines.length - this.previewHeight(),
218
+ );
219
+ } else {
220
+ const removedFromHead = Math.min(
221
+ oldLength,
222
+ combinedLength - this.previewLines.length,
223
+ );
224
+ this.previewOffset = Math.min(
225
+ Math.max(0, this.previewOffset - removedFromHead),
226
+ Math.max(0, this.previewLines.length - 1),
227
+ );
228
+ }
229
+ this.requestRender();
230
+ }
231
+
232
+ private applyView(processes: ProcessInfo[]): ProcessInfo[] {
233
+ const filtered = this.filterByMode(processes);
234
+ const quick = this.quickFilter
235
+ ? filtered.filter((process) =>
236
+ process.name.toLowerCase().includes(this.quickFilter.toLowerCase()),
237
+ )
238
+ : filtered;
239
+ return this.sortByMode(quick);
240
+ }
241
+
242
+ private filterByMode(processes: ProcessInfo[]): ProcessInfo[] {
243
+ if (this.filter === "running") {
244
+ return processes.filter((process) => LIVE_STATUSES.has(process.status));
245
+ }
246
+ if (this.filter === "finished") {
247
+ return processes.filter((process) => !LIVE_STATUSES.has(process.status));
248
+ }
249
+ return processes;
250
+ }
251
+
252
+ private sortByMode(processes: ProcessInfo[]): ProcessInfo[] {
253
+ const copy = [...processes];
254
+ if (this.sort === "status") {
255
+ copy.sort((a, b) => {
256
+ const aLive = LIVE_STATUSES.has(a.status) ? 1 : 0;
257
+ const bLive = LIVE_STATUSES.has(b.status) ? 1 : 0;
258
+ if (bLive !== aLive) return bLive - aLive;
259
+ return b.startTime - a.startTime;
260
+ });
261
+ } else if (this.sort === "started") {
262
+ copy.sort((a, b) => b.startTime - a.startTime);
263
+ } else {
264
+ copy.sort((a, b) => a.name.localeCompare(b.name));
265
+ }
266
+ return copy;
267
+ }
268
+
269
+ private selectedProcess(): ProcessInfo | null {
270
+ return this.processes[this.selectedIndex] ?? null;
271
+ }
272
+
273
+ private moveSelection(delta: number): void {
274
+ if (this.processes.length === 0) return;
275
+ this.selectedIndex =
276
+ (this.selectedIndex + delta + this.processes.length) %
277
+ this.processes.length;
278
+ this.clampViewOffset();
279
+ this.refreshPreview("newest");
280
+ this.requestRender();
281
+ }
282
+
283
+ private clampViewOffset(): void {
284
+ const maxVisible = this.maxVisibleRows();
285
+ if (this.selectedIndex < this.viewOffset) {
286
+ this.viewOffset = this.selectedIndex;
287
+ } else if (this.selectedIndex >= this.viewOffset + maxVisible) {
288
+ this.viewOffset = this.selectedIndex - maxVisible + 1;
289
+ }
290
+ this.viewOffset = Math.max(0, this.viewOffset);
291
+ }
292
+
293
+ private maxVisibleRows(): number {
294
+ return Math.max(1, this.opts.config.processList.maxVisibleProcesses);
295
+ }
296
+
297
+ private scrollPreview(delta: number): void {
298
+ this.previewOffset = Math.max(
299
+ 0,
300
+ Math.min(this.previewLines.length - 1, this.previewOffset + delta),
301
+ );
302
+ this.requestRender();
303
+ }
304
+
305
+ private refreshPreview(align: "newest" | "keep" = "keep"): void {
306
+ const selected = this.selectedProcess();
307
+ if (!selected) {
308
+ this.previewLines = [];
309
+ this.previewOffset = 0;
310
+ return;
311
+ }
312
+
313
+ const available = this.previewHeight();
314
+ // Follow-tail: if the view was already pinned to the newest page (or the
315
+ // caller asked for newest), keep it there after reloading so new output
316
+ // scrolls into view. Otherwise preserve the user's scroll position so a
317
+ // deliberate K-scroll-up is not yanked back by the next output batch.
318
+ const follow = align === "newest" || this.isFollowingTail();
319
+
320
+ const lines = requestCombinedOutput(
321
+ this.opts.events,
322
+ selected.id,
323
+ this.opts.config.output.defaultTailLines,
324
+ );
325
+ this.previewLines = trimToBudget(
326
+ lines,
327
+ MAX_PREVIEW_LINES,
328
+ this.opts.config.output.maxOutputBytes,
329
+ );
330
+ const newTotal = this.previewLines.length;
331
+
332
+ if (follow) {
333
+ // Show the newest page: the last `available` lines, so the most recent
334
+ // output is visible immediately on select/open (matching 0.9.4).
335
+ this.previewOffset = Math.max(0, newTotal - available);
336
+ } else {
337
+ this.previewOffset = Math.min(
338
+ this.previewOffset,
339
+ Math.max(0, newTotal - 1),
340
+ );
341
+ }
342
+ }
343
+
344
+ private isFollowingTail(): boolean {
345
+ return (
346
+ this.previewLines.length === 0 ||
347
+ this.previewOffset >= this.previewLines.length - this.previewHeight()
348
+ );
349
+ }
350
+
351
+ private cycleSort(): void {
352
+ this.sort =
353
+ OVERVIEW_SORTS[
354
+ (OVERVIEW_SORTS.indexOf(this.sort) + 1) % OVERVIEW_SORTS.length
355
+ ];
356
+ this.reapplyKeepSelection();
357
+ }
358
+
359
+ private cycleFilter(): void {
360
+ this.filter =
361
+ OVERVIEW_FILTERS[
362
+ (OVERVIEW_FILTERS.indexOf(this.filter) + 1) % OVERVIEW_FILTERS.length
363
+ ];
364
+ this.reapplyKeepSelection();
365
+ }
366
+
367
+ private reapplyKeepSelection(): void {
368
+ const keepId = this.selectedProcess()?.id;
369
+ const all = requestProcessList(this.opts.events);
370
+ this.hasAnyProcesses = all.length > 0;
371
+ this.totalProcessCount = all.length;
372
+ this.runningProcessCount = all.filter((process) =>
373
+ LIVE_STATUSES.has(process.status),
374
+ ).length;
375
+ this.processes = this.applyView(all);
376
+ const nextIndex = keepId
377
+ ? this.processes.findIndex((process) => process.id === keepId)
378
+ : -1;
379
+ this.selectedIndex =
380
+ nextIndex >= 0
381
+ ? nextIndex
382
+ : Math.min(this.selectedIndex, Math.max(0, this.processes.length - 1));
383
+ this.clampViewOffset();
384
+ this.refreshPreview();
385
+ this.requestRender();
386
+ }
387
+
388
+ private killSelected(): void {
389
+ const selected = this.selectedProcess();
390
+ if (!selected) return;
391
+ requestKill(this.opts.events, selected.id);
392
+ // CHANNELS.CHANGED re-renders on kill completion.
393
+ }
394
+
395
+ private clearFinished(): void {
396
+ requestClear(this.opts.events);
397
+ // CHANNELS.CHANGED re-renders on clear completion.
398
+ }
399
+
400
+ private async pinSelected(): Promise<void> {
401
+ const selected = this.selectedProcess();
402
+ if (!selected) return;
403
+ // Toggle: if the selected process is already pinned, unpin it.
404
+ const isPinned = this.pinnedId === selected.id;
405
+ if (!isPinned && !LIVE_STATUSES.has(selected.status)) {
406
+ this.requestRender();
407
+ return;
408
+ }
409
+ const result = await requestPin(
410
+ this.opts.events,
411
+ isPinned ? null : selected.id,
412
+ PIN_REPLY_TIMEOUT_MS,
413
+ );
414
+ this.pinUnavailable = !result.ok;
415
+ if (result.ok) {
416
+ this.pinnedId = isPinned ? null : selected.id;
417
+ }
418
+ this.requestRender();
419
+ }
420
+
421
+ private configureFilterInput(): void {
422
+ this.filterInput.onSubmit = (query) => {
423
+ this.quickFilter = query.trim();
424
+ this.mode = "normal";
425
+ this.reapplyKeepSelection();
426
+ };
427
+ this.filterInput.onEscape = () => {
428
+ this.quickFilter = "";
429
+ this.mode = "normal";
430
+ this.requestRender();
431
+ };
432
+ }
433
+
434
+ private startQuickFilter(): void {
435
+ this.filterInput.setValue(this.quickFilter);
436
+ this.mode = "filter-typing";
437
+ this.requestRender();
438
+ }
439
+
440
+ private close(): void {
441
+ this.dispose();
442
+ this.opts.onClose();
443
+ }
444
+
445
+ private requestRender(): void {
446
+ this.opts.tui.requestRender();
447
+ }
448
+
449
+ // --- rendering ---
450
+
451
+ private buildBody(): Component {
452
+ if (this.processes.length === 0) {
453
+ return new LinesComponent((w) => this.renderEmptyState(w));
454
+ }
455
+
456
+ const body = new Stack({ gap: 0 });
457
+ body.addChild(new LinesComponent((w) => this.renderListRows(w)));
458
+ body.addChild(new LineComponent((w) => this.renderRule(w)));
459
+ body.addChild(new LinesComponent((w) => this.renderPreview(w)));
460
+ return body;
461
+ }
462
+
463
+ private buildTooSmallBody(width: number): Component {
464
+ const t = this.opts.theme;
465
+ return new LinesComponent(() => [
466
+ t.fg("warning", "Terminal too small for /ps."),
467
+ t.fg("dim", `Need ${MIN_OVERVIEW_WIDTH} columns, have ${width}.`),
468
+ "",
469
+ t.fg("dim", "Resize and reopen /ps."),
470
+ ]);
471
+ }
472
+
473
+ private renderEmptyState(width: number): string[] {
474
+ const t = this.opts.theme;
475
+ const filtering = this.hasAnyProcesses;
476
+ const title = t.fg("muted", "No managed processes");
477
+ const description = t.fg(
478
+ "dim",
479
+ filtering
480
+ ? "No processes match the current filter. Press f or / to change it."
481
+ : "Start one with the process tool, then reopen /ps",
482
+ );
483
+ const titleW = visibleWidth(title);
484
+ const descW = visibleWidth(description);
485
+
486
+ // Fixed small block: a couple of blank lines, title, blank, description.
487
+ const lines: string[] = ["", "", "", ""];
488
+
489
+ const titleRow = 1;
490
+ const descRow = 3;
491
+ const titlePad = Math.max(0, Math.floor((width - titleW) / 2));
492
+ const descPad = Math.max(0, Math.floor((width - descW) / 2));
493
+ lines[titleRow] = truncateToWidth(
494
+ `${" ".repeat(titlePad)}${title}`,
495
+ width,
496
+ "",
497
+ true,
498
+ );
499
+ lines[descRow] = truncateToWidth(
500
+ `${" ".repeat(descPad)}${description}`,
501
+ width,
502
+ "",
503
+ true,
504
+ );
505
+ return lines;
506
+ }
507
+
508
+ private renderPinHint(): string {
509
+ const t = this.opts.theme;
510
+ const dim = (value: string) => t.fg("dim", value);
511
+ const accent = (value: string) => t.fg("accent", value);
512
+ if (this.pinUnavailable) return dim("pin (dock not loaded)");
513
+ const selected = this.selectedProcess();
514
+ if (selected && this.pinnedId === selected.id) {
515
+ return `${accent("unpin")}`;
516
+ }
517
+ if (selected && !LIVE_STATUSES.has(selected.status)) {
518
+ return dim("pin running only");
519
+ }
520
+ return accent("pin");
521
+ }
522
+
523
+ private renderHeaderLeft(): string {
524
+ const parts = [
525
+ `${this.runningProcessCount}/${this.totalProcessCount} running`,
526
+ ];
527
+ if (this.sort !== "status") parts.push(`sort: ${this.sort}`);
528
+ if (this.filter !== "all" || this.quickFilter) {
529
+ const filter = `${this.filter}${this.quickFilter ? ` "${this.quickFilter}"` : ""}`;
530
+ parts.push(`filter: ${filter}`);
531
+ }
532
+ return parts.join(" - ");
533
+ }
534
+
535
+ private renderHeaderRight(): string {
536
+ const hiddenAbove = this.viewOffset;
537
+ const hiddenBelow = Math.max(
538
+ 0,
539
+ this.processes.length - (this.viewOffset + this.maxVisibleRows()),
540
+ );
541
+ return [
542
+ hiddenAbove > 0 ? `↑ ${hiddenAbove} more` : "",
543
+ hiddenBelow > 0 ? `↓ ${hiddenBelow} more` : "",
544
+ ]
545
+ .filter(Boolean)
546
+ .join(" - ");
547
+ }
548
+
549
+ private renderRule(width: number): string {
550
+ const t = this.opts.theme;
551
+ return t.fg("dim", "─".repeat(Math.max(0, width)));
552
+ }
553
+
554
+ private renderListRows(width: number): string[] {
555
+ const maxVisible = this.maxVisibleRows();
556
+ const start = this.viewOffset;
557
+ const rows: string[] = [];
558
+ for (let offset = 0; offset < maxVisible; offset++) {
559
+ const index = start + offset;
560
+ const process = this.processes[index];
561
+ rows.push(
562
+ process
563
+ ? this.renderRow(process, index === this.selectedIndex, width)
564
+ : "",
565
+ );
566
+ }
567
+ return rows;
568
+ }
569
+
570
+ private renderRow(
571
+ process: ProcessInfo,
572
+ selected: boolean,
573
+ width: number,
574
+ ): string {
575
+ const t = this.opts.theme;
576
+ const name = truncateToWidth(process.name, MAX_NAME_WIDTH, "", true);
577
+ const id = truncateToWidth(process.id, MAX_ID_WIDTH, "", true);
578
+ const status = truncateToWidth(
579
+ formatColoredStatusShort(process, t),
580
+ STATUS_WIDTH,
581
+ "",
582
+ true,
583
+ );
584
+ const runtime = truncateToWidth(
585
+ formatRuntime(process.startTime, process.endTime),
586
+ RUNTIME_WIDTH,
587
+ "",
588
+ true,
589
+ );
590
+
591
+ const dim = (value: string) => t.fg("dim", value);
592
+ const sep = dim(" ");
593
+
594
+ // Leading marker: ◆ for the pinned process, a space otherwise, so every
595
+ // row aligns with no offset when nothing is pinned.
596
+ const marker = this.pinnedId === process.id ? t.fg("accent", "◆") : " ";
597
+ const left = `${marker} ${name}${sep}${dim(id)}${sep}${status}${sep}${runtime}`;
598
+ const remaining = Math.max(0, width - visibleWidth(left) - 2);
599
+ const command = dim(truncateCmd(process.command, remaining));
600
+ const line = `${left}${sep}${command}`;
601
+
602
+ if (selected) {
603
+ const padded = truncateToWidth(line, width, "", true);
604
+ const pad = Math.max(0, width - visibleWidth(padded));
605
+ return t.bg("selectedBg", `${padded}${" ".repeat(pad)}`);
606
+ }
607
+ return truncateToWidth(line, width, "", true);
608
+ }
609
+
610
+ private renderPreview(width: number): string[] {
611
+ const selected = this.selectedProcess();
612
+ if (!selected) return [];
613
+ const t = this.opts.theme;
614
+ const dim = (value: string) => t.fg("dim", value);
615
+ const accent = (value: string) => t.fg("accent", value);
616
+
617
+ const header = `${dim(">")} ${accent(truncateCmd(selected.command, Math.max(1, width - 2)))}`;
618
+ const body: string[] = [header];
619
+ const available = this.previewHeight();
620
+ const total = this.previewLines.length;
621
+ const start = this.previewOffset;
622
+ const slice = this.previewLines.slice(start, start + available);
623
+ for (const line of slice) {
624
+ const text =
625
+ line.type === "stderr" ? t.fg("warning", line.text) : line.text;
626
+ body.push(
627
+ truncateToWidth(`${dim(PREVIEW_LOG_PREFIX)}${text}`, width, "", true),
628
+ );
629
+ }
630
+ while (body.length < available + 1) body.push("");
631
+ const pageHint =
632
+ total > available
633
+ ? ` ${start + 1}-${Math.min(start + available, total)} of ${total} (J/K scroll)`
634
+ : "";
635
+ // Keep the overview stable while moving between processes. A short log
636
+ // still reserves the page-hint row that a longer log uses for scrolling.
637
+ body.push(truncateToWidth(dim(pageHint), width, "", true));
638
+ return body;
639
+ }
640
+
641
+ private previewHeight(): number {
642
+ // Fixed, bounded preview size. We deliberately do NOT size from the
643
+ // terminal height — the overview is a compact panel, not a full-screen
644
+ // log viewer (use /ps:logs for that).
645
+ return PREVIEW_HEIGHT;
646
+ }
647
+
648
+ private buildFooter(): Component {
649
+ return new LineComponent((width) => this.renderFooterLine(width));
650
+ }
651
+
652
+ private renderFooterLine(width: number): string {
653
+ const t = this.opts.theme;
654
+ const dim = (value: string) => t.fg("dim", value);
655
+
656
+ if (this.mode === "filter-typing") {
657
+ const rendered = this.filterInput.render(60)[0] ?? "";
658
+ return truncateToWidth(
659
+ `${dim("/")}${rendered} ${dim("enter")} apply ${dim("esc")} cancel`,
660
+ width,
661
+ "",
662
+ true,
663
+ );
664
+ }
665
+
666
+ const keys = [
667
+ `${dim("j/k")} move`,
668
+ `${dim("J/K")} scroll`,
669
+ `${dim("enter")} ${this.renderPinHint()}`,
670
+ `${dim("x")} kill`,
671
+ `${dim("c")} clear`,
672
+ `${dim("s")} sort`,
673
+ `${dim("f")} filter`,
674
+ `${dim("/")} find`,
675
+ `${dim("q")} close`,
676
+ ];
677
+ return truncateToWidth(keys.join(" "), width, "", true);
678
+ }
679
+ }
680
+
681
+ // --- formatting helpers (pure, unit-testable) ---
682
+
683
+ export { statusDot as renderStatusDot } from "../../shared/ui";
684
+
685
+ export function formatStatusShort(process: ProcessInfo): string {
686
+ if (LIVE_STATUSES.has(process.status)) return process.status;
687
+ if (process.status === "exited") {
688
+ return process.success === false ? "failed" : "exited";
689
+ }
690
+ return process.status;
691
+ }
692
+
693
+ function formatColoredStatusShort(process: ProcessInfo, theme: Theme): string {
694
+ return theme.fg(statusColor(process), formatStatusShort(process));
695
+ }
696
+
697
+ /** Sort + filter view used by the overview. Pure, unit-testable. */
698
+ export function applyOverviewView(
699
+ processes: ProcessInfo[],
700
+ sort: OverviewSort,
701
+ filter: OverviewFilter,
702
+ quickFilter: string,
703
+ ): ProcessInfo[] {
704
+ const filtered =
705
+ filter === "running"
706
+ ? processes.filter((process) => LIVE_STATUSES.has(process.status))
707
+ : filter === "finished"
708
+ ? processes.filter((process) => !LIVE_STATUSES.has(process.status))
709
+ : processes;
710
+ const quick = quickFilter
711
+ ? filtered.filter((process) =>
712
+ process.name.toLowerCase().includes(quickFilter.toLowerCase()),
713
+ )
714
+ : filtered;
715
+ const copy = [...quick];
716
+ if (sort === "status") {
717
+ copy.sort((a, b) => {
718
+ const aLive = LIVE_STATUSES.has(a.status) ? 1 : 0;
719
+ const bLive = LIVE_STATUSES.has(b.status) ? 1 : 0;
720
+ if (bLive !== aLive) return bLive - aLive;
721
+ return b.startTime - a.startTime;
722
+ });
723
+ } else if (sort === "started") {
724
+ copy.sort((a, b) => b.startTime - a.startTime);
725
+ } else {
726
+ copy.sort((a, b) => a.name.localeCompare(b.name));
727
+ }
728
+ return copy;
729
+ }