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