@aliou/pi-processes 0.9.5 → 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.
- package/CONTRIBUTING.md +40 -42
- package/README.md +54 -84
- package/extensions/process-tabs.ts +26 -0
- package/extensions/processes/client.ts +194 -0
- package/extensions/processes/commands/clear.ts +29 -0
- package/extensions/processes/commands/kill.ts +152 -0
- package/extensions/processes/commands/overview.ts +102 -0
- package/extensions/processes/components/overview-component.ts +729 -0
- package/extensions/processes/components/overview-panel.ts +177 -0
- package/extensions/processes/config/defaults.ts +28 -0
- package/extensions/processes/config/index.ts +13 -0
- package/extensions/processes/config/loader.ts +62 -0
- package/extensions/processes/config/migrations/000-import-legacy-process-config.ts +113 -0
- package/extensions/processes/config/migrations/001-v0-9-4-to-v0-10-0-config.ts +184 -0
- package/extensions/processes/config/migrations/002-stamp-v0-10-0-config-version.ts +21 -0
- package/extensions/processes/config/migrations/index.ts +25 -0
- package/extensions/processes/config/schema.ts +9 -0
- package/extensions/processes/config/types.ts +72 -0
- package/extensions/processes/constants.ts +1 -0
- package/extensions/processes/handlers/commands.ts +113 -0
- package/extensions/processes/handlers/kill-process.ts +31 -0
- package/extensions/processes/handlers/notifications.ts +147 -0
- package/extensions/processes/handlers/requests.ts +114 -0
- package/extensions/processes/handlers/subscriptions.ts +144 -0
- package/extensions/processes/hooks/background-blocker.ts +201 -0
- package/extensions/processes/hooks/cleanup.ts +43 -0
- package/extensions/processes/hooks/event-bridge.ts +32 -0
- package/extensions/processes/i18n/index.ts +3 -0
- package/extensions/processes/i18n/messages.ts +27 -0
- package/extensions/processes/i18n/translator.ts +49 -0
- package/extensions/processes/index.ts +109 -0
- package/extensions/processes/message-renderer.ts +117 -0
- package/extensions/processes/notification-sender.ts +43 -0
- package/extensions/processes/notifications/classify.ts +27 -0
- package/extensions/processes/notifications/log-matchers.ts +146 -0
- package/extensions/processes/notifications/registry.ts +225 -0
- package/extensions/processes/notifications/render-content.ts +122 -0
- package/extensions/processes/notifications/service.ts +336 -0
- package/extensions/processes/notifications/types.ts +11 -0
- package/extensions/processes/settings/apply-setting-change.ts +30 -0
- package/extensions/processes/settings/build-sections.ts +420 -0
- package/extensions/processes/settings/index.ts +42 -0
- package/extensions/processes/tools/clear/index.ts +21 -0
- package/extensions/processes/tools/clear/render.ts +40 -0
- package/extensions/processes/tools/components/index.ts +4 -0
- package/extensions/processes/tools/components/process-action-header.ts +31 -0
- package/extensions/processes/tools/components/process-action-title.ts +17 -0
- package/extensions/processes/tools/components/tool-layout.ts +70 -0
- package/extensions/processes/tools/components/watch.ts +53 -0
- package/extensions/processes/tools/index.ts +265 -0
- package/extensions/processes/tools/list/index.ts +157 -0
- package/extensions/processes/tools/list/render.ts +211 -0
- package/extensions/processes/tools/notify.ts +191 -0
- package/extensions/processes/tools/output/index.ts +307 -0
- package/extensions/processes/tools/output/render.ts +229 -0
- package/extensions/processes/tools/schema.ts +263 -0
- package/extensions/processes/tools/start/index.ts +64 -0
- package/extensions/processes/tools/start/render.ts +67 -0
- package/extensions/processes/tools/stop/index.ts +35 -0
- package/extensions/processes/tools/stop/render.ts +43 -0
- package/extensions/processes/tools/update/index.ts +320 -0
- package/extensions/processes/tools/update/render.ts +251 -0
- package/extensions/processes/tools/utils.ts +112 -0
- package/extensions/processes/tools/watch-format.ts +14 -0
- package/extensions/processes/tools/write/index.ts +75 -0
- package/extensions/processes/tools/write/render.ts +68 -0
- package/extensions/processes/utils/truncate.ts +285 -0
- package/extensions/processes-dock/client.ts +60 -0
- package/extensions/processes-dock/commands/dock.ts +70 -0
- package/extensions/processes-dock/commands/pin.ts +166 -0
- package/extensions/processes-dock/components/log-dock-component.ts +375 -0
- package/extensions/processes-dock/dock-state.ts +33 -0
- package/extensions/processes-dock/index.ts +28 -0
- package/extensions/processes-dock/logs-client.ts +5 -0
- package/extensions/processes-dock/widget/setup.ts +520 -0
- package/extensions/processes-dock/widget/status.ts +149 -0
- package/extensions/processes-dock/widget/types.ts +16 -0
- package/extensions/processes-logs/client.ts +51 -0
- package/extensions/processes-logs/commands/logs.ts +108 -0
- package/extensions/processes-logs/completions.ts +31 -0
- package/extensions/processes-logs/components/log-file-viewer.ts +274 -0
- package/extensions/processes-logs/components/log-overlay-component.ts +685 -0
- package/extensions/processes-logs/index.ts +27 -0
- package/extensions/processes-logs/logs-client.ts +101 -0
- package/extensions/shared/line-buffer.ts +35 -0
- package/extensions/shared/output-payload.ts +28 -0
- package/extensions/shared/ui.ts +146 -0
- package/package.json +34 -9
- package/schema.json +134 -0
- package/skills/pi-processes/SKILL.md +496 -25
- package/src/get-manager.ts +15 -0
- package/src/manager/index.ts +182 -0
- package/src/manager/internal-types.ts +130 -0
- package/src/manager/limits.ts +15 -0
- package/src/manager/process-log-store.ts +422 -0
- package/src/manager/process-output.ts +345 -0
- package/src/manager/process-registry.ts +78 -0
- package/src/manager/process-runtime-controller.ts +499 -0
- package/src/protocol/broadcasts.ts +14 -0
- package/src/protocol/channels.ts +30 -0
- package/src/protocol/commands.ts +24 -0
- package/src/protocol/index.ts +34 -0
- package/src/protocol/logs.ts +23 -0
- package/src/protocol/notifications.ts +48 -0
- package/src/protocol/requests.ts +76 -0
- package/src/types.ts +66 -0
- package/src/utils/ansi.ts +2 -4
- package/src/utils/format.ts +24 -32
- package/src/utils/index.ts +5 -1
- package/src/utils/is-record.ts +3 -0
- package/src/utils/match-line.ts +32 -0
- package/src/utils/platform.ts +13 -0
- package/src/utils/shorten-path.ts +11 -0
- package/src/utils/signals.ts +22 -0
- package/src/commands/clear/command.ts +0 -14
- package/src/commands/clear/index.ts +0 -1
- package/src/commands/completions.ts +0 -38
- package/src/commands/dock/command.ts +0 -29
- package/src/commands/dock/index.ts +0 -1
- package/src/commands/index.ts +0 -33
- package/src/commands/kill/command.ts +0 -69
- package/src/commands/kill/index.ts +0 -1
- package/src/commands/logs/command.ts +0 -47
- package/src/commands/logs/index.ts +0 -1
- package/src/commands/pick-process.ts +0 -34
- package/src/commands/pin/command.ts +0 -34
- package/src/commands/pin/index.ts +0 -1
- package/src/commands/processes/command.ts +0 -39
- package/src/commands/processes/index.ts +0 -1
- package/src/commands/settings/apply-setting-change.ts +0 -72
- package/src/commands/settings/build-sections.ts +0 -160
- package/src/commands/settings/command.ts +0 -20
- package/src/commands/settings/index.ts +0 -1
- package/src/components/log-dock-component.test.ts +0 -29
- package/src/components/log-dock-component.ts +0 -236
- package/src/components/log-file-viewer.ts +0 -317
- package/src/components/log-overlay-component.ts +0 -546
- package/src/components/panel-helpers.ts +0 -37
- package/src/components/process-picker-component.ts +0 -166
- package/src/components/processes-component.ts +0 -485
- package/src/components/status-format.ts +0 -38
- package/src/config.ts +0 -107
- package/src/constants/index.ts +0 -16
- package/src/constants/types.ts +0 -119
- package/src/hooks/background-blocker.ts +0 -66
- package/src/hooks/cleanup.ts +0 -10
- package/src/hooks/index.ts +0 -32
- package/src/hooks/message-renderer.ts +0 -115
- package/src/hooks/process-end.ts +0 -69
- package/src/hooks/process-watch.ts +0 -85
- package/src/hooks/utils.ts +0 -23
- package/src/hooks/widget/index.ts +0 -2
- package/src/hooks/widget/setup.ts +0 -167
- package/src/hooks/widget/status-widget.ts +0 -105
- package/src/hooks/widget/types.ts +0 -20
- package/src/index.ts +0 -35
- package/src/manager.test.ts +0 -331
- package/src/manager.ts +0 -758
- package/src/tools/actions/clear.ts +0 -20
- package/src/tools/actions/debug.ts +0 -155
- package/src/tools/actions/index.ts +0 -171
- package/src/tools/actions/kill.ts +0 -89
- package/src/tools/actions/list.ts +0 -188
- package/src/tools/actions/logs.ts +0 -117
- package/src/tools/actions/output-truncate.ts +0 -187
- package/src/tools/actions/output.test.ts +0 -392
- package/src/tools/actions/output.ts +0 -447
- package/src/tools/actions/start.ts +0 -245
- package/src/tools/actions/write.ts +0 -113
- package/src/tools/index.ts +0 -207
- package/src/utils/ansi.test.ts +0 -22
- package/src/utils/command-executor.test.ts +0 -48
- 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
|
+
}
|