pi-async 0.2.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 +135 -1
- package/README.md +73 -19
- package/docs/CONTRACT.md +242 -53
- package/extensions/index.ts +211 -59
- package/package.json +4 -2
- package/src/core/duration.ts +8 -4
- package/src/core/env.ts +111 -0
- package/src/core/notify.ts +63 -15
- package/src/core/registry.ts +465 -41
- package/src/core/spawn.ts +46 -11
- package/src/core/task.ts +13 -0
- package/src/guard/settle-guard.ts +152 -43
- package/src/tools/index.ts +185 -30
- package/src/tui/panel.ts +308 -74
- package/src/tui/rail.ts +76 -45
- package/src/tui/status.ts +86 -30
package/src/tui/panel.ts
CHANGED
|
@@ -1,13 +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
4
|
import { stripAnsi, visibleText } from "../core/output.ts";
|
|
6
5
|
import { formatDuration } from "../core/duration.ts";
|
|
7
|
-
import { fitWidth } from "./width.ts";
|
|
6
|
+
import { displayWidth, fitWidth, truncateToWidth } from "./width.ts";
|
|
8
7
|
import { clearDoneStatus, survivesClearCutoff } from "./status.ts";
|
|
9
8
|
import { tuiHost } from "./host.ts";
|
|
10
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
|
+
|
|
11
39
|
const isTui = (pi: unknown): boolean => {
|
|
12
40
|
// Event/command contexts carry mode; the factory API does not. Both paths must resolve.
|
|
13
41
|
const asCtx = pi as { mode?: string } | undefined;
|
|
@@ -15,34 +43,118 @@ const isTui = (pi: unknown): boolean => {
|
|
|
15
43
|
};
|
|
16
44
|
|
|
17
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;
|
|
18
48
|
const PANEL_ROWS = 12;
|
|
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"]);
|
|
19
55
|
const visible = visibleText;
|
|
20
56
|
/** Per-line sanitizer for multiline command display — keeps LF, strips controls/tabs. */
|
|
21
57
|
const visibleLine = (value: unknown): string => stripAnsi(String(value ?? "")).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "").replace(/[\t]+/g, " ");
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 } {
|
|
25
122
|
try {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
const size = fstatSync(fd).size;
|
|
29
|
-
const start = Math.max(0, size - UI_TAIL_BYTES);
|
|
30
|
-
const buffer = Buffer.alloc(Math.min(size, UI_TAIL_BYTES));
|
|
31
|
-
readSync(fd, buffer, 0, buffer.length, start);
|
|
32
|
-
return { text: stripAnsi(buffer.toString("utf8")), truncated: start > 0 };
|
|
33
|
-
} finally { closeSync(fd); }
|
|
123
|
+
const slice = registry.logs(taskId, { maxBytes, tail: true });
|
|
124
|
+
return { text: stripAnsi(slice.text), truncated: slice.truncated };
|
|
34
125
|
} catch {
|
|
35
|
-
|
|
126
|
+
// Unknown/pruned task or an outputFile outside the data dir — never read it, never throw.
|
|
127
|
+
return { text: "", truncated: false };
|
|
36
128
|
}
|
|
37
129
|
}
|
|
38
130
|
|
|
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
|
+
|
|
39
150
|
const frame = (title: string, body: string[], width: number, height: number): string[] => {
|
|
40
151
|
const w = Math.max(8, Math.floor(width));
|
|
41
152
|
const h = Math.max(4, Math.floor(height));
|
|
42
153
|
// Title prefix is "╭─ " (3) + "─╮" (2) => budget w-5; body "│ "/" │" (2+2) => w-4.
|
|
43
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.
|
|
44
156
|
const fitTitle = (text: string): string => fitWidth(text, w - 5);
|
|
45
|
-
const fit = (text: string): string =>
|
|
157
|
+
const fit = (text: string): string => fitAnsiLine(text, w - 4);
|
|
46
158
|
const content = [`╭─ ${fitTitle(title)}─╮`, ...body.slice(0, h - 2).map((line) => `│ ${fit(line)} │`), `╰${"─".repeat(w - 2)}╯`];
|
|
47
159
|
while (content.length < height) content.splice(content.length - 1, 0, `│${" ".repeat(w - 2)}│`);
|
|
48
160
|
// The solid background masks chat rows beneath the overlay, as in legion-viz's live overlay.
|
|
@@ -60,9 +172,9 @@ export function registerPanelCommands(pi: PiCtx, registry: TaskRegistry): void {
|
|
|
60
172
|
// Commands/shortcuts are factory-time APIs (they exist on the factory pi); TUI
|
|
61
173
|
// gating comes from the bound event context. Handlers receive a rich ctx with ui.
|
|
62
174
|
const bound = tuiHost();
|
|
175
|
+
// SAFETY: the factory host may omit registration methods; optional chaining is intentional.
|
|
63
176
|
const host = pi as unknown as {
|
|
64
177
|
registerCommand?: (name: string, spec: { description: string; handler: (args: unknown, ctx: PiCtx) => void }) => void;
|
|
65
|
-
registerShortcut?: (key: string, spec: { description: string; handler: (ctx: PiCtx) => void }) => void;
|
|
66
178
|
};
|
|
67
179
|
if (!bound) return;
|
|
68
180
|
host.registerCommand?.("async", { description: "Open async task panel", handler: (_args, ctx) => openPanel(ctx, registry) });
|
|
@@ -73,21 +185,17 @@ export function registerPanelCommands(pi: PiCtx, registry: TaskRegistry): void {
|
|
|
73
185
|
clearDoneStatus(); // shared watermark in status.ts (#31)
|
|
74
186
|
},
|
|
75
187
|
});
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
} catch {
|
|
88
|
-
// The /async command remains the fallback when Pi reserves this shortcut.
|
|
89
|
-
}
|
|
90
|
-
}
|
|
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;
|
|
91
199
|
}
|
|
92
200
|
|
|
93
201
|
export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: string): void {
|
|
@@ -96,18 +204,31 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
|
|
|
96
204
|
const ctxUi = (pi as { ui?: Record<string, unknown> }).ui ?? tuiHost()?.ui;
|
|
97
205
|
const host = {
|
|
98
206
|
ui: ctxUi as {
|
|
99
|
-
copyToClipboard?: (text: string) => void;
|
|
100
207
|
custom?: (factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value?: unknown) => void) => unknown, options: unknown) => Promise<unknown>;
|
|
101
208
|
notify?: (message: string, level?: string) => void;
|
|
102
209
|
setStatus?: (key: string, text: string | undefined) => void;
|
|
103
210
|
} | undefined,
|
|
104
211
|
};
|
|
105
212
|
if (!isTui(pi) || typeof host.ui?.custom !== "function") return;
|
|
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
|
+
};
|
|
106
223
|
const snapshots = (): TaskSnapshot[] => registry.list({ includeHistory: true }).filter((task) => survivesClearCutoff(task));
|
|
107
224
|
const custom = host.ui.custom;
|
|
108
|
-
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;
|
|
109
229
|
let selected = initialTaskId ?? snapshots()[0]?.taskId;
|
|
110
230
|
let detail = Boolean(initialTaskId);
|
|
231
|
+
const origin: "rail" | "list" = initialTaskId ? "rail" : "list";
|
|
111
232
|
let history = true;
|
|
112
233
|
let confirmKill = false;
|
|
113
234
|
let scroll = 0;
|
|
@@ -117,9 +238,45 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
|
|
|
117
238
|
let height = process.stdout.rows ?? 24;
|
|
118
239
|
let lastLines: string[] = [];
|
|
119
240
|
let actionError: string | undefined;
|
|
241
|
+
let closed = false;
|
|
242
|
+
let unsubscribe: (() => void) | undefined;
|
|
120
243
|
const requestRender = (): void => {
|
|
121
|
-
|
|
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
|
+
}
|
|
122
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
|
+
}
|
|
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
|
+
}
|
|
123
280
|
const filtered = (): TaskSnapshot[] => snapshots().filter((task) => history || task.status === "running" || task.status === "pending");
|
|
124
281
|
const choose = (): TaskSnapshot | undefined => snapshots().find((task) => task.taskId === selected);
|
|
125
282
|
const listBudget = (): number => Math.max(1, height - 3); // frame borders + hint row
|
|
@@ -132,47 +289,75 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
|
|
|
132
289
|
listScroll = Math.max(0, Math.min(listScroll, Math.max(0, tasks.length - budget)));
|
|
133
290
|
return listScroll;
|
|
134
291
|
};
|
|
135
|
-
const
|
|
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[] => {
|
|
136
317
|
const tasks = filtered();
|
|
137
318
|
if (!detail) {
|
|
138
319
|
const start = ensureListWindow(tasks);
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
const output = task.status === "running" ? registry.logs(task.taskId, { maxBytes: UI_TAIL_BYTES, tail: true }).text.split(/\r?\n/).filter(Boolean).at(-1) : undefined;
|
|
320
|
+
const rows = tasks.slice(start, start + Math.max(1, budget - 1)).map((task) => {
|
|
321
|
+
const output = task.status === "running" ? latestOutputLine(task, registry) : undefined;
|
|
142
322
|
return `${task.taskId === selected ? "▶" : " "} ${visible(task.name)} · ${task.status} · ${formatDuration(task, Date.now())}${output ? ` · ${visible(output)}` : ""}`;
|
|
143
323
|
});
|
|
144
324
|
return ["↑/↓ select · Enter details · h history · q close", ...rows];
|
|
145
325
|
}
|
|
146
326
|
const task = choose();
|
|
147
|
-
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`];
|
|
148
328
|
const output = readUiOutput(task, registry);
|
|
149
329
|
const outputLines = output.text.split(/\r?\n/).filter((line) => line.length > 0);
|
|
150
330
|
scroll = Math.min(scroll, Math.max(0, outputLines.length - PANEL_ROWS));
|
|
151
331
|
const tail = outputLines.slice(Math.max(0, outputLines.length - PANEL_ROWS - scroll), outputLines.length - scroll);
|
|
152
|
-
const
|
|
153
|
-
const commandLines = String(task.command ?? "").split(/\r?\n/);
|
|
332
|
+
const lineBudget = Math.max(1, Math.floor(width) - 4);
|
|
154
333
|
const killHint = task.status === "pending"
|
|
155
|
-
? "Cancel this pending task? press
|
|
156
|
-
: "Kill this task? press
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
...
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
...(
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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);
|
|
168
350
|
};
|
|
169
351
|
const render = (nextWidth?: number): string[] => {
|
|
170
|
-
width = nextWidth ??
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
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.
|
|
174
360
|
if (follow) scroll = 0;
|
|
175
|
-
else scroll = Math.min(scroll, maxScroll);
|
|
176
361
|
return frame(detail ? "Async task detail" : "Async tasks", lastLines, width, height);
|
|
177
362
|
};
|
|
178
363
|
const move = (delta: number): void => {
|
|
@@ -186,14 +371,34 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
|
|
|
186
371
|
else if (detail && delta > 0) { scroll = Math.max(0, scroll - delta); follow = scroll === 0; }
|
|
187
372
|
requestRender();
|
|
188
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
|
+
};
|
|
189
390
|
let latchedId: string | undefined;
|
|
190
391
|
return {
|
|
191
392
|
render,
|
|
192
393
|
handleInput(data: string): void {
|
|
193
394
|
const key = data.toLowerCase();
|
|
194
|
-
if (confirmKill &&
|
|
195
|
-
if (key === "q") {
|
|
196
|
-
if (isEscapeKey(data)) {
|
|
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
|
+
}
|
|
197
402
|
if (key === "h" && !detail) {
|
|
198
403
|
history = !history;
|
|
199
404
|
const tasks = filtered();
|
|
@@ -203,19 +408,35 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
|
|
|
203
408
|
return;
|
|
204
409
|
}
|
|
205
410
|
if (data === "\r" || data === "\n") { detail = true; scroll = 0; follow = true; actionError = undefined; requestRender(); return; }
|
|
206
|
-
|
|
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)) {
|
|
207
421
|
if (!confirmKill) { confirmKill = true; requestRender(); return; }
|
|
208
422
|
confirmKill = false;
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
423
|
+
killSelected();
|
|
424
|
+
requestRender();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
if (data === "\u001b[A" || key === "k") { move(-1); return; }
|
|
213
428
|
if (data === "\u001b[B" || key === "j") { move(1); return; }
|
|
214
429
|
if (data === "R" && detail) {
|
|
215
430
|
const task = choose();
|
|
216
431
|
if (task) {
|
|
217
432
|
try {
|
|
218
|
-
|
|
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
|
+
});
|
|
219
440
|
selected = created.taskId;
|
|
220
441
|
scroll = 0;
|
|
221
442
|
follow = true;
|
|
@@ -229,9 +450,10 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
|
|
|
229
450
|
}
|
|
230
451
|
if (data === "c" && detail) {
|
|
231
452
|
const path = choose()?.metadata.outputFile;
|
|
453
|
+
// §2.9: pi has no copyToClipboard API — show the path so the user can copy it.
|
|
232
454
|
if (typeof path === "string") {
|
|
233
|
-
|
|
234
|
-
|
|
455
|
+
host.ui?.notify?.(path, "info");
|
|
456
|
+
requestRender();
|
|
235
457
|
}
|
|
236
458
|
}
|
|
237
459
|
},
|
|
@@ -270,10 +492,22 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
|
|
|
270
492
|
requestRender();
|
|
271
493
|
return { handled: true };
|
|
272
494
|
},
|
|
273
|
-
dispose(): void {
|
|
495
|
+
dispose(): void { teardown(); },
|
|
274
496
|
};
|
|
275
497
|
};
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
|
|
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
|
+
}
|
|
279
513
|
}
|