pi-async 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +80 -2
- package/docs/CONTRACT.md +257 -0
- package/extensions/index.ts +107 -0
- package/package.json +54 -4
- package/src/core/bus.ts +28 -0
- package/src/core/notify.ts +41 -0
- package/src/core/output.ts +6 -0
- package/src/core/registry.ts +416 -0
- package/src/core/spawn.ts +98 -0
- package/src/core/task.ts +85 -0
- package/src/core/watch.ts +272 -0
- package/src/guard/settle-guard.ts +78 -0
- package/src/tools/index.ts +92 -0
- package/src/tui/host.ts +30 -0
- package/src/tui/panel.ts +218 -0
- package/src/tui/rail.ts +153 -0
- package/src/tui/status.ts +42 -0
package/src/tui/panel.ts
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import { openSync, closeSync, fstatSync, readSync } from "node:fs";
|
|
2
|
+
import type { PiCtx } from "../core/notify.ts";
|
|
3
|
+
import type { TaskRegistry } from "../core/registry.ts";
|
|
4
|
+
import type { TaskSnapshot } from "../core/task.ts";
|
|
5
|
+
import { stripAnsi } from "../core/output.ts";
|
|
6
|
+
import { clearDoneStatus } from "./status.ts";
|
|
7
|
+
import { tuiHost } from "./host.ts";
|
|
8
|
+
|
|
9
|
+
const isTui = (pi: unknown): boolean => {
|
|
10
|
+
// Event/command contexts carry mode; the factory API does not. Both paths must resolve.
|
|
11
|
+
const asCtx = pi as { mode?: string } | undefined;
|
|
12
|
+
return asCtx?.mode === "tui" || tuiHost()?.mode === "tui";
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const UI_TAIL_BYTES = 128 * 1024;
|
|
16
|
+
const PANEL_ROWS = 12;
|
|
17
|
+
let historyClearedAt = 0;
|
|
18
|
+
const visible = (value: unknown): string => stripAnsi(String(value ?? "")).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "").replace(/[\r\n\t]+/g, " ");
|
|
19
|
+
function readUiOutput(task: TaskSnapshot, registry: TaskRegistry): { text: string; truncated: boolean } {
|
|
20
|
+
const outputPath = task.metadata.outputFile;
|
|
21
|
+
if (typeof outputPath !== "string") return { text: registry.logs(task.taskId, { maxBytes: UI_TAIL_BYTES, tail: true }).text, truncated: false };
|
|
22
|
+
try {
|
|
23
|
+
const fd = openSync(outputPath, "r");
|
|
24
|
+
try {
|
|
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); }
|
|
31
|
+
} catch {
|
|
32
|
+
return { text: registry.logs(task.taskId, { maxBytes: UI_TAIL_BYTES, tail: true }).text, truncated: false };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const duration = (task: TaskSnapshot, now: number): string => {
|
|
37
|
+
const end = task.finishedAt ?? now;
|
|
38
|
+
const seconds = Math.floor(Math.max(0, end - (task.startedAt ?? task.createdAt)) / 1000);
|
|
39
|
+
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
40
|
+
};
|
|
41
|
+
const frame = (title: string, body: string[], width: number, height: number): string[] => {
|
|
42
|
+
const w = Math.max(8, Math.floor(width));
|
|
43
|
+
const h = Math.max(4, Math.floor(height));
|
|
44
|
+
const fit = (text: string): string => Array.from(text).slice(0, w - 4).join("").padEnd(w - 4);
|
|
45
|
+
const content = [`╭─ ${fit(title)}─╮`, ...body.slice(0, h - 2).map((line) => `│ ${fit(line)} │`), `╰${"─".repeat(w - 2)}╯`];
|
|
46
|
+
while (content.length < height) content.splice(content.length - 1, 0, `│${" ".repeat(w - 2)}│`);
|
|
47
|
+
// The solid background masks chat rows beneath the overlay, as in legion-viz's live overlay.
|
|
48
|
+
return content;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export function registerPanelCommands(pi: PiCtx, registry: TaskRegistry): void {
|
|
52
|
+
// Commands/shortcuts are factory-time APIs (they exist on the factory pi); TUI
|
|
53
|
+
// gating comes from the bound event context. Handlers receive a rich ctx with ui.
|
|
54
|
+
const bound = tuiHost();
|
|
55
|
+
const host = pi as unknown as {
|
|
56
|
+
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
|
+
};
|
|
59
|
+
if (!bound) return;
|
|
60
|
+
host.registerCommand?.("async", { description: "Open async task panel", handler: (_args, ctx) => openPanel(ctx, registry) });
|
|
61
|
+
host.registerCommand?.("async-clear", {
|
|
62
|
+
description: "Clear async task history",
|
|
63
|
+
handler: (_args, ctx) => {
|
|
64
|
+
if (!isTui(ctx)) return;
|
|
65
|
+
historyClearedAt = Date.now();
|
|
66
|
+
clearDoneStatus();
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
try {
|
|
70
|
+
host.registerShortcut?.(process.env.PI_ASYNC_DOCK_SHORTCUT ?? "ctrl+alt+a", { description: "Open async task panel", handler: (ctx) => openPanel(ctx, registry) });
|
|
71
|
+
} catch {
|
|
72
|
+
// The /async command remains the fallback when Pi reserves this shortcut.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: string): void {
|
|
77
|
+
// Event/command contexts carry a working ui; rail-click paths pass the factory
|
|
78
|
+
// pi, whose ui is undefined — fall back to the bound TUI host in that case.
|
|
79
|
+
const ctxUi = (pi as { ui?: Record<string, unknown> }).ui ?? tuiHost()?.ui;
|
|
80
|
+
const host = {
|
|
81
|
+
ui: ctxUi as {
|
|
82
|
+
copyToClipboard?: (text: string) => void;
|
|
83
|
+
custom?: (factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value?: unknown) => void) => unknown, options: unknown) => Promise<unknown>;
|
|
84
|
+
notify?: (message: string, level?: string) => void;
|
|
85
|
+
setStatus?: (key: string, text: string | undefined) => void;
|
|
86
|
+
} | undefined,
|
|
87
|
+
};
|
|
88
|
+
if (!isTui(pi) || typeof host.ui?.custom !== "function") return;
|
|
89
|
+
const snapshots = (): TaskSnapshot[] => registry.list({ includeHistory: true }).filter((task) => task.createdAt >= historyClearedAt || task.status === "running" || task.status === "pending");
|
|
90
|
+
const custom = host.ui.custom;
|
|
91
|
+
const factory = (tui: unknown, _theme: unknown, _keybindings: unknown, done: (value?: unknown) => void) => {
|
|
92
|
+
let selected = initialTaskId ?? snapshots()[0]?.taskId;
|
|
93
|
+
let detail = Boolean(initialTaskId);
|
|
94
|
+
let history = true;
|
|
95
|
+
let confirmKill = false;
|
|
96
|
+
let scroll = 0;
|
|
97
|
+
let follow = true;
|
|
98
|
+
let width = 100;
|
|
99
|
+
let height = process.stdout.rows ?? 24;
|
|
100
|
+
let lastLines: string[] = [];
|
|
101
|
+
const requestRender = (): void => {
|
|
102
|
+
(tui as { requestRender?: () => void } | undefined)?.requestRender?.();
|
|
103
|
+
};
|
|
104
|
+
const choose = (): TaskSnapshot | undefined => snapshots().find((task) => task.taskId === selected);
|
|
105
|
+
const renderBody = (): string[] => {
|
|
106
|
+
const tasks = snapshots().filter((task) => history || task.status === "running" || task.status === "pending");
|
|
107
|
+
if (!detail) {
|
|
108
|
+
return ["↑/↓ select · Enter details · h history · q close", ...tasks.map((task) => {
|
|
109
|
+
const output = task.status === "running" ? registry.logs(task.taskId, { maxBytes: UI_TAIL_BYTES, tail: true }).text.split(/\r?\n/).filter(Boolean).at(-1) : undefined;
|
|
110
|
+
return `${task.taskId === selected ? "▶" : " "} ${visible(task.name)} · ${task.status} · ${duration(task, Date.now())}${output ? ` · ${visible(output)}` : ""}`;
|
|
111
|
+
})];
|
|
112
|
+
}
|
|
113
|
+
const task = choose();
|
|
114
|
+
if (!task) return ["Task not found", "Esc return · q close"];
|
|
115
|
+
const output = readUiOutput(task, registry);
|
|
116
|
+
const outputLines = output.text.split(/\r?\n/).filter((line) => line.length > 0);
|
|
117
|
+
scroll = Math.min(scroll, Math.max(0, outputLines.length - PANEL_ROWS));
|
|
118
|
+
const tail = outputLines.slice(Math.max(0, outputLines.length - PANEL_ROWS - scroll), outputLines.length - scroll);
|
|
119
|
+
const meta = JSON.stringify(task.metadata);
|
|
120
|
+
return [
|
|
121
|
+
`${visible(task.name)} [${task.taskId}]`, `status: ${task.status} · elapsed: ${duration(task, Date.now())}`,
|
|
122
|
+
`command: ${visible(task.command)}`, `metadata: ${meta}`, `output${output.truncated ? " (tail truncated)" : ""}:`,
|
|
123
|
+
// registry.logs caps its own data at 50 KiB; the TUI intentionally reads a larger 128 KiB tail directly from the output file.
|
|
124
|
+
...tail.map(visible), ...(confirmKill ? ["Kill this task? press k again to confirm; any other key cancels"] : []),
|
|
125
|
+
"Esc return · k kill · R rerun · c copy output path · ↑/↓ scroll",
|
|
126
|
+
];
|
|
127
|
+
};
|
|
128
|
+
const render = (nextWidth?: number): string[] => {
|
|
129
|
+
width = nextWidth ?? width;
|
|
130
|
+
height = process.stdout.rows ?? height;
|
|
131
|
+
lastLines = renderBody();
|
|
132
|
+
const maxScroll = Math.max(0, lastLines.length - PANEL_ROWS);
|
|
133
|
+
if (follow) scroll = 0;
|
|
134
|
+
else scroll = Math.min(scroll, maxScroll);
|
|
135
|
+
return frame(detail ? "Async task detail" : "Async tasks", lastLines, width, height);
|
|
136
|
+
};
|
|
137
|
+
const move = (delta: number): void => {
|
|
138
|
+
const tasks = snapshots().filter((task) => history || task.status === "running" || task.status === "pending");
|
|
139
|
+
const index = tasks.findIndex((task) => task.taskId === selected);
|
|
140
|
+
const next = tasks[Math.max(0, Math.min(tasks.length - 1, index + delta))];
|
|
141
|
+
if (next) selected = next.taskId;
|
|
142
|
+
if (detail && delta < 0) { scroll += -delta; follow = false; }
|
|
143
|
+
else if (detail && delta > 0) { scroll = Math.max(0, scroll - delta); follow = scroll === 0; }
|
|
144
|
+
requestRender();
|
|
145
|
+
};
|
|
146
|
+
let latchedId: string | undefined;
|
|
147
|
+
return {
|
|
148
|
+
render,
|
|
149
|
+
handleInput(data: string): void {
|
|
150
|
+
const key = data.toLowerCase();
|
|
151
|
+
if (confirmKill && key !== "k") { confirmKill = false; requestRender(); return; }
|
|
152
|
+
if (key === "q") { done(undefined); return; }
|
|
153
|
+
if (data === "\u001b") { if (detail) { detail = false; confirmKill = false; requestRender(); } else done(undefined); return; }
|
|
154
|
+
if (key === "h" && !detail) { history = !history; requestRender(); return; }
|
|
155
|
+
if (data === "\r" || data === "\n") { detail = true; scroll = 0; follow = true; requestRender(); return; }
|
|
156
|
+
if (data === "\u001b[A" || data === "k") { if (detail && key === "k") {
|
|
157
|
+
if (!confirmKill) { confirmKill = true; requestRender(); return; }
|
|
158
|
+
confirmKill = false;
|
|
159
|
+
const task = choose();
|
|
160
|
+
if (task?.status === "running") void registry.kill(task.taskId, "user");
|
|
161
|
+
requestRender(); return;
|
|
162
|
+
} move(-1); return; }
|
|
163
|
+
if (data === "\u001b[B" || key === "j") { move(1); return; }
|
|
164
|
+
if (data === "R" && detail) {
|
|
165
|
+
const task = choose();
|
|
166
|
+
if (task) {
|
|
167
|
+
try { registry.submit({ command: task.command, name: task.name, mode: "background" }); }
|
|
168
|
+
catch (error) { host.ui?.notify?.(String(error), "error"); }
|
|
169
|
+
}
|
|
170
|
+
requestRender(); return;
|
|
171
|
+
}
|
|
172
|
+
if (data === "c" && detail) {
|
|
173
|
+
const path = choose()?.metadata.outputFile;
|
|
174
|
+
if (typeof path === "string") {
|
|
175
|
+
if (host.ui?.copyToClipboard) host.ui.copyToClipboard(path);
|
|
176
|
+
else host.ui?.notify?.(path, "info");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
handleMouse(event: { type: string; y?: number; button?: string; wheelDelta?: number }): { handled: boolean } | undefined {
|
|
181
|
+
if (event.type === "wheel") {
|
|
182
|
+
const delta = Math.trunc(event.wheelDelta ?? 0);
|
|
183
|
+
if (detail) { scroll = Math.max(0, scroll + delta); follow = scroll === 0; }
|
|
184
|
+
else move(delta);
|
|
185
|
+
requestRender();
|
|
186
|
+
return { handled: true };
|
|
187
|
+
}
|
|
188
|
+
if (event.type === "press") {
|
|
189
|
+
latchedId = undefined;
|
|
190
|
+
if (detail) return undefined;
|
|
191
|
+
if (event.button !== "left") return undefined;
|
|
192
|
+
const index = (event.y ?? -1) - 2;
|
|
193
|
+
const tasks = snapshots().filter((task) => history || task.status === "running" || task.status === "pending");
|
|
194
|
+
const task = tasks[index];
|
|
195
|
+
if (!task) return undefined;
|
|
196
|
+
latchedId = task.taskId;
|
|
197
|
+
return { handled: true };
|
|
198
|
+
}
|
|
199
|
+
if (event.type !== "click" || event.button !== "left" || detail) return undefined;
|
|
200
|
+
const index = (event.y ?? -1) - 2;
|
|
201
|
+
const tasks = snapshots().filter((task) => history || task.status === "running" || task.status === "pending");
|
|
202
|
+
const task = tasks[index];
|
|
203
|
+
if (!latchedId || !task || task.taskId !== latchedId) { latchedId = undefined; return undefined; }
|
|
204
|
+
selected = latchedId;
|
|
205
|
+
detail = true;
|
|
206
|
+
scroll = 0;
|
|
207
|
+
follow = true;
|
|
208
|
+
latchedId = undefined;
|
|
209
|
+
requestRender();
|
|
210
|
+
return { handled: true };
|
|
211
|
+
},
|
|
212
|
+
dispose(): void { void width; },
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
// Required full-screen overlay geometry. custom() resolves undefined outside supported TUI contexts.
|
|
216
|
+
// The RPC peer's custom() returns a Promise even though the overlay is unavailable there; mode guards above prevent invocation.
|
|
217
|
+
void custom(factory, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left", row: 0, col: 0 } }).catch(() => undefined);
|
|
218
|
+
}
|
package/src/tui/rail.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { PiCtx } from "../core/notify.ts";
|
|
2
|
+
import type { TaskRegistry } from "../core/registry.ts";
|
|
3
|
+
import type { TaskSnapshot } from "../core/task.ts";
|
|
4
|
+
import type { TUI, TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
5
|
+
import { openPanel } from "./panel.ts";
|
|
6
|
+
import { tuiHost } from "./host.ts";
|
|
7
|
+
|
|
8
|
+
type Theme = { fg(color: string, text: string): string; getFgAnsi?(color: string): string };
|
|
9
|
+
export interface RailWidget {
|
|
10
|
+
render(width?: number): string[];
|
|
11
|
+
handleMouse(event: TuiMouseEvent): { handled: boolean } | undefined;
|
|
12
|
+
dispose?(): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Palette color selection adapted from legion-viz/palette.ts (MIT License). */
|
|
16
|
+
const TYPE_TOKENS = ["syntaxVariable", "syntaxKeyword", "syntaxNumber", "syntaxFunction", "syntaxString", "syntaxType"];
|
|
17
|
+
type Swatch = { kind: "rgb"; l: number; a: number; b: number } | { kind: "key"; key: string };
|
|
18
|
+
function ansiFor(theme: Theme, token: string): string {
|
|
19
|
+
// SAFETY: The peer theme exposes fg(); getFgAnsi is feature-detected for newer TUI versions.
|
|
20
|
+
const color = theme as Theme;
|
|
21
|
+
try { return color.getFgAnsi?.(token) ?? color.fg(token, "\0").split("\0")[0] ?? token; } catch { return token; }
|
|
22
|
+
}
|
|
23
|
+
function oklab(r: number, g: number, b: number): Swatch {
|
|
24
|
+
const linear = (n: number): number => { const v = n / 255; return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
|
|
25
|
+
const R = linear(r), G = linear(g), B = linear(b);
|
|
26
|
+
const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B);
|
|
27
|
+
const m = Math.cbrt(0.2119034708 * R + 0.6806995451 * G + 0.1073969566 * B);
|
|
28
|
+
const s = Math.cbrt(0.0883024619 * R + 0.281718565 * G + 0.6299787005 * B);
|
|
29
|
+
return { kind: "rgb", l: 0.2104542553*l + 0.793617785*m - 0.0040720468*s, a: 1.9779984951*l - 2.428592205*m + 0.4505937099*s, b: 0.0259040371*l + 0.7827717662*m - 0.808675766*s };
|
|
30
|
+
}
|
|
31
|
+
function swatch(theme: Theme, token: string): Swatch {
|
|
32
|
+
const ansi = ansiFor(theme, token);
|
|
33
|
+
const rgb = /38;2;(\d+);(\d+);(\d+)/.exec(ansi);
|
|
34
|
+
if (rgb) return oklab(Number(rgb[1]), Number(rgb[2]), Number(rgb[3]));
|
|
35
|
+
const index = /38;5;(\d+)/.exec(ansi);
|
|
36
|
+
if (index) {
|
|
37
|
+
const n = Number(index[1]);
|
|
38
|
+
if (n >= 232) { const v = 8 + 10 * (n - 232); return oklab(v, v, v); }
|
|
39
|
+
if (n >= 16) { const i = n - 16, step = [0, 95, 135, 175, 215, 255]; return oklab(step[Math.floor(i / 36)]!, step[Math.floor(i / 6) % 6]!, step[i % 6]!); }
|
|
40
|
+
return { kind: "key", key: `p${n}` };
|
|
41
|
+
}
|
|
42
|
+
const basic = /(?:^|\[|;)(3[0-7]|9[0-7])m/.exec(ansi);
|
|
43
|
+
return { kind: "key", key: basic?.[1] ?? ansi };
|
|
44
|
+
}
|
|
45
|
+
function clashes(a: Swatch, b: Swatch): boolean {
|
|
46
|
+
if (a.kind === "key" && b.kind === "key") return a.key === b.key;
|
|
47
|
+
if (a.kind === "key" || b.kind === "key") return false;
|
|
48
|
+
return Math.hypot(a.l - b.l, a.a - b.a, a.b - b.b) < 0.025;
|
|
49
|
+
}
|
|
50
|
+
function typeColor(theme: Theme, mode: string, used: Swatch[]): string {
|
|
51
|
+
const modeIndex = ["background", "sync", "delayed"].indexOf(mode);
|
|
52
|
+
const preferred = TYPE_TOKENS[modeIndex < 0 ? 0 : modeIndex]!;
|
|
53
|
+
for (const token of [preferred, ...TYPE_TOKENS]) {
|
|
54
|
+
const color = swatch(theme, token);
|
|
55
|
+
if (!used.some((previous) => clashes(color, previous))) { used.push(color); return token; }
|
|
56
|
+
}
|
|
57
|
+
return preferred;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const statePriority: Record<TaskSnapshot["status"], number> = { running: 0, pending: 1, completed: 2, failed: 3, killed: 4 };
|
|
61
|
+
function stripAnsi(value: string): string {
|
|
62
|
+
return value.replace(/\u001b(?:\][\s\S]*?(?:\u0007|\u001b\\|\u009c)|\[[0-?]*[ -/]*[@-~]|[PX^_][\s\S]*?\u001b\\|[ -/]*[0-~])|\u009b[0-?]*[ -/]*[@-~]|\u009d[\s\S]*?(?:\u0007|\u001b\\|\u009c)/g, "");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function elapsed(snapshot: TaskSnapshot, now: number): string {
|
|
66
|
+
const seconds = Math.floor(Math.max(0, now - (snapshot.startedAt ?? snapshot.createdAt)) / 1000);
|
|
67
|
+
if (seconds < 60) return `${seconds}s`;
|
|
68
|
+
const minutes = Math.floor(seconds / 60);
|
|
69
|
+
return minutes < 60 ? `${minutes}m ${seconds % 60}s` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Pure, unstyled rail rows; active tasks first and a hard ten-row limit. */
|
|
73
|
+
export function renderRailRows(snapshots: TaskSnapshot[], width: number, now = Date.now()): string[] {
|
|
74
|
+
const rows = snapshots.filter((task) => task.status !== "pending")
|
|
75
|
+
.slice().sort((a, b) => statePriority[a.status] - statePriority[b.status] || (b.startedAt ?? b.createdAt) - (a.startedAt ?? a.createdAt))
|
|
76
|
+
.slice(0, 10)
|
|
77
|
+
.map((task) => `▶ ${stripAnsi(task.name).replace(/[\r\n]+/g, " ")} · ${task.status} · ${elapsed(task, now)}`);
|
|
78
|
+
const limit = Math.max(0, Math.floor(width));
|
|
79
|
+
return rows.map((row) => Array.from(row).length > limit ? `${Array.from(row).slice(0, Math.max(0, limit - 1)).join("")}…` : row);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Terminal-task linger window on the rail, overridable for tests. */
|
|
83
|
+
function lingerMsFromEnv(): number {
|
|
84
|
+
const raw = Number(process.env.PI_ASYNC_RAIL_LINGER_MS ?? 30_000);
|
|
85
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : 30_000;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createRailFactory(pi: PiCtx, registry: TaskRegistry, opts: { onOpenTask?: (taskId: string) => void } = {}): (tui: TUI, theme: Theme) => RailWidget {
|
|
89
|
+
// Factory signature includes the extension context for future host-specific rail integrations.
|
|
90
|
+
void pi;
|
|
91
|
+
return (tui, theme) => {
|
|
92
|
+
let scroll = 0;
|
|
93
|
+
let hitRows: string[] = [];
|
|
94
|
+
let latchedId: string | undefined;
|
|
95
|
+
// Terminal tasks linger briefly so the user sees the outcome, then drop off
|
|
96
|
+
// the rail (CONTRACT §8: idle sessions render nothing). Live tasks always show.
|
|
97
|
+
const lingerMs = lingerMsFromEnv();
|
|
98
|
+
const ordered = (now = Date.now()): TaskSnapshot[] => registry.list({ includeHistory: true })
|
|
99
|
+
.filter((task) => task.status === "running" || task.status === "pending"
|
|
100
|
+
|| (now - (task.finishedAt ?? task.startedAt ?? task.createdAt)) < lingerMs)
|
|
101
|
+
.sort((a, b) => statePriority[a.status] - statePriority[b.status] || (b.startedAt ?? b.createdAt) - (a.startedAt ?? b.createdAt));
|
|
102
|
+
const render = (width = 120): string[] => {
|
|
103
|
+
const tasks = ordered();
|
|
104
|
+
scroll = Math.max(0, Math.min(scroll, Math.max(0, tasks.length - 10)));
|
|
105
|
+
const visible = tasks.slice(scroll, scroll + 10);
|
|
106
|
+
hitRows = visible.map((task) => task.taskId);
|
|
107
|
+
const used = [swatch(theme, "error"), swatch(theme, "dim")];
|
|
108
|
+
return renderRailRows(visible, width).map((row, index) => {
|
|
109
|
+
const task = visible[index];
|
|
110
|
+
if (!task) return row;
|
|
111
|
+
let token = "dim";
|
|
112
|
+
if (task.status === "running") token = typeColor(theme, task.mode, used);
|
|
113
|
+
else if (task.status === "completed") token = "success";
|
|
114
|
+
else if (task.status === "failed") token = "error";
|
|
115
|
+
return theme.fg(token, row);
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
return {
|
|
119
|
+
render,
|
|
120
|
+
handleMouse(event: TuiMouseEvent): { handled: boolean } | undefined {
|
|
121
|
+
const mouse = event as TuiMouseEvent & { type: string; y: number; button?: string; wheelDelta?: number };
|
|
122
|
+
if (mouse.type === "wheel") {
|
|
123
|
+
const count = ordered().length;
|
|
124
|
+
if (count <= 10) return undefined;
|
|
125
|
+
const next = Math.max(0, Math.min(count - 10, scroll + Math.trunc(mouse.wheelDelta ?? 0)));
|
|
126
|
+
if (next === scroll) return undefined;
|
|
127
|
+
scroll = next; latchedId = undefined; tui.requestRender(); return { handled: true };
|
|
128
|
+
}
|
|
129
|
+
if (mouse.type === "press") {
|
|
130
|
+
latchedId = undefined;
|
|
131
|
+
if (mouse.button !== "left") return undefined;
|
|
132
|
+
const id = hitRows[mouse.y];
|
|
133
|
+
if (!id) return undefined;
|
|
134
|
+
latchedId = id; return { handled: true };
|
|
135
|
+
}
|
|
136
|
+
if (mouse.type !== "click" || mouse.button !== "left") return undefined;
|
|
137
|
+
const id = hitRows[mouse.y];
|
|
138
|
+
if (!latchedId || id !== latchedId) { latchedId = undefined; return undefined; }
|
|
139
|
+
const clicked = latchedId; latchedId = undefined; opts.onOpenTask?.(clicked); return { handled: true };
|
|
140
|
+
},
|
|
141
|
+
dispose(): void { latchedId = undefined; },
|
|
142
|
+
};
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** The sole rail widget registration point. */
|
|
147
|
+
export function registerRail(pi: PiCtx, registry: TaskRegistry): void {
|
|
148
|
+
// TUI surfaces bind from event contexts; the factory API has no mode/ui.
|
|
149
|
+
const bound = tuiHost();
|
|
150
|
+
if (!bound) return;
|
|
151
|
+
const setWidget = bound.ui["setWidget"] as ((key: string, factory: ReturnType<typeof createRailFactory> | undefined, options?: { placement: "aboveEditor" }) => void) | undefined;
|
|
152
|
+
setWidget?.("pi-async-rail", createRailFactory(pi, registry, { onOpenTask: (id) => openPanel(pi, registry, id) }), { placement: "aboveEditor" });
|
|
153
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { PiCtx } from "../core/notify.ts";
|
|
2
|
+
import type { TaskRegistry } from "../core/registry.ts";
|
|
3
|
+
import { reportActivity } from "../core/bus.ts";
|
|
4
|
+
import { tuiHost } from "./host.ts";
|
|
5
|
+
|
|
6
|
+
export function formatStatus(active: { running: number; pending: number }, done: number): string {
|
|
7
|
+
return ` ${active.running + active.pending} running · ${done} done `;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let clearedAt = 0;
|
|
11
|
+
let refreshCurrent: (() => void) | undefined;
|
|
12
|
+
let refreshInterval: ReturnType<typeof setInterval> | undefined;
|
|
13
|
+
|
|
14
|
+
export function disposeStatus(): void {
|
|
15
|
+
if (refreshInterval) clearInterval(refreshInterval);
|
|
16
|
+
refreshInterval = undefined;
|
|
17
|
+
refreshCurrent = undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function clearDoneStatus(): void {
|
|
21
|
+
clearedAt = Date.now();
|
|
22
|
+
refreshCurrent?.();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function initStatus(pi: PiCtx, registry: TaskRegistry): void {
|
|
26
|
+
disposeStatus();
|
|
27
|
+
// TUI surfaces bind from event contexts; the factory API has no mode/ui.
|
|
28
|
+
const bound = tuiHost();
|
|
29
|
+
if (!bound) return;
|
|
30
|
+
const setStatus = bound.ui["setStatus"] as ((key: string, text: string | undefined) => void) | undefined;
|
|
31
|
+
const refresh = (): void => {
|
|
32
|
+
const active = registry.activeCount();
|
|
33
|
+
const done = registry.list({ includeHistory: true }).filter((task) => task.status === "completed" && (task.finishedAt ?? task.createdAt) >= clearedAt).length;
|
|
34
|
+
setStatus?.("pi-async", formatStatus(active, done));
|
|
35
|
+
reportActivity(pi, active);
|
|
36
|
+
};
|
|
37
|
+
refreshCurrent = refresh;
|
|
38
|
+
refresh();
|
|
39
|
+
// Registry intentionally exposes no event subscription API; keep the footer current without replacing its core events.
|
|
40
|
+
refreshInterval = setInterval(refresh, 1_000);
|
|
41
|
+
refreshInterval.unref?.();
|
|
42
|
+
}
|