pi-async 0.1.0 → 0.3.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/CHANGELOG.md +188 -0
- package/README.md +93 -19
- package/docs/CONTRACT.md +301 -61
- package/extensions/index.ts +221 -38
- package/package.json +6 -3
- package/src/core/bus.ts +11 -3
- package/src/core/duration.ts +20 -0
- package/src/core/env.ts +134 -0
- package/src/core/notify.ts +82 -18
- package/src/core/output.ts +48 -2
- package/src/core/registry.ts +646 -95
- package/src/core/spawn.ts +95 -20
- package/src/core/task.ts +38 -8
- package/src/core/watch.ts +49 -13
- package/src/guard/settle-guard.ts +152 -46
- package/src/tools/index.ts +221 -27
- package/src/tui/panel.ts +375 -80
- package/src/tui/rail.ts +117 -33
- package/src/tui/status.ts +102 -25
- package/src/tui/width.ts +53 -0
package/src/tui/panel.ts
CHANGED
|
@@ -1,11 +1,41 @@
|
|
|
1
|
-
import { openSync, closeSync, fstatSync, readSync } from "node:fs";
|
|
2
1
|
import type { PiCtx } from "../core/notify.ts";
|
|
3
2
|
import type { TaskRegistry } from "../core/registry.ts";
|
|
4
3
|
import type { TaskSnapshot } from "../core/task.ts";
|
|
5
|
-
import { stripAnsi } from "../core/output.ts";
|
|
6
|
-
import {
|
|
4
|
+
import { stripAnsi, visibleText } from "../core/output.ts";
|
|
5
|
+
import { formatDuration } from "../core/duration.ts";
|
|
6
|
+
import { displayWidth, fitWidth, truncateToWidth } from "./width.ts";
|
|
7
|
+
import { clearDoneStatus, survivesClearCutoff } from "./status.ts";
|
|
7
8
|
import { tuiHost } from "./host.ts";
|
|
8
9
|
|
|
10
|
+
type Theme = { fg?(color: string, text: string): string };
|
|
11
|
+
|
|
12
|
+
/** Paint with host theme; missing/throwing fg falls back to plain text (#41 / #75). */
|
|
13
|
+
export function paint(theme: Theme | null | undefined, token: string, text: string): string {
|
|
14
|
+
try {
|
|
15
|
+
const fg = theme?.fg;
|
|
16
|
+
if (typeof fg !== "function") return text;
|
|
17
|
+
return fg.call(theme, token, text) ?? text;
|
|
18
|
+
} catch {
|
|
19
|
+
return text;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function statusToken(status: string): string {
|
|
24
|
+
if (status === "completed") return "success";
|
|
25
|
+
if (status === "failed" || status === "killed") return "error";
|
|
26
|
+
return "accent";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function stringifyMetaValue(value: unknown): string {
|
|
30
|
+
if (typeof value === "string") return value;
|
|
31
|
+
try { return JSON.stringify(value); } catch { return String(value); }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function sectionDivider(theme: Theme | null | undefined, budget: number): string {
|
|
35
|
+
const bar = "─".repeat(Math.max(0, Math.min(budget, 64)));
|
|
36
|
+
return paint(theme, "dim", bar);
|
|
37
|
+
}
|
|
38
|
+
|
|
9
39
|
const isTui = (pi: unknown): boolean => {
|
|
10
40
|
// Event/command contexts carry mode; the factory API does not. Both paths must resolve.
|
|
11
41
|
const asCtx = pi as { mode?: string } | undefined;
|
|
@@ -13,48 +43,138 @@ const isTui = (pi: unknown): boolean => {
|
|
|
13
43
|
};
|
|
14
44
|
|
|
15
45
|
const UI_TAIL_BYTES = 128 * 1024;
|
|
46
|
+
/** The list shows one live output line per task — a small tail window is enough (§2.9). */
|
|
47
|
+
const LIST_TAIL_BYTES = 16 * 1024;
|
|
16
48
|
const PANEL_ROWS = 12;
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
49
|
+
/** PageUp/PageDown step for the detail body. */
|
|
50
|
+
const PAGE_SCROLL = PANEL_ROWS;
|
|
51
|
+
/** Identity lines kept above the `… (+K lines)` marker when the detail body overflows. */
|
|
52
|
+
const DETAIL_HEADER_LINES = 2;
|
|
53
|
+
/** 0.3.0 §2.9: kill moved off `k` (which now scrolls up) onto `x`/`X`/`K`. */
|
|
54
|
+
const KILL_KEYS = new Set(["x", "X", "K"]);
|
|
55
|
+
const visible = visibleText;
|
|
56
|
+
/** Per-line sanitizer for multiline command display — keeps LF, strips controls/tabs. */
|
|
57
|
+
const visibleLine = (value: unknown): string => stripAnsi(String(value ?? "")).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "").replace(/[\t]+/g, " ");
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Mask a delayed task's env values but keep the keys: they are credentials-in-waiting,
|
|
61
|
+
* exactly like the async_status redaction (D-08). Kept local so the panel never imports src/tools.
|
|
62
|
+
*/
|
|
63
|
+
function maskDelayedEnv(value: unknown): string {
|
|
64
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return "***";
|
|
65
|
+
const keys = Object.keys(value as Record<string, unknown>);
|
|
66
|
+
return keys.length === 0 ? "***" : `{ ${keys.map((key) => `${key}: "***"`).join(", ")} }`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function metadataText(key: string, value: unknown): string {
|
|
70
|
+
return key === "delayedEnv" ? maskDelayedEnv(value) : stringifyMetaValue(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Known keys first (outputFile), then remaining keys sorted; each line truncated to budget. */
|
|
74
|
+
export function formatMetadataLines(metadata: Record<string, unknown>, budget: number): string[] {
|
|
75
|
+
const keys = Object.keys(metadata);
|
|
76
|
+
const ordered = [
|
|
77
|
+
...(["outputFile"] as const).filter((key) => Object.prototype.hasOwnProperty.call(metadata, key)),
|
|
78
|
+
...keys.filter((key) => key !== "outputFile").sort(),
|
|
79
|
+
];
|
|
80
|
+
return ordered.map((key) => truncateToWidth(`${key}: ${metadataText(key, metadata[key])}`, Math.max(0, budget)));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function detailTitleLines(task: { name: string; taskId: string }, theme: Theme | null | undefined): string[] {
|
|
84
|
+
const name = visibleText(task.name);
|
|
85
|
+
const id = `[${task.taskId}]`;
|
|
86
|
+
return [`${paint(theme, "accent", name)} ${paint(theme, "dim", id)}`];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function detailStatusLines(task: TaskSnapshot, theme: Theme | null | undefined, now: number): string[] {
|
|
90
|
+
const status = paint(theme, statusToken(task.status), task.status);
|
|
91
|
+
const elapsed = paint(theme, "dim", formatDuration(task, now));
|
|
92
|
+
return [`${paint(theme, "dim", "status:")} ${status} · ${paint(theme, "dim", "elapsed:")} ${elapsed}`];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function commandSection(command: unknown, theme: Theme | null | undefined, budget: number): string[] {
|
|
96
|
+
const label = paint(theme, "dim", "command:");
|
|
97
|
+
const lines = String(command ?? "").split(/\r?\n/);
|
|
98
|
+
const body = lines.map((line) => {
|
|
99
|
+
const plain = ` ${visibleLine(line)}`;
|
|
100
|
+
return paint(theme, "text", truncateToWidth(plain, Math.max(0, budget)));
|
|
101
|
+
});
|
|
102
|
+
return [label, ...body];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function metadataSection(metadata: Record<string, unknown>, theme: Theme | null | undefined, budget: number): string[] {
|
|
106
|
+
const label = paint(theme, "dim", "metadata:");
|
|
107
|
+
const rows = formatMetadataLines(metadata, Math.max(0, budget - 2)).map((row) => paint(theme, "dim", ` ${row}`));
|
|
108
|
+
return [label, ...rows];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function outputSection(tail: string[], truncated: boolean, theme: Theme | null | undefined, budget: number): string[] {
|
|
112
|
+
const label = paint(theme, "dim", `output${truncated ? " (tail truncated)" : ""}:`);
|
|
113
|
+
const rows = tail.map((line) => paint(theme, "dim", truncateToWidth(visible(line), Math.max(0, budget))));
|
|
114
|
+
return [label, ...rows];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Bounded output tail, always asked of the registry: it enforces the dataDir boundary
|
|
119
|
+
* (D-11) and the task may already be pruned, so a throw means "show nothing".
|
|
120
|
+
*/
|
|
121
|
+
function logsTail(registry: TaskRegistry, taskId: string, maxBytes: number): { text: string; truncated: boolean } {
|
|
22
122
|
try {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
const size = fstatSync(fd).size;
|
|
26
|
-
const start = Math.max(0, size - UI_TAIL_BYTES);
|
|
27
|
-
const buffer = Buffer.alloc(Math.min(size, UI_TAIL_BYTES));
|
|
28
|
-
readSync(fd, buffer, 0, buffer.length, start);
|
|
29
|
-
return { text: stripAnsi(buffer.toString("utf8")), truncated: start > 0 };
|
|
30
|
-
} finally { closeSync(fd); }
|
|
123
|
+
const slice = registry.logs(taskId, { maxBytes, tail: true });
|
|
124
|
+
return { text: stripAnsi(slice.text), truncated: slice.truncated };
|
|
31
125
|
} catch {
|
|
32
|
-
|
|
126
|
+
// Unknown/pruned task or an outputFile outside the data dir — never read it, never throw.
|
|
127
|
+
return { text: "", truncated: false };
|
|
33
128
|
}
|
|
34
129
|
}
|
|
35
130
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
131
|
+
function readUiOutput(task: TaskSnapshot, registry: TaskRegistry): { text: string; truncated: boolean } {
|
|
132
|
+
return logsTail(registry, task.taskId, UI_TAIL_BYTES);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Newest non-empty output line for the list row (bounded tail, never a whole-file read). */
|
|
136
|
+
function latestOutputLine(task: TaskSnapshot, registry: TaskRegistry): string | undefined {
|
|
137
|
+
const text = logsTail(registry, task.taskId, LIST_TAIL_BYTES).text;
|
|
138
|
+
return text.split(/\r?\n/).filter(Boolean).at(-1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Fit a possibly-ANSI line to a cell budget without counting escape bytes (#75). */
|
|
142
|
+
function fitAnsiLine(text: string, budget: number): string {
|
|
143
|
+
const plain = stripAnsi(text);
|
|
144
|
+
if (plain === text) return fitWidth(text, budget);
|
|
145
|
+
const used = displayWidth(plain);
|
|
146
|
+
if (used > budget) return fitWidth(plain, budget);
|
|
147
|
+
return text + " ".repeat(budget - used);
|
|
148
|
+
}
|
|
149
|
+
|
|
41
150
|
const frame = (title: string, body: string[], width: number, height: number): string[] => {
|
|
42
151
|
const w = Math.max(8, Math.floor(width));
|
|
43
152
|
const h = Math.max(4, Math.floor(height));
|
|
44
|
-
|
|
45
|
-
|
|
153
|
+
// Title prefix is "╭─ " (3) + "─╮" (2) => budget w-5; body "│ "/" │" (2+2) => w-4.
|
|
154
|
+
// #55 keeps fitTitle(w-5); #7/#62 routes both through fitWidth (cell-aware).
|
|
155
|
+
// #75: body lines may carry theme ANSI — fit by visible cells, not raw code points.
|
|
156
|
+
const fitTitle = (text: string): string => fitWidth(text, w - 5);
|
|
157
|
+
const fit = (text: string): string => fitAnsiLine(text, w - 4);
|
|
158
|
+
const content = [`╭─ ${fitTitle(title)}─╮`, ...body.slice(0, h - 2).map((line) => `│ ${fit(line)} │`), `╰${"─".repeat(w - 2)}╯`];
|
|
46
159
|
while (content.length < height) content.splice(content.length - 1, 0, `│${" ".repeat(w - 2)}│`);
|
|
47
160
|
// The solid background masks chat rows beneath the overlay, as in legion-viz's live overlay.
|
|
48
161
|
return content;
|
|
49
162
|
};
|
|
50
163
|
|
|
164
|
+
/** Bare Esc, Kitty CSI-u Escape (key 27), and a lone CSI introducer. */
|
|
165
|
+
export function isEscapeKey(data: string): boolean {
|
|
166
|
+
if (data === "\u001b") return true;
|
|
167
|
+
if (data === "\u001b[") return true;
|
|
168
|
+
return /^\u001b\[27(?:;\d+)*u$/.test(data);
|
|
169
|
+
}
|
|
170
|
+
|
|
51
171
|
export function registerPanelCommands(pi: PiCtx, registry: TaskRegistry): void {
|
|
52
172
|
// Commands/shortcuts are factory-time APIs (they exist on the factory pi); TUI
|
|
53
173
|
// gating comes from the bound event context. Handlers receive a rich ctx with ui.
|
|
54
174
|
const bound = tuiHost();
|
|
175
|
+
// SAFETY: the factory host may omit registration methods; optional chaining is intentional.
|
|
55
176
|
const host = pi as unknown as {
|
|
56
177
|
registerCommand?: (name: string, spec: { description: string; handler: (args: unknown, ctx: PiCtx) => void }) => void;
|
|
57
|
-
registerShortcut?: (key: string, spec: { description: string; handler: (ctx: PiCtx) => void }) => void;
|
|
58
178
|
};
|
|
59
179
|
if (!bound) return;
|
|
60
180
|
host.registerCommand?.("async", { description: "Open async task panel", handler: (_args, ctx) => openPanel(ctx, registry) });
|
|
@@ -62,15 +182,20 @@ export function registerPanelCommands(pi: PiCtx, registry: TaskRegistry): void {
|
|
|
62
182
|
description: "Clear async task history",
|
|
63
183
|
handler: (_args, ctx) => {
|
|
64
184
|
if (!isTui(ctx)) return;
|
|
65
|
-
|
|
66
|
-
clearDoneStatus();
|
|
185
|
+
clearDoneStatus(); // shared watermark in status.ts (#31)
|
|
67
186
|
},
|
|
68
187
|
});
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* D-14: the overlay is a module-level singleton. A second `/async` (or a rail click
|
|
192
|
+
* while the panel is open) must not stack another overlay.
|
|
193
|
+
*/
|
|
194
|
+
let panelOpen: symbol | undefined;
|
|
195
|
+
|
|
196
|
+
/** @internal Test-only: forget an open panel so isolated unit tests can re-open one. */
|
|
197
|
+
export function resetPanelForTests(): void {
|
|
198
|
+
panelOpen = undefined;
|
|
74
199
|
}
|
|
75
200
|
|
|
76
201
|
export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: string): void {
|
|
@@ -79,101 +204,256 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
|
|
|
79
204
|
const ctxUi = (pi as { ui?: Record<string, unknown> }).ui ?? tuiHost()?.ui;
|
|
80
205
|
const host = {
|
|
81
206
|
ui: ctxUi as {
|
|
82
|
-
copyToClipboard?: (text: string) => void;
|
|
83
207
|
custom?: (factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value?: unknown) => void) => unknown, options: unknown) => Promise<unknown>;
|
|
84
208
|
notify?: (message: string, level?: string) => void;
|
|
85
209
|
setStatus?: (key: string, text: string | undefined) => void;
|
|
86
210
|
} | undefined,
|
|
87
211
|
};
|
|
88
212
|
if (!isTui(pi) || typeof host.ui?.custom !== "function") return;
|
|
89
|
-
|
|
213
|
+
if (panelOpen !== undefined) {
|
|
214
|
+
host.ui.notify?.("Async panel is already open", "info");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
// Set before custom(): the host may render synchronously, and a reject must not leak the flag.
|
|
218
|
+
const token = Symbol("pi-async-panel");
|
|
219
|
+
panelOpen = token;
|
|
220
|
+
const release = (): void => {
|
|
221
|
+
if (panelOpen === token) panelOpen = undefined;
|
|
222
|
+
};
|
|
223
|
+
const snapshots = (): TaskSnapshot[] => registry.list({ includeHistory: true }).filter((task) => survivesClearCutoff(task));
|
|
90
224
|
const custom = host.ui.custom;
|
|
91
|
-
const
|
|
225
|
+
const kind = initialTaskId ? "detail" : "list";
|
|
226
|
+
const ratio = kind === "detail" ? { w: 0.85, h: 0.8 } : { w: 0.8, h: 0.7 };
|
|
227
|
+
const factory = (tui: unknown, themeArg: unknown, _keybindings: unknown, done: (value?: unknown) => void) => {
|
|
228
|
+
const theme = (themeArg ?? {}) as Theme;
|
|
92
229
|
let selected = initialTaskId ?? snapshots()[0]?.taskId;
|
|
93
230
|
let detail = Boolean(initialTaskId);
|
|
231
|
+
const origin: "rail" | "list" = initialTaskId ? "rail" : "list";
|
|
94
232
|
let history = true;
|
|
95
233
|
let confirmKill = false;
|
|
96
234
|
let scroll = 0;
|
|
235
|
+
let listScroll = 0;
|
|
97
236
|
let follow = true;
|
|
98
237
|
let width = 100;
|
|
99
238
|
let height = process.stdout.rows ?? 24;
|
|
100
239
|
let lastLines: string[] = [];
|
|
240
|
+
let actionError: string | undefined;
|
|
241
|
+
let closed = false;
|
|
242
|
+
let unsubscribe: (() => void) | undefined;
|
|
101
243
|
const requestRender = (): void => {
|
|
102
|
-
|
|
244
|
+
// A stale subscription (registry still holding our listener) must not render.
|
|
245
|
+
if (closed) return;
|
|
246
|
+
try {
|
|
247
|
+
(tui as { requestRender?: () => void } | undefined)?.requestRender?.();
|
|
248
|
+
} catch {
|
|
249
|
+
// A dead overlay host must not break the render loop.
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
/** Single teardown path for done / dispose / custom() failure — keeps the subscription leak-free. */
|
|
253
|
+
const teardown = (): void => {
|
|
254
|
+
if (closed) return;
|
|
255
|
+
closed = true;
|
|
256
|
+
release();
|
|
257
|
+
const off = unsubscribe;
|
|
258
|
+
unsubscribe = undefined;
|
|
259
|
+
if (!off) return;
|
|
260
|
+
try {
|
|
261
|
+
off();
|
|
262
|
+
} catch {
|
|
263
|
+
// Registry may already be torn down (reload/quit); unsubscribing is best-effort.
|
|
264
|
+
}
|
|
103
265
|
};
|
|
266
|
+
const close = (value?: unknown): void => {
|
|
267
|
+
teardown();
|
|
268
|
+
done(value);
|
|
269
|
+
};
|
|
270
|
+
// W1a adds `subscribe`; older/mock registries (0.2.x hosts) do not have it.
|
|
271
|
+
const subscribe = (registry as unknown as { subscribe?: (listener: () => void) => () => void }).subscribe;
|
|
272
|
+
if (typeof subscribe === "function") {
|
|
273
|
+
try {
|
|
274
|
+
const off = subscribe.call(registry, () => requestRender());
|
|
275
|
+
if (typeof off === "function") unsubscribe = off;
|
|
276
|
+
} catch {
|
|
277
|
+
// A registry that cannot publish events simply never refreshes us.
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const filtered = (): TaskSnapshot[] => snapshots().filter((task) => history || task.status === "running" || task.status === "pending");
|
|
104
281
|
const choose = (): TaskSnapshot | undefined => snapshots().find((task) => task.taskId === selected);
|
|
105
|
-
const
|
|
106
|
-
|
|
282
|
+
const listBudget = (): number => Math.max(1, height - 3); // frame borders + hint row
|
|
283
|
+
const ensureListWindow = (tasks: TaskSnapshot[]): number => {
|
|
284
|
+
const budget = listBudget();
|
|
285
|
+
const selIndex = tasks.findIndex((task) => task.taskId === selected);
|
|
286
|
+
const index = selIndex < 0 ? 0 : selIndex;
|
|
287
|
+
if (index < listScroll) listScroll = index;
|
|
288
|
+
if (index >= listScroll + budget) listScroll = index - budget + 1;
|
|
289
|
+
listScroll = Math.max(0, Math.min(listScroll, Math.max(0, tasks.length - budget)));
|
|
290
|
+
return listScroll;
|
|
291
|
+
};
|
|
292
|
+
const footerLine = (): string => paint(
|
|
293
|
+
theme,
|
|
294
|
+
"dim",
|
|
295
|
+
`${origin === "rail" ? "Esc close" : "Esc return"} · x kill/cancel · R rerun · c show output path · ↑/↓ scroll`,
|
|
296
|
+
);
|
|
297
|
+
/**
|
|
298
|
+
* Keep the header and the footer visible; collapse the middle into `… (+K lines)`.
|
|
299
|
+
* The footer is always the last emitted line, so `frame` can never silently cut it.
|
|
300
|
+
*/
|
|
301
|
+
const withBodyBudget = (lines: string[], budget: number): string[] => {
|
|
302
|
+
const limit = Math.max(1, Math.floor(budget));
|
|
303
|
+
if (lines.length <= limit) return lines;
|
|
304
|
+
const headerCount = Math.min(DETAIL_HEADER_LINES, Math.max(0, limit - 1));
|
|
305
|
+
const tailCount = limit - headerCount - 1;
|
|
306
|
+
const last = lines[lines.length - 1]!;
|
|
307
|
+
// Degenerate viewport: the key hints outrank every other line.
|
|
308
|
+
if (tailCount <= 0) return [last];
|
|
309
|
+
const hidden = lines.length - headerCount - tailCount;
|
|
310
|
+
return [
|
|
311
|
+
...lines.slice(0, headerCount),
|
|
312
|
+
paint(theme, "dim", `… (+${hidden} lines)`),
|
|
313
|
+
...lines.slice(lines.length - tailCount),
|
|
314
|
+
];
|
|
315
|
+
};
|
|
316
|
+
const renderBody = (budget: number): string[] => {
|
|
317
|
+
const tasks = filtered();
|
|
107
318
|
if (!detail) {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
319
|
+
const start = ensureListWindow(tasks);
|
|
320
|
+
const rows = tasks.slice(start, start + Math.max(1, budget - 1)).map((task) => {
|
|
321
|
+
const output = task.status === "running" ? latestOutputLine(task, registry) : undefined;
|
|
322
|
+
return `${task.taskId === selected ? "▶" : " "} ${visible(task.name)} · ${task.status} · ${formatDuration(task, Date.now())}${output ? ` · ${visible(output)}` : ""}`;
|
|
323
|
+
});
|
|
324
|
+
return ["↑/↓ select · Enter details · h history · q close", ...rows];
|
|
112
325
|
}
|
|
113
326
|
const task = choose();
|
|
114
|
-
if (!task) return ["Task not found", "Esc return · q close
|
|
327
|
+
if (!task) return ["Task not found", `${origin === "rail" ? "Esc close" : "Esc return"} · q close`];
|
|
115
328
|
const output = readUiOutput(task, registry);
|
|
116
329
|
const outputLines = output.text.split(/\r?\n/).filter((line) => line.length > 0);
|
|
117
330
|
scroll = Math.min(scroll, Math.max(0, outputLines.length - PANEL_ROWS));
|
|
118
331
|
const tail = outputLines.slice(Math.max(0, outputLines.length - PANEL_ROWS - scroll), outputLines.length - scroll);
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
332
|
+
const lineBudget = Math.max(1, Math.floor(width) - 4);
|
|
333
|
+
const killHint = task.status === "pending"
|
|
334
|
+
? "Cancel this pending task? press x again to confirm; any other key cancels"
|
|
335
|
+
: "Kill this task? press x again to confirm; any other key cancels";
|
|
336
|
+
const divider = sectionDivider(theme, lineBudget);
|
|
337
|
+
return withBodyBudget([
|
|
338
|
+
...detailTitleLines(task, theme),
|
|
339
|
+
...detailStatusLines(task, theme, Date.now()),
|
|
340
|
+
divider,
|
|
341
|
+
...commandSection(task.command, theme, lineBudget),
|
|
342
|
+
divider,
|
|
343
|
+
...metadataSection(task.metadata as Record<string, unknown>, theme, lineBudget),
|
|
344
|
+
divider,
|
|
345
|
+
...outputSection(tail, output.truncated, theme, lineBudget),
|
|
346
|
+
...(actionError ? [paint(theme, "error", `error: ${visible(actionError)}`)] : []),
|
|
347
|
+
...(confirmKill ? [paint(theme, "warning", killHint)] : []),
|
|
348
|
+
footerLine(),
|
|
349
|
+
], budget);
|
|
127
350
|
};
|
|
128
351
|
const render = (nextWidth?: number): string[] => {
|
|
129
|
-
width = nextWidth ??
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
352
|
+
width = nextWidth ?? Math.max(20, Math.floor((process.stdout.columns ?? 120) * ratio.w));
|
|
353
|
+
// Cap by the overlay's own percentage budget too: on very short terminals a fixed floor of 6
|
|
354
|
+
// exceeds what the host will render and the bottom border gets sliced off.
|
|
355
|
+
const rows = process.stdout.rows ?? 24;
|
|
356
|
+
height = Math.min(Math.max(6, Math.floor(rows * ratio.h)), Math.max(4, rows - 2));
|
|
357
|
+
lastLines = renderBody(Math.max(1, height - 2));
|
|
358
|
+
// The body is already budgeted, so its length no longer measures the scroll range;
|
|
359
|
+
// renderBody clamps `scroll` against the real output-line count instead.
|
|
133
360
|
if (follow) scroll = 0;
|
|
134
|
-
else scroll = Math.min(scroll, maxScroll);
|
|
135
361
|
return frame(detail ? "Async task detail" : "Async tasks", lastLines, width, height);
|
|
136
362
|
};
|
|
137
363
|
const move = (delta: number): void => {
|
|
138
|
-
const tasks =
|
|
364
|
+
const tasks = filtered();
|
|
139
365
|
const index = tasks.findIndex((task) => task.taskId === selected);
|
|
140
|
-
|
|
366
|
+
// Harden dangling selection (filtered away): start from edge rather than relying on -1+delta===0 alone.
|
|
367
|
+
const base = index < 0 ? (delta > 0 ? -1 : 0) : index;
|
|
368
|
+
const next = tasks[Math.max(0, Math.min(tasks.length - 1, base + delta))];
|
|
141
369
|
if (next) selected = next.taskId;
|
|
142
370
|
if (detail && delta < 0) { scroll += -delta; follow = false; }
|
|
143
371
|
else if (detail && delta > 0) { scroll = Math.max(0, scroll - delta); follow = scroll === 0; }
|
|
144
372
|
requestRender();
|
|
145
373
|
};
|
|
374
|
+
const killSelected = (): void => {
|
|
375
|
+
const task = choose();
|
|
376
|
+
if (!task || (task.status !== "running" && task.status !== "pending")) return;
|
|
377
|
+
const fail = (error: unknown): void => {
|
|
378
|
+
actionError = String(error);
|
|
379
|
+
host.ui?.notify?.(actionError, "error");
|
|
380
|
+
requestRender();
|
|
381
|
+
};
|
|
382
|
+
// §2.9: a rejected kill must surface in the panel instead of an unhandled rejection.
|
|
383
|
+
try {
|
|
384
|
+
void Promise.resolve(registry.kill(task.taskId, "user")).catch(fail);
|
|
385
|
+
} catch (error) {
|
|
386
|
+
// Some hosts throw synchronously instead of rejecting; treat both the same.
|
|
387
|
+
fail(error);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
146
390
|
let latchedId: string | undefined;
|
|
147
391
|
return {
|
|
148
392
|
render,
|
|
149
393
|
handleInput(data: string): void {
|
|
150
394
|
const key = data.toLowerCase();
|
|
151
|
-
if (confirmKill &&
|
|
152
|
-
if (key === "q") {
|
|
153
|
-
if (data
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
395
|
+
if (confirmKill && !KILL_KEYS.has(data)) { confirmKill = false; requestRender(); return; }
|
|
396
|
+
if (key === "q") { close(undefined); return; }
|
|
397
|
+
if (isEscapeKey(data)) {
|
|
398
|
+
if (detail && origin === "list") { detail = false; confirmKill = false; actionError = undefined; requestRender(); }
|
|
399
|
+
else close(undefined);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
if (key === "h" && !detail) {
|
|
403
|
+
history = !history;
|
|
404
|
+
const tasks = filtered();
|
|
405
|
+
if (!tasks.some((task) => task.taskId === selected)) selected = tasks[0]?.taskId;
|
|
406
|
+
listScroll = 0;
|
|
407
|
+
requestRender();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (data === "\r" || data === "\n") { detail = true; scroll = 0; follow = true; actionError = undefined; requestRender(); return; }
|
|
411
|
+
// PLAN §2.9: PageUp/PageDown page the detail body (PageUp = older lines).
|
|
412
|
+
// renderBody clamps the actual offset against the output length.
|
|
413
|
+
if (detail && (data === "\u001b[5~" || data === "\u001b[6~")) {
|
|
414
|
+
scroll = Math.max(0, scroll + (data === "\u001b[5~" ? PAGE_SCROLL : -PAGE_SCROLL));
|
|
415
|
+
follow = scroll === 0;
|
|
416
|
+
requestRender();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
// Kill lives above the movement keys: `K` is a kill key, `k` alone scrolls up.
|
|
420
|
+
if (detail && KILL_KEYS.has(data)) {
|
|
157
421
|
if (!confirmKill) { confirmKill = true; requestRender(); return; }
|
|
158
422
|
confirmKill = false;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
423
|
+
killSelected();
|
|
424
|
+
requestRender();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (data === "\u001b[A" || key === "k") { move(-1); return; }
|
|
163
428
|
if (data === "\u001b[B" || key === "j") { move(1); return; }
|
|
164
429
|
if (data === "R" && detail) {
|
|
165
430
|
const task = choose();
|
|
166
431
|
if (task) {
|
|
167
|
-
try {
|
|
168
|
-
|
|
432
|
+
try {
|
|
433
|
+
// Rerun keeps the original task's cwd (REV-13) instead of the host process cwd.
|
|
434
|
+
const created = registry.submit({
|
|
435
|
+
command: task.command,
|
|
436
|
+
name: task.name,
|
|
437
|
+
mode: "background",
|
|
438
|
+
...(task.cwd ? { cwd: task.cwd } : {}),
|
|
439
|
+
});
|
|
440
|
+
selected = created.taskId;
|
|
441
|
+
scroll = 0;
|
|
442
|
+
follow = true;
|
|
443
|
+
actionError = undefined;
|
|
444
|
+
} catch (error) {
|
|
445
|
+
actionError = String(error);
|
|
446
|
+
host.ui?.notify?.(actionError, "error");
|
|
447
|
+
}
|
|
169
448
|
}
|
|
170
449
|
requestRender(); return;
|
|
171
450
|
}
|
|
172
451
|
if (data === "c" && detail) {
|
|
173
452
|
const path = choose()?.metadata.outputFile;
|
|
453
|
+
// §2.9: pi has no copyToClipboard API — show the path so the user can copy it.
|
|
174
454
|
if (typeof path === "string") {
|
|
175
|
-
|
|
176
|
-
|
|
455
|
+
host.ui?.notify?.(path, "info");
|
|
456
|
+
requestRender();
|
|
177
457
|
}
|
|
178
458
|
}
|
|
179
459
|
},
|
|
@@ -189,30 +469,45 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
|
|
|
189
469
|
latchedId = undefined;
|
|
190
470
|
if (detail) return undefined;
|
|
191
471
|
if (event.button !== "left") return undefined;
|
|
192
|
-
const
|
|
193
|
-
const
|
|
472
|
+
const tasks = filtered();
|
|
473
|
+
const start = ensureListWindow(tasks);
|
|
474
|
+
const index = (event.y ?? -1) - 2 + start;
|
|
194
475
|
const task = tasks[index];
|
|
195
476
|
if (!task) return undefined;
|
|
196
477
|
latchedId = task.taskId;
|
|
197
478
|
return { handled: true };
|
|
198
479
|
}
|
|
199
480
|
if (event.type !== "click" || event.button !== "left" || detail) return undefined;
|
|
200
|
-
const
|
|
201
|
-
const
|
|
481
|
+
const tasks = filtered();
|
|
482
|
+
const start = ensureListWindow(tasks);
|
|
483
|
+
const index = (event.y ?? -1) - 2 + start;
|
|
202
484
|
const task = tasks[index];
|
|
203
485
|
if (!latchedId || !task || task.taskId !== latchedId) { latchedId = undefined; return undefined; }
|
|
204
486
|
selected = latchedId;
|
|
205
487
|
detail = true;
|
|
206
488
|
scroll = 0;
|
|
207
489
|
follow = true;
|
|
490
|
+
actionError = undefined;
|
|
208
491
|
latchedId = undefined;
|
|
209
492
|
requestRender();
|
|
210
493
|
return { handled: true };
|
|
211
494
|
},
|
|
212
|
-
dispose(): void {
|
|
495
|
+
dispose(): void { teardown(); },
|
|
213
496
|
};
|
|
214
497
|
};
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
|
|
498
|
+
// custom() resolves undefined outside supported TUI contexts. The RPC peer's
|
|
499
|
+
// custom() returns a Promise even though mode guards prevent invocation there.
|
|
500
|
+
try {
|
|
501
|
+
void Promise.resolve(custom.call(host.ui, factory, {
|
|
502
|
+
overlay: true,
|
|
503
|
+
overlayOptions: {
|
|
504
|
+
width: kind === "detail" ? "85%" : "80%",
|
|
505
|
+
maxHeight: kind === "detail" ? "80%" : "70%",
|
|
506
|
+
anchor: "center",
|
|
507
|
+
},
|
|
508
|
+
})).catch(() => release());
|
|
509
|
+
} catch {
|
|
510
|
+
// A synchronously throwing custom() must not leave the singleton latched open.
|
|
511
|
+
release();
|
|
512
|
+
}
|
|
218
513
|
}
|