@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.
- 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 +47 -20
- 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 -106
- 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 -148
- 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.ts +0 -269
- 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,685 @@
|
|
|
1
|
+
import { Panel, 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 {
|
|
13
|
+
CHANNELS,
|
|
14
|
+
type ProcessesChangedPayload,
|
|
15
|
+
type ProcessProtocolConfig,
|
|
16
|
+
type ProcessProtocolNotificationPayload,
|
|
17
|
+
} from "../../../src/protocol";
|
|
18
|
+
import { LIVE_STATUSES, type ProcessInfo } from "../../../src/types";
|
|
19
|
+
import { formatRuntime, truncateCmd } from "../../../src/utils/format";
|
|
20
|
+
import { isRecord } from "../../../src/utils/is-record";
|
|
21
|
+
import { renderProcessTab } from "../../process-tabs";
|
|
22
|
+
import { LineComponent, LinesComponent, RuleComponent } from "../../shared/ui";
|
|
23
|
+
import { requestProcessList } from "../client";
|
|
24
|
+
import {
|
|
25
|
+
connectToProcessLogs,
|
|
26
|
+
type LogsConnection,
|
|
27
|
+
type ProcessLogLine,
|
|
28
|
+
} from "../logs-client";
|
|
29
|
+
import { LogFileViewer } from "./log-file-viewer";
|
|
30
|
+
|
|
31
|
+
type OverlayMode = "normal" | "search-typing" | "search-active";
|
|
32
|
+
|
|
33
|
+
interface LogOverlayOptions {
|
|
34
|
+
events: EventBus;
|
|
35
|
+
tui: TUI;
|
|
36
|
+
theme: Theme;
|
|
37
|
+
initialProcessId?: string;
|
|
38
|
+
config: ProcessProtocolConfig;
|
|
39
|
+
onClose: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const CHROME_LINES = 9;
|
|
43
|
+
const MIN_LOG_ROWS = 5;
|
|
44
|
+
const MIN_OVERLAY_WIDTH = 80;
|
|
45
|
+
const MIN_OVERLAY_HEIGHT = 12;
|
|
46
|
+
const OVERLAY_FRACTION = 0.9;
|
|
47
|
+
const MAX_NOTIFY_MARKERS_PER_PROCESS = 100;
|
|
48
|
+
/** Cap retained viewers so a long session does not hold unbounded buffers. */
|
|
49
|
+
const MAX_CACHED_VIEWERS = 12;
|
|
50
|
+
/** Throttle chunk-driven renders so the editor under the overlay is not
|
|
51
|
+
* re-rendered at the framework's 60fps cadence on every output burst. */
|
|
52
|
+
const RENDER_THROTTLE_MS = 100;
|
|
53
|
+
|
|
54
|
+
interface NotifyMatchMark {
|
|
55
|
+
pattern: string;
|
|
56
|
+
line: string;
|
|
57
|
+
stream: "stdout" | "stderr";
|
|
58
|
+
matcherIndex: number;
|
|
59
|
+
timestamp: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export class LogOverlayComponent implements Component {
|
|
63
|
+
private processes: ProcessInfo[] = [];
|
|
64
|
+
private selectedIndex = 0;
|
|
65
|
+
private tabViewOffset = 0;
|
|
66
|
+
private connection: LogsConnection | null = null;
|
|
67
|
+
private viewer: LogFileViewer | null = null;
|
|
68
|
+
private message: string | null = null;
|
|
69
|
+
private mode: OverlayMode = "normal";
|
|
70
|
+
private searchInput = new Input();
|
|
71
|
+
private hasSeenRunningProcess = false;
|
|
72
|
+
private renderTimer: ReturnType<typeof setTimeout> | null = null;
|
|
73
|
+
private readonly disposers: Array<() => void> = [];
|
|
74
|
+
private disposed = false;
|
|
75
|
+
/** Per-process notify log-match markers, capped per process. */
|
|
76
|
+
private readonly notifyMarkers = new Map<string, NotifyMatchMark[]>();
|
|
77
|
+
/**
|
|
78
|
+
* Per-process cached viewers. A tab switch unsubscribes the live log
|
|
79
|
+
* connection (only one process is subscribed at a time) but preserves the
|
|
80
|
+
* viewer so scroll position, search, follow toggle, and stream filter
|
|
81
|
+
* survive a round-trip. Mirrors the notifyMarkers persistence pattern.
|
|
82
|
+
*/
|
|
83
|
+
private readonly viewers = new Map<string, LogFileViewer>();
|
|
84
|
+
|
|
85
|
+
constructor(private readonly opts: LogOverlayOptions) {
|
|
86
|
+
this.configureSearchInput();
|
|
87
|
+
|
|
88
|
+
this.refreshProcesses(opts.initialProcessId);
|
|
89
|
+
this.disposers.push(
|
|
90
|
+
opts.events.on(CHANNELS.CHANGED, (payload) => {
|
|
91
|
+
if (isChangedPayload(payload)) this.handleProcessesChanged(payload);
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
this.disposers.push(
|
|
95
|
+
opts.events.on(CHANNELS.NOTIFICATION, (payload) => {
|
|
96
|
+
this.handleNotification(payload);
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private handleNotification(payload: unknown): void {
|
|
102
|
+
if (!isLogMatchNotification(payload)) return;
|
|
103
|
+
const mark: NotifyMatchMark = {
|
|
104
|
+
pattern: payload.logMatch.pattern,
|
|
105
|
+
line: payload.logMatch.line,
|
|
106
|
+
stream: payload.logMatch.stream,
|
|
107
|
+
matcherIndex: payload.logMatch.matcherIndex,
|
|
108
|
+
timestamp: payload.timestamp,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const list = this.notifyMarkers.get(payload.processId) ?? [];
|
|
112
|
+
list.push(mark);
|
|
113
|
+
if (list.length > MAX_NOTIFY_MARKERS_PER_PROCESS) {
|
|
114
|
+
list.splice(0, list.length - MAX_NOTIFY_MARKERS_PER_PROCESS);
|
|
115
|
+
}
|
|
116
|
+
this.notifyMarkers.set(payload.processId, list);
|
|
117
|
+
|
|
118
|
+
const selected = this.selectedProcess();
|
|
119
|
+
if (selected && selected.id === payload.processId) {
|
|
120
|
+
this.viewer?.addNotifyMatch(mark);
|
|
121
|
+
this.opts.tui.requestRender();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
render(width: number): string[] {
|
|
126
|
+
const terminalSize = this.getTerminalSize(width);
|
|
127
|
+
const tooSmall =
|
|
128
|
+
terminalSize.columns < MIN_OVERLAY_WIDTH ||
|
|
129
|
+
terminalSize.rows < MIN_OVERLAY_HEIGHT;
|
|
130
|
+
const panel = new Panel({
|
|
131
|
+
title: "Process Logs",
|
|
132
|
+
body: tooSmall
|
|
133
|
+
? this.buildTooSmallBody(terminalSize.columns, terminalSize.rows)
|
|
134
|
+
: this.buildBody(),
|
|
135
|
+
footer: tooSmall ? undefined : this.buildFooter(),
|
|
136
|
+
border: "round",
|
|
137
|
+
padding: 0,
|
|
138
|
+
borderStyle: (text) => this.opts.theme.fg("dim", text),
|
|
139
|
+
titleStyle: (text) =>
|
|
140
|
+
this.opts.theme.fg("accent", this.opts.theme.bold(text)),
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return panel.render(width);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
handleInput(data: string): void {
|
|
147
|
+
if (this.mode === "search-typing") {
|
|
148
|
+
this.searchInput.handleInput?.(data);
|
|
149
|
+
this.opts.tui.requestRender();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (this.mode === "search-active") {
|
|
154
|
+
if (matchesKey(data, Key.escape)) {
|
|
155
|
+
this.viewer?.clearSearch();
|
|
156
|
+
this.searchInput.setValue("");
|
|
157
|
+
this.mode = "normal";
|
|
158
|
+
this.opts.tui.requestRender();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (data === "n") {
|
|
162
|
+
this.viewer?.nextMatch();
|
|
163
|
+
this.opts.tui.requestRender();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (data === "N") {
|
|
167
|
+
this.viewer?.previousMatch();
|
|
168
|
+
this.opts.tui.requestRender();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (data === "/") {
|
|
172
|
+
this.searchInput.setValue(this.viewer?.getSearchInfo()?.query ?? "");
|
|
173
|
+
this.mode = "search-typing";
|
|
174
|
+
this.opts.tui.requestRender();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (
|
|
180
|
+
matchesKey(data, Key.escape) ||
|
|
181
|
+
matchesKey(data, Key.ctrl("c")) ||
|
|
182
|
+
data === "q" ||
|
|
183
|
+
data === "Q"
|
|
184
|
+
) {
|
|
185
|
+
this.close();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (matchesKey(data, Key.tab)) this.selectRelative(1);
|
|
190
|
+
else if (matchesKey(data, Key.shift("tab"))) this.selectRelative(-1);
|
|
191
|
+
else if (matchesKey(data, Key.down) || data === "j")
|
|
192
|
+
this.viewer?.scrollBy(-1);
|
|
193
|
+
else if (matchesKey(data, Key.up) || data === "k") this.viewer?.scrollBy(1);
|
|
194
|
+
else if (data === "g") this.viewer?.scrollToTop();
|
|
195
|
+
else if (data === "G") this.viewer?.scrollToBottom();
|
|
196
|
+
else if (data === "s") this.viewer?.cycleStreamFilter();
|
|
197
|
+
else if (data === "f") this.viewer?.toggleFollow();
|
|
198
|
+
else if (data === "/") this.startSearch();
|
|
199
|
+
|
|
200
|
+
this.opts.tui.requestRender();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
invalidate(): void {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Throttle render requests driven by streaming output. The viewer's buffer
|
|
209
|
+
* accumulates every line regardless; this only coalesces the visual update
|
|
210
|
+
* so a fast producer does not force the framework to re-render the editor
|
|
211
|
+
* beneath the overlay 60 times a second. User input and search still call
|
|
212
|
+
* requestRender directly for immediate feedback.
|
|
213
|
+
*/
|
|
214
|
+
private scheduleRender(): void {
|
|
215
|
+
if (this.disposed || this.renderTimer) return;
|
|
216
|
+
this.renderTimer = setTimeout(() => {
|
|
217
|
+
this.renderTimer = null;
|
|
218
|
+
this.opts.tui.requestRender();
|
|
219
|
+
}, RENDER_THROTTLE_MS);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
dispose(): void {
|
|
223
|
+
if (this.disposed) return;
|
|
224
|
+
this.disposed = true;
|
|
225
|
+
if (this.renderTimer) {
|
|
226
|
+
clearTimeout(this.renderTimer);
|
|
227
|
+
this.renderTimer = null;
|
|
228
|
+
}
|
|
229
|
+
this.connection?.unsubscribe();
|
|
230
|
+
this.connection = null;
|
|
231
|
+
this.viewer = null;
|
|
232
|
+
this.viewers.clear();
|
|
233
|
+
this.notifyMarkers.clear();
|
|
234
|
+
for (const dispose of this.disposers.splice(0)) dispose();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private configureSearchInput(): void {
|
|
238
|
+
this.searchInput.onSubmit = (query) => {
|
|
239
|
+
const trimmed = query.trim();
|
|
240
|
+
if (trimmed) {
|
|
241
|
+
this.viewer?.setSearch(trimmed);
|
|
242
|
+
this.mode = "search-active";
|
|
243
|
+
} else {
|
|
244
|
+
this.viewer?.clearSearch();
|
|
245
|
+
this.mode = "normal";
|
|
246
|
+
}
|
|
247
|
+
this.opts.tui.requestRender();
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
this.searchInput.onEscape = () => {
|
|
251
|
+
this.searchInput.setValue("");
|
|
252
|
+
this.mode = "normal";
|
|
253
|
+
this.opts.tui.requestRender();
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private getTerminalSize(renderWidth: number): {
|
|
258
|
+
columns: number;
|
|
259
|
+
rows: number;
|
|
260
|
+
} {
|
|
261
|
+
return {
|
|
262
|
+
columns:
|
|
263
|
+
process.stdout.columns ?? this.opts.tui.terminal.columns ?? renderWidth,
|
|
264
|
+
rows: process.stdout.rows ?? this.opts.tui.terminal.rows ?? 0,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private buildTooSmallBody(columns: number, rows: number): Component {
|
|
269
|
+
return new LinesComponent(() => [
|
|
270
|
+
this.opts.theme.fg("warning", "Terminal size too small:"),
|
|
271
|
+
` Width = ${columns} Height = ${rows}`,
|
|
272
|
+
"",
|
|
273
|
+
"Needed for current config:",
|
|
274
|
+
` Width = ${MIN_OVERLAY_WIDTH} Height = ${MIN_OVERLAY_HEIGHT}`,
|
|
275
|
+
]);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
private buildBody(): Component {
|
|
279
|
+
const body = new Stack({ gap: 0 });
|
|
280
|
+
const selected = this.selectedProcess();
|
|
281
|
+
body.addChild(new LineComponent((width) => this.renderTabBar(width)));
|
|
282
|
+
body.addChild(
|
|
283
|
+
new LineComponent((width) => this.renderMetaLine(width, selected)),
|
|
284
|
+
);
|
|
285
|
+
body.addChild(new RuleComponent(this.opts.theme));
|
|
286
|
+
body.addChild(
|
|
287
|
+
new LinesComponent((width) => {
|
|
288
|
+
const logRows = this.logRows();
|
|
289
|
+
if (!selected) return this.renderNoProcesses(width, logRows);
|
|
290
|
+
if (!this.viewer) return this.renderUnavailable(width, logRows);
|
|
291
|
+
return this.viewer.render(width, logRows);
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
294
|
+
return body;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private buildFooter(): Component {
|
|
298
|
+
return new LineComponent((width) => this.renderFooter(width));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private renderSearchInput(): string {
|
|
302
|
+
const rendered = this.searchInput.render(80)[0] ?? "";
|
|
303
|
+
return `${this.opts.theme.fg("dim", "/")}${rendered.startsWith("> ") ? rendered.slice(2) : rendered}`;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private logRows(): number {
|
|
307
|
+
const rows = this.opts.tui.terminal.rows ?? 24;
|
|
308
|
+
const fromTerminal = Math.floor(rows * OVERLAY_FRACTION) - CHROME_LINES;
|
|
309
|
+
return Math.max(
|
|
310
|
+
MIN_LOG_ROWS,
|
|
311
|
+
Math.min(this.opts.config.processList.maxPreviewLines, fromTerminal),
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private close(): void {
|
|
316
|
+
this.dispose();
|
|
317
|
+
this.opts.onClose();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private refreshProcesses(preferredProcessId?: string): void {
|
|
321
|
+
this.processes = this.sortProcesses(requestProcessList(this.opts.events));
|
|
322
|
+
if (this.processes.some((process) => process.status === "running")) {
|
|
323
|
+
this.hasSeenRunningProcess = true;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (this.processes.length === 0) {
|
|
327
|
+
this.selectedIndex = 0;
|
|
328
|
+
this.connection?.unsubscribe();
|
|
329
|
+
this.connection = null;
|
|
330
|
+
this.viewer = null;
|
|
331
|
+
this.pruneRemovedProcesses();
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const currentId = preferredProcessId ?? this.selectedProcess()?.id;
|
|
336
|
+
const nextIndex = currentId
|
|
337
|
+
? this.processes.findIndex((process) => process.id === currentId)
|
|
338
|
+
: -1;
|
|
339
|
+
this.selectedIndex =
|
|
340
|
+
nextIndex >= 0
|
|
341
|
+
? nextIndex
|
|
342
|
+
: Math.min(this.selectedIndex, this.processes.length - 1);
|
|
343
|
+
this.ensureTabVisible();
|
|
344
|
+
this.subscribeToSelected();
|
|
345
|
+
this.pruneRemovedProcesses();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Drop cached viewers/markers for processes that no longer exist. */
|
|
349
|
+
private pruneRemovedProcesses(): void {
|
|
350
|
+
const live = new Set(this.processes.map((process) => process.id));
|
|
351
|
+
for (const id of [...this.viewers.keys()]) {
|
|
352
|
+
if (!live.has(id) && this.viewers.get(id) !== this.viewer) {
|
|
353
|
+
this.viewers.delete(id);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
for (const id of [...this.notifyMarkers.keys()]) {
|
|
357
|
+
if (!live.has(id) && id !== this.selectedProcess()?.id) {
|
|
358
|
+
this.notifyMarkers.delete(id);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private sortProcesses(processes: ProcessInfo[]): ProcessInfo[] {
|
|
364
|
+
return [...processes].sort((a, b) => {
|
|
365
|
+
const aLive = LIVE_STATUSES.has(a.status) ? 1 : 0;
|
|
366
|
+
const bLive = LIVE_STATUSES.has(b.status) ? 1 : 0;
|
|
367
|
+
if (bLive !== aLive) return bLive - aLive;
|
|
368
|
+
return b.startTime - a.startTime;
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
private handleProcessesChanged(change?: ProcessesChangedPayload): void {
|
|
373
|
+
this.refreshProcesses();
|
|
374
|
+
if (this.processes.length === 0 && change?.reason === "cleared") {
|
|
375
|
+
this.opts.tui.requestRender();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
if (
|
|
379
|
+
this.opts.config.follow.autoHideOnFinish &&
|
|
380
|
+
this.hasSeenRunningProcess &&
|
|
381
|
+
this.processes.every((process) => process.status !== "running")
|
|
382
|
+
) {
|
|
383
|
+
this.close();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
this.opts.tui.requestRender();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private selectRelative(delta: number): void {
|
|
390
|
+
if (this.processes.length === 0) return;
|
|
391
|
+
this.selectedIndex =
|
|
392
|
+
(this.selectedIndex + delta + this.processes.length) %
|
|
393
|
+
this.processes.length;
|
|
394
|
+
this.tabViewOffset = this.selectedIndex;
|
|
395
|
+
this.subscribeToSelected();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private ensureTabVisible(): void {
|
|
399
|
+
if (this.selectedIndex < this.tabViewOffset) {
|
|
400
|
+
this.tabViewOffset = this.selectedIndex;
|
|
401
|
+
}
|
|
402
|
+
this.tabViewOffset = Math.max(
|
|
403
|
+
0,
|
|
404
|
+
Math.min(this.tabViewOffset, this.selectedIndex),
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private selectedProcess(): ProcessInfo | null {
|
|
409
|
+
return this.processes[this.selectedIndex] ?? null;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private subscribeToSelected(): void {
|
|
413
|
+
const selected = this.selectedProcess();
|
|
414
|
+
// Drop the live subscription on switch, but keep the viewer (search,
|
|
415
|
+
// scroll, follow, stream filter) cached for the next time this tab is
|
|
416
|
+
// selected. Only one process is subscribed at a time.
|
|
417
|
+
this.connection?.unsubscribe();
|
|
418
|
+
this.connection = null;
|
|
419
|
+
this.viewer = null;
|
|
420
|
+
if (!selected) return;
|
|
421
|
+
|
|
422
|
+
const connection = connectToProcessLogs(this.opts.events, selected.id, {
|
|
423
|
+
tailLines: this.opts.config.output.defaultTailLines,
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
if (isLogsConnectionError(connection)) {
|
|
427
|
+
this.message = this.opts.theme.fg("warning", connection.error);
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
this.message = null;
|
|
432
|
+
this.connection = connection;
|
|
433
|
+
|
|
434
|
+
// Reuse a cached viewer if we already viewed this process; otherwise
|
|
435
|
+
// create one from the fresh initial tail.
|
|
436
|
+
let viewer = this.viewers.get(selected.id);
|
|
437
|
+
if (!viewer) {
|
|
438
|
+
viewer = new LogFileViewer(connection.initialLines, this.opts.theme, {
|
|
439
|
+
followEnabled: this.opts.config.follow.enabledByDefault,
|
|
440
|
+
maxBufferLines: this.opts.config.output.maxOutputLines,
|
|
441
|
+
maxBufferBytes: this.opts.config.output.maxOutputBytes,
|
|
442
|
+
});
|
|
443
|
+
this.viewers.set(selected.id, viewer);
|
|
444
|
+
this.pruneCachedViewers();
|
|
445
|
+
}
|
|
446
|
+
// NOTE: do NOT re-feed connection.initialLines into a cached viewer --
|
|
447
|
+
// doing so would duplicate lines already in its buffer.
|
|
448
|
+
this.viewer = viewer;
|
|
449
|
+
|
|
450
|
+
// Re-apply any notify markers that arrived while this viewer was
|
|
451
|
+
// detached (addNotifyMatch is a set, so this is idempotent for ones
|
|
452
|
+
// already applied).
|
|
453
|
+
const stored = this.notifyMarkers.get(selected.id);
|
|
454
|
+
if (stored) for (const mark of stored) viewer.addNotifyMatch(mark);
|
|
455
|
+
|
|
456
|
+
connection.onChunk((lines: ProcessLogLine[]) => {
|
|
457
|
+
// The active viewer may have changed by the time a chunk arrives; only
|
|
458
|
+
// feed the one that is still current.
|
|
459
|
+
this.viewer?.appendLines(lines);
|
|
460
|
+
this.scheduleRender();
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Keep the retained-viewer map bounded across a long session. */
|
|
465
|
+
private pruneCachedViewers(): void {
|
|
466
|
+
while (this.viewers.size > MAX_CACHED_VIEWERS) {
|
|
467
|
+
const oldest = this.viewers.keys().next().value;
|
|
468
|
+
if (oldest === undefined) break;
|
|
469
|
+
const removed = this.viewers.get(oldest);
|
|
470
|
+
if (removed && removed === this.viewer) {
|
|
471
|
+
// Never evict the currently-displayed viewer; rotate it to the end
|
|
472
|
+
// so a fresher entry is evicted instead.
|
|
473
|
+
this.viewers.delete(oldest);
|
|
474
|
+
this.viewers.set(oldest, removed);
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
this.viewers.delete(oldest);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private startSearch(): void {
|
|
482
|
+
this.searchInput.setValue("");
|
|
483
|
+
this.mode = "search-typing";
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private renderTabBar(width: number): string {
|
|
487
|
+
if (this.processes.length === 0)
|
|
488
|
+
return this.opts.theme.fg("dim", "No processes");
|
|
489
|
+
|
|
490
|
+
const parts = this.processes.map((process, index) =>
|
|
491
|
+
renderProcessTab(process, index === this.selectedIndex, this.opts.theme),
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
const separator = " ";
|
|
495
|
+
const allTabs = parts.join(separator);
|
|
496
|
+
if (visibleWidth(allTabs) <= width) return truncateToWidth(allTabs, width);
|
|
497
|
+
|
|
498
|
+
const window = this.renderTabWindow(parts, separator, width);
|
|
499
|
+
return truncateToWidth(window, width, "", true);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
private renderTabWindow(
|
|
503
|
+
parts: string[],
|
|
504
|
+
separator: string,
|
|
505
|
+
width: number,
|
|
506
|
+
): string {
|
|
507
|
+
const dim = (value: string) => this.opts.theme.fg("dim", value);
|
|
508
|
+
const activeIndex = Math.max(0, this.selectedIndex);
|
|
509
|
+
let start = activeIndex;
|
|
510
|
+
let end = activeIndex;
|
|
511
|
+
|
|
512
|
+
while (true) {
|
|
513
|
+
const canGrowLeft = start > 0;
|
|
514
|
+
const canGrowRight = end < parts.length - 1;
|
|
515
|
+
if (!canGrowLeft && !canGrowRight) break;
|
|
516
|
+
|
|
517
|
+
const leftFits =
|
|
518
|
+
canGrowLeft &&
|
|
519
|
+
this.tabWindowFits(parts, separator, start - 1, end, width);
|
|
520
|
+
const rightFits =
|
|
521
|
+
canGrowRight &&
|
|
522
|
+
this.tabWindowFits(parts, separator, start, end + 1, width);
|
|
523
|
+
|
|
524
|
+
if (!leftFits && !rightFits) break;
|
|
525
|
+
|
|
526
|
+
if (leftFits && rightFits) {
|
|
527
|
+
if (activeIndex - start <= end - activeIndex) start--;
|
|
528
|
+
else end++;
|
|
529
|
+
} else if (leftFits) start--;
|
|
530
|
+
else end++;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const left = start > 0 ? dim("← ") : "";
|
|
534
|
+
const right = end < parts.length - 1 ? dim(" →") : "";
|
|
535
|
+
return `${left}${parts.slice(start, end + 1).join(separator)}${right}`;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private tabWindowFits(
|
|
539
|
+
parts: string[],
|
|
540
|
+
separator: string,
|
|
541
|
+
start: number,
|
|
542
|
+
end: number,
|
|
543
|
+
width: number,
|
|
544
|
+
): boolean {
|
|
545
|
+
const left = start > 0 ? this.opts.theme.fg("dim", "← ") : "";
|
|
546
|
+
const right = end < parts.length - 1 ? this.opts.theme.fg("dim", " →") : "";
|
|
547
|
+
const rendered = `${left}${parts.slice(start, end + 1).join(separator)}${right}`;
|
|
548
|
+
return visibleWidth(rendered) <= width;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private renderMetaLine(width: number, process: ProcessInfo | null): string {
|
|
552
|
+
if (!process) return "";
|
|
553
|
+
const dim = (value: string) => this.opts.theme.fg("dim", value);
|
|
554
|
+
const duration = dim(formatRuntime(process.startTime, process.endTime));
|
|
555
|
+
const durationWidth = visibleWidth(duration);
|
|
556
|
+
const separator = ` ${dim("·")} `;
|
|
557
|
+
const leftPrefix = `${dim(process.id)}${separator}`;
|
|
558
|
+
const availableCommandWidth = Math.max(
|
|
559
|
+
4,
|
|
560
|
+
width - visibleWidth(leftPrefix) - durationWidth - 1,
|
|
561
|
+
);
|
|
562
|
+
const command = dim(truncateCmd(process.command, availableCommandWidth));
|
|
563
|
+
const left = `${leftPrefix}${command}`;
|
|
564
|
+
const gap = Math.max(1, width - visibleWidth(left) - durationWidth);
|
|
565
|
+
return truncateToWidth(`${left}${" ".repeat(gap)}${duration}`, width);
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private renderNoProcesses(width: number, height: number): string[] {
|
|
569
|
+
return centeredBlock(
|
|
570
|
+
width,
|
|
571
|
+
height,
|
|
572
|
+
this.opts.theme.fg("muted", "Start a process with the process tool."),
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
private renderUnavailable(width: number, height: number): string[] {
|
|
577
|
+
return centeredBlock(
|
|
578
|
+
width,
|
|
579
|
+
height,
|
|
580
|
+
this.opts.theme.fg("warning", "Logs unavailable."),
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
private renderFooter(width: number): string {
|
|
585
|
+
const status = this.viewer?.getStatusParts() ?? { left: [], right: [] };
|
|
586
|
+
const right = status.right.join(" ");
|
|
587
|
+
const rightWidth = visibleWidth(right);
|
|
588
|
+
const leftWidth = Math.max(1, width - rightWidth - 1);
|
|
589
|
+
const left = this.renderFooterLeft(leftWidth, status.left);
|
|
590
|
+
const gap = Math.max(1, width - visibleWidth(left) - rightWidth);
|
|
591
|
+
return truncateToWidth(`${left}${" ".repeat(gap)}${right}`, width);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
private renderFooterLeft(width: number, statusLeft: string[]): string {
|
|
595
|
+
if (this.mode === "search-typing") {
|
|
596
|
+
return truncateToWidth(
|
|
597
|
+
`${this.renderSearchInput()} ${this.opts.theme.fg("dim", "enter")} apply ${this.opts.theme.fg("dim", "esc")} cancel`,
|
|
598
|
+
width,
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const leftPrefix = this.message ?? statusLeft.join(" ");
|
|
603
|
+
const prefix = leftPrefix ? `${leftPrefix} ` : "";
|
|
604
|
+
const keys = this.renderFooterKeys(
|
|
605
|
+
Math.max(1, width - visibleWidth(prefix)),
|
|
606
|
+
);
|
|
607
|
+
return truncateToWidth(`${prefix}${keys}`, width);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
private renderFooterKeys(width: number): string {
|
|
611
|
+
const dim = (value: string) => this.opts.theme.fg("dim", value);
|
|
612
|
+
const accent = (value: string) => this.opts.theme.fg("accent", value);
|
|
613
|
+
|
|
614
|
+
if (this.mode === "search-active") {
|
|
615
|
+
return truncateToWidth(
|
|
616
|
+
`${dim("n")} next ${dim("N")} prev ${dim("/")} edit ${dim("esc")} clear ${dim("j/k")} scroll ${dim("q")} close`,
|
|
617
|
+
width,
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const streamFilter = this.viewer?.getStreamFilter() ?? "both";
|
|
622
|
+
const stdout =
|
|
623
|
+
streamFilter === "both" || streamFilter === "stdout"
|
|
624
|
+
? accent("stdout")
|
|
625
|
+
: dim("stdout");
|
|
626
|
+
const stderr =
|
|
627
|
+
streamFilter === "both" || streamFilter === "stderr"
|
|
628
|
+
? accent("stderr")
|
|
629
|
+
: dim("stderr");
|
|
630
|
+
|
|
631
|
+
return truncateToWidth(
|
|
632
|
+
`${dim("tab/shift+tab")} switch ${dim("g/G")} top/bot ${dim("j/k")} scroll ${dim("/")} search ${dim("s:")}${stdout}${dim("+")}${stderr} ${dim("f")} follow ${dim("q")} close`,
|
|
633
|
+
width,
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function isLogsConnectionError(
|
|
639
|
+
connection: LogsConnection | { ok: false; error: string },
|
|
640
|
+
): connection is { ok: false; error: string } {
|
|
641
|
+
return "ok" in connection && connection.ok === false;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function centeredBlock(
|
|
645
|
+
width: number,
|
|
646
|
+
height: number,
|
|
647
|
+
content: string,
|
|
648
|
+
): string[] {
|
|
649
|
+
const lines = Array.from({ length: height }, () => "");
|
|
650
|
+
const row = Math.max(0, Math.floor((height - 1) / 2));
|
|
651
|
+
const leftPad = Math.max(0, Math.floor((width - visibleWidth(content)) / 2));
|
|
652
|
+
lines[row] = truncateToWidth(`${" ".repeat(leftPad)}${content}`, width);
|
|
653
|
+
return lines;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function isChangedPayload(
|
|
657
|
+
payload: unknown,
|
|
658
|
+
): payload is ProcessesChangedPayload {
|
|
659
|
+
return (
|
|
660
|
+
isRecord(payload) &&
|
|
661
|
+
(payload.reason === "started" ||
|
|
662
|
+
payload.reason === "ended" ||
|
|
663
|
+
payload.reason === "cleared")
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function isLogMatchNotification(
|
|
668
|
+
payload: unknown,
|
|
669
|
+
): payload is ProcessProtocolNotificationPayload & {
|
|
670
|
+
kind: "log_match";
|
|
671
|
+
logMatch: NonNullable<ProcessProtocolNotificationPayload["logMatch"]>;
|
|
672
|
+
} {
|
|
673
|
+
if (!isRecord(payload)) return false;
|
|
674
|
+
if (payload.kind !== "log_match") return false;
|
|
675
|
+
if (typeof payload.processId !== "string") return false;
|
|
676
|
+
if (typeof payload.timestamp !== "number") return false;
|
|
677
|
+
const logMatch = payload.logMatch;
|
|
678
|
+
if (!isRecord(logMatch)) return false;
|
|
679
|
+
if (typeof logMatch.pattern !== "string") return false;
|
|
680
|
+
if (typeof logMatch.line !== "string") return false;
|
|
681
|
+
if (logMatch.stream !== "stdout" && logMatch.stream !== "stderr")
|
|
682
|
+
return false;
|
|
683
|
+
if (typeof logMatch.matcherIndex !== "number") return false;
|
|
684
|
+
return true;
|
|
685
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { isWindowsPlatform } from "../../src/utils/platform";
|
|
3
|
+
import { registerLogsCommand } from "./commands/logs";
|
|
4
|
+
|
|
5
|
+
export default async function processesLogsExtension(
|
|
6
|
+
pi: ExtensionAPI,
|
|
7
|
+
): Promise<void> {
|
|
8
|
+
// The core extension no-ops on Windows; without the manager's protocol
|
|
9
|
+
// handlers there is nothing for the logs overlay to subscribe to, so bail
|
|
10
|
+
// silently. The core extension already surfaced a warning.
|
|
11
|
+
if (isWindowsPlatform()) return;
|
|
12
|
+
|
|
13
|
+
const openOverlays = new Set<{ dispose: () => void }>();
|
|
14
|
+
|
|
15
|
+
registerLogsCommand(pi, {
|
|
16
|
+
events: pi.events,
|
|
17
|
+
registerOverlay: (overlay) => {
|
|
18
|
+
openOverlays.add(overlay);
|
|
19
|
+
return () => openOverlays.delete(overlay);
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
pi.on("session_shutdown", () => {
|
|
24
|
+
for (const overlay of [...openOverlays]) overlay.dispose();
|
|
25
|
+
openOverlays.clear();
|
|
26
|
+
});
|
|
27
|
+
}
|