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/rail.ts
CHANGED
|
@@ -3,8 +3,8 @@ import type { TaskRegistry } from "../core/registry.ts";
|
|
|
3
3
|
import type { TaskSnapshot } from "../core/task.ts";
|
|
4
4
|
import { visibleText } from "../core/output.ts";
|
|
5
5
|
import { formatDuration } from "../core/duration.ts";
|
|
6
|
-
import {
|
|
7
|
-
import { truncateToWidth } from "./width.ts";
|
|
6
|
+
import { inRailWindow, railLingerMs, railWindowCounts, summarySegments, type SummaryCounts } from "./status.ts";
|
|
7
|
+
import { displayWidth, truncateToWidth } from "./width.ts";
|
|
8
8
|
import type { TUI, TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
9
9
|
import { openPanel } from "./panel.ts";
|
|
10
10
|
import { tuiHost } from "./host.ts";
|
|
@@ -13,7 +13,8 @@ type Theme = { fg(color: string, text: string): string; getFgAnsi?(color: string
|
|
|
13
13
|
export interface RailWidget {
|
|
14
14
|
render(width?: number): string[];
|
|
15
15
|
handleMouse(event: TuiMouseEvent): { handled: boolean } | undefined;
|
|
16
|
-
|
|
16
|
+
// 0.3.0 §2.9: the rail has no keyboard scrolling (it never receives focus), so
|
|
17
|
+
// `handleInput` / `RAIL_PAGE` were removed. Mouse wheel + click remain.
|
|
17
18
|
dispose?(): void;
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -71,61 +72,90 @@ export function fitRailRow(row: string, width: number): string {
|
|
|
71
72
|
return truncateToWidth(row, Math.max(0, Math.floor(width)));
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
|
|
75
|
+
export const RAIL_MAX_LINES = 4;
|
|
76
|
+
export const RAIL_TASK_SLOTS = 3;
|
|
77
|
+
export const RAIL_TASK_SLOTS_OVERFLOW = 2;
|
|
78
|
+
|
|
79
|
+
/** Pure, unstyled rail rows; active tasks first and a hard three-row limit. */
|
|
75
80
|
export function renderRailRows(snapshots: TaskSnapshot[], width: number, now = Date.now()): string[] {
|
|
76
81
|
const rows = snapshots
|
|
77
82
|
.slice().sort((a, b) => statePriority[a.status] - statePriority[b.status] || (b.startedAt ?? b.createdAt) - (a.startedAt ?? a.createdAt))
|
|
78
|
-
.slice(0,
|
|
83
|
+
.slice(0, RAIL_TASK_SLOTS)
|
|
79
84
|
.map((task) => `▶ ${visibleText(task.name)} · ${task.status} · ${formatDuration(task, now)}`);
|
|
80
85
|
const limit = Math.max(0, Math.floor(width));
|
|
81
86
|
return rows.map((row) => truncateToWidth(row, limit));
|
|
82
87
|
}
|
|
83
88
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
89
|
+
let railTicker: ReturnType<typeof setInterval> | undefined;
|
|
90
|
+
|
|
91
|
+
function coloredSummary(counts: SummaryCounts, now: number, width: number, theme: Theme): string {
|
|
92
|
+
const plainSegments = summarySegments(counts, now);
|
|
93
|
+
let kept = plainSegments.map((segment) => ({ ...segment }));
|
|
94
|
+
const plain = () => kept.map((segment) => segment.text).join("");
|
|
95
|
+
const removableSegments = [
|
|
96
|
+
(segment: SummarySegmentLike) => segment.token === "error",
|
|
97
|
+
(segment: SummarySegmentLike) => segment.token === "success",
|
|
98
|
+
(segment: SummarySegmentLike) => segment.text.startsWith(" · ") && segment.token === "dim" && !segment.text.includes("pending"),
|
|
99
|
+
(segment: SummarySegmentLike) => segment.text.includes(" pending"),
|
|
100
|
+
];
|
|
101
|
+
while (displayWidth(plain()) > Math.max(0, Math.floor(width))) {
|
|
102
|
+
const predicate = removableSegments.find((remove) => kept.some(remove));
|
|
103
|
+
if (!predicate) break;
|
|
104
|
+
kept = kept.filter((segment) => !predicate(segment));
|
|
105
|
+
}
|
|
106
|
+
const truncated = truncateToWidth(plain(), Math.max(0, Math.floor(width)));
|
|
107
|
+
let offset = 0;
|
|
108
|
+
return kept.map((segment) => {
|
|
109
|
+
const text = truncated.slice(offset, offset + segment.text.length);
|
|
110
|
+
offset += segment.text.length;
|
|
111
|
+
if (!text) return "";
|
|
112
|
+
try { return theme.fg(segment.token, text) ?? text; } catch { return text; }
|
|
113
|
+
}).join("");
|
|
87
114
|
}
|
|
88
115
|
|
|
89
|
-
|
|
116
|
+
type SummarySegmentLike = { text: string; token: string };
|
|
90
117
|
|
|
91
118
|
export function createRailFactory(pi: PiCtx, registry: TaskRegistry, opts: { onOpenTask?: (taskId: string) => void } = {}): (tui: TUI, theme: Theme) => RailWidget {
|
|
92
119
|
// Factory signature includes the extension context for future host-specific rail integrations.
|
|
93
120
|
void pi;
|
|
94
121
|
return (tui, theme) => {
|
|
122
|
+
if (railTicker) clearInterval(railTicker);
|
|
123
|
+
const ticker = setInterval(() => {
|
|
124
|
+
// A disposed/failing host must not crash the timer (same class as the retired heartbeat).
|
|
125
|
+
try { tui.requestRender(); } catch { /* repaint is best-effort */ }
|
|
126
|
+
}, 1_000);
|
|
127
|
+
railTicker = ticker;
|
|
128
|
+
ticker.unref?.();
|
|
95
129
|
let scroll = 0;
|
|
96
130
|
let hitRows: string[] = [];
|
|
97
131
|
let latchedId: string | undefined;
|
|
98
132
|
// Terminal tasks linger briefly so the user sees the outcome, then drop off
|
|
99
133
|
// the rail (CONTRACT §8: idle sessions render nothing). Live tasks always show.
|
|
100
|
-
const lingerMs =
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
134
|
+
const lingerMs = railLingerMs();
|
|
135
|
+
// Rows and summary counts are drawn from one snapshot + one window so `✓N`
|
|
136
|
+
// always has a matching row (0.3.0 §2.9).
|
|
137
|
+
const listAll = (): TaskSnapshot[] => {
|
|
138
|
+
try { return registry.list({ includeHistory: true }); } catch { return []; }
|
|
139
|
+
};
|
|
140
|
+
const ordered = (all: TaskSnapshot[], now = Date.now()): TaskSnapshot[] => all
|
|
141
|
+
.filter((task) => inRailWindow(task, now, lingerMs))
|
|
104
142
|
.sort((a, b) => statePriority[a.status] - statePriority[b.status] || (b.startedAt ?? b.createdAt) - (a.startedAt ?? a.createdAt));
|
|
105
143
|
const clampScroll = (count: number, taskSlots: number): void => {
|
|
106
144
|
scroll = Math.max(0, Math.min(scroll, Math.max(0, count - taskSlots)));
|
|
107
145
|
};
|
|
108
|
-
const applyScroll = (delta: number): boolean => {
|
|
109
|
-
const count = ordered().length;
|
|
110
|
-
if (count <= 10) return false;
|
|
111
|
-
const taskSlots = 9; // reserve one row for the overflow indicator
|
|
112
|
-
const next = Math.max(0, Math.min(count - taskSlots, scroll + delta));
|
|
113
|
-
if (next === scroll) return false;
|
|
114
|
-
scroll = next;
|
|
115
|
-
latchedId = undefined;
|
|
116
|
-
tui.requestRender();
|
|
117
|
-
return true;
|
|
118
|
-
};
|
|
119
146
|
const render = (width = 120): string[] => {
|
|
120
|
-
const
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
const all = listAll();
|
|
149
|
+
const tasks = ordered(all, now);
|
|
150
|
+
if (tasks.length === 0) { hitRows = []; return []; }
|
|
151
|
+
const overflow = tasks.length > RAIL_TASK_SLOTS;
|
|
152
|
+
const taskSlots = overflow ? RAIL_TASK_SLOTS_OVERFLOW : RAIL_TASK_SLOTS;
|
|
124
153
|
clampScroll(tasks.length, taskSlots);
|
|
125
154
|
const visible = tasks.slice(scroll, scroll + taskSlots);
|
|
126
|
-
|
|
155
|
+
const counts = railWindowCounts(all, now, lingerMs);
|
|
156
|
+
hitRows = ["", ...visible.map((task) => task.taskId)];
|
|
127
157
|
const used = [swatch(theme, "error"), swatch(theme, "dim")];
|
|
128
|
-
const rows = renderRailRows(visible, width).map((row, index) => {
|
|
158
|
+
const rows = [coloredSummary(counts, now, width, theme), ...renderRailRows(visible, width).map((row, index) => {
|
|
129
159
|
const task = visible[index];
|
|
130
160
|
if (!task) return row;
|
|
131
161
|
let token = "dim";
|
|
@@ -137,7 +167,7 @@ export function createRailFactory(pi: PiCtx, registry: TaskRegistry, opts: { onO
|
|
|
137
167
|
} catch {
|
|
138
168
|
return row;
|
|
139
169
|
}
|
|
140
|
-
});
|
|
170
|
+
})];
|
|
141
171
|
if (overflow) {
|
|
142
172
|
const hidden = tasks.length - visible.length;
|
|
143
173
|
const above = scroll;
|
|
@@ -155,26 +185,21 @@ export function createRailFactory(pi: PiCtx, registry: TaskRegistry, opts: { onO
|
|
|
155
185
|
}
|
|
156
186
|
hitRows.push(""); // overflow row is not a task hit target
|
|
157
187
|
}
|
|
188
|
+
// Bind the ≤4 invariant to RAIL_MAX_LINES so retuning the slot constants cannot silently break it.
|
|
189
|
+
if (rows.length > RAIL_MAX_LINES) {
|
|
190
|
+
rows.length = RAIL_MAX_LINES;
|
|
191
|
+
hitRows.length = RAIL_MAX_LINES;
|
|
192
|
+
}
|
|
158
193
|
return rows;
|
|
159
194
|
};
|
|
160
195
|
return {
|
|
161
196
|
render,
|
|
162
|
-
handleInput(data: string): { handled?: boolean } | void {
|
|
163
|
-
// PageUp / PageDown, and Ctrl+↑ / Ctrl+↓ when the rail has focus.
|
|
164
|
-
if (data === "\u001b[5~" || data === "\u001b[1;5A") {
|
|
165
|
-
return applyScroll(-RAIL_PAGE) ? { handled: true } : undefined;
|
|
166
|
-
}
|
|
167
|
-
if (data === "\u001b[6~" || data === "\u001b[1;5B") {
|
|
168
|
-
return applyScroll(RAIL_PAGE) ? { handled: true } : undefined;
|
|
169
|
-
}
|
|
170
|
-
return undefined;
|
|
171
|
-
},
|
|
172
197
|
handleMouse(event: TuiMouseEvent): { handled: boolean } | undefined {
|
|
173
198
|
const mouse = event as TuiMouseEvent & { type: string; y: number; button?: string; wheelDelta?: number };
|
|
174
199
|
if (mouse.type === "wheel") {
|
|
175
|
-
const count = ordered().length;
|
|
176
|
-
if (count <=
|
|
177
|
-
const taskSlots =
|
|
200
|
+
const count = ordered(listAll()).length;
|
|
201
|
+
if (count <= RAIL_TASK_SLOTS) return undefined;
|
|
202
|
+
const taskSlots = RAIL_TASK_SLOTS_OVERFLOW;
|
|
178
203
|
const next = Math.max(0, Math.min(count - taskSlots, scroll + Math.trunc(mouse.wheelDelta ?? 0)));
|
|
179
204
|
if (next === scroll) return undefined;
|
|
180
205
|
scroll = next; latchedId = undefined; tui.requestRender(); return { handled: true };
|
|
@@ -191,7 +216,13 @@ export function createRailFactory(pi: PiCtx, registry: TaskRegistry, opts: { onO
|
|
|
191
216
|
if (!latchedId || id !== latchedId) { latchedId = undefined; return undefined; }
|
|
192
217
|
const clicked = latchedId; latchedId = undefined; opts.onOpenTask?.(clicked); return { handled: true };
|
|
193
218
|
},
|
|
194
|
-
dispose(): void {
|
|
219
|
+
dispose(): void {
|
|
220
|
+
latchedId = undefined;
|
|
221
|
+
if (railTicker === ticker) {
|
|
222
|
+
clearInterval(ticker);
|
|
223
|
+
railTicker = undefined;
|
|
224
|
+
}
|
|
225
|
+
},
|
|
195
226
|
};
|
|
196
227
|
};
|
|
197
228
|
}
|
package/src/tui/status.ts
CHANGED
|
@@ -1,19 +1,42 @@
|
|
|
1
1
|
import type { PiCtx } from "../core/notify.ts";
|
|
2
2
|
import type { TaskRegistry } from "../core/registry.ts";
|
|
3
|
+
import type { TaskSnapshot } from "../core/task.ts";
|
|
3
4
|
import { reportActivity } from "../core/bus.ts";
|
|
5
|
+
import { envNumber } from "../core/env.ts";
|
|
6
|
+
import { formatElapsedMs } from "../core/duration.ts";
|
|
4
7
|
import { tuiHost } from "./host.ts";
|
|
5
8
|
|
|
6
|
-
|
|
9
|
+
export interface SummaryCounts {
|
|
10
|
+
running: number;
|
|
11
|
+
pending: number;
|
|
12
|
+
completed: number;
|
|
13
|
+
failed: number;
|
|
14
|
+
longestRunningStartedAt?: number;
|
|
15
|
+
}
|
|
7
16
|
|
|
8
|
-
|
|
9
|
-
export
|
|
10
|
-
|
|
11
|
-
|
|
17
|
+
export type SummaryToken = "accent" | "dim" | "success" | "error";
|
|
18
|
+
export interface SummarySegment { text: string; token: SummaryToken }
|
|
19
|
+
|
|
20
|
+
export function summarySegments(c: SummaryCounts, now = Date.now()): SummarySegment[] {
|
|
21
|
+
const elapsed = c.running > 0 && c.longestRunningStartedAt !== undefined
|
|
22
|
+
? formatElapsedMs(now - c.longestRunningStartedAt)
|
|
23
|
+
: "—";
|
|
24
|
+
return [
|
|
25
|
+
{ text: "▶", token: c.running + c.pending > 0 ? "accent" : "dim" },
|
|
26
|
+
{ text: ` ${c.running} running`, token: "accent" },
|
|
27
|
+
...(c.pending > 0 ? [{ text: ` · ${c.pending} pending`, token: "dim" as const }] : []),
|
|
28
|
+
{ text: ` · ${elapsed}`, token: "dim" },
|
|
29
|
+
// ✓ and ✗ are symmetric: both only appear when non-zero, so an all-failed run shows no green 0.
|
|
30
|
+
...(c.completed > 0 ? [{ text: ` · ✓${c.completed}`, token: "success" as const }] : []),
|
|
31
|
+
...(c.failed > 0 ? [{ text: ` · ✗${c.failed}`, token: "error" as const }] : []),
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatSummary(c: SummaryCounts, now = Date.now()): string {
|
|
36
|
+
return summarySegments(c, now).map((segment) => segment.text).join("");
|
|
12
37
|
}
|
|
13
38
|
|
|
14
39
|
let clearedAt = 0;
|
|
15
|
-
let refreshCurrent: (() => void) | undefined;
|
|
16
|
-
let refreshInterval: ReturnType<typeof setInterval> | undefined;
|
|
17
40
|
|
|
18
41
|
/** Shared /async-clear watermark used by footer and panel (#31). */
|
|
19
42
|
export function clearedAtMs(): number {
|
|
@@ -32,32 +55,65 @@ export function survivesClearCutoff(
|
|
|
32
55
|
return (task.finishedAt ?? task.createdAt) >= cutoffMs;
|
|
33
56
|
}
|
|
34
57
|
|
|
35
|
-
export function disposeStatus(): void {
|
|
36
|
-
if (refreshInterval) clearInterval(refreshInterval);
|
|
37
|
-
refreshInterval = undefined;
|
|
38
|
-
refreshCurrent = undefined;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
58
|
export function clearDoneStatus(): void {
|
|
42
59
|
clearedAt = Date.now();
|
|
43
|
-
refreshCurrent?.();
|
|
44
60
|
}
|
|
45
61
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
62
|
+
const TERMINAL = new Set(["completed", "failed", "killed"]);
|
|
63
|
+
|
|
64
|
+
/** Terminal-task linger window on the rail, overridable for tests. */
|
|
65
|
+
export function railLingerMs(): number {
|
|
66
|
+
return envNumber("PI_ASYNC_RAIL_LINGER_MS", 30_000, { integer: true, min: 0 });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The single rail visibility window: live tasks always show, terminal tasks linger.
|
|
71
|
+
* Rows and summary counts both go through this predicate so `✓N` can never show a
|
|
72
|
+
* number without a matching row (0.3.0 §2.9).
|
|
73
|
+
*/
|
|
74
|
+
export function inRailWindow(
|
|
75
|
+
task: { status: string; createdAt: number; startedAt?: number; finishedAt?: number },
|
|
76
|
+
now = Date.now(),
|
|
77
|
+
lingerMs = railLingerMs(),
|
|
78
|
+
): boolean {
|
|
79
|
+
if (task.status === "running" || task.status === "pending") return true;
|
|
80
|
+
return now - (task.finishedAt ?? task.startedAt ?? task.createdAt) < lingerMs;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Counts drawn from exactly the rows the rail renders (same linger window + clear watermark). */
|
|
84
|
+
export function railWindowCounts(tasks: TaskSnapshot[], now = Date.now(), lingerMs = railLingerMs()): SummaryCounts {
|
|
85
|
+
const window = tasks.filter((task) => inRailWindow(task, now, lingerMs));
|
|
86
|
+
const running = window.filter((task) => task.status === "running");
|
|
87
|
+
const pending = window.filter((task) => task.status === "pending").length;
|
|
88
|
+
const terminal = window.filter((task) => TERMINAL.has(task.status) && survivesClearCutoff(task));
|
|
89
|
+
return {
|
|
90
|
+
running: running.length,
|
|
91
|
+
pending,
|
|
92
|
+
completed: terminal.filter((task) => task.status === "completed").length,
|
|
93
|
+
failed: terminal.filter((task) => task.status === "failed" || task.status === "killed").length,
|
|
94
|
+
...(running.length ? { longestRunningStartedAt: Math.min(...running.map((task) => task.startedAt ?? task.createdAt)) } : {}),
|
|
57
95
|
};
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Opt-in single-line footer (`PI_ASYNC_FOOTER=1`); the rail is the default status surface. */
|
|
99
|
+
export function footerEnabled(): boolean {
|
|
100
|
+
return process.env.PI_ASYNC_FOOTER === "1";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Publish the summary line when the footer is opted in; no timers, callers drive it. */
|
|
104
|
+
export function refreshFooter(registry: TaskRegistry, now = Date.now()): void {
|
|
105
|
+
if (!footerEnabled()) return;
|
|
106
|
+
const ui = tuiHost()?.ui as { setStatus?: (key: string, text: string | undefined) => void } | undefined;
|
|
107
|
+
if (typeof ui?.setStatus !== "function") return;
|
|
108
|
+
try {
|
|
109
|
+
ui.setStatus("pi-async", formatSummary(railWindowCounts(registry.list({ includeHistory: true }), now)));
|
|
110
|
+
} catch {
|
|
111
|
+
// Status rendering is best-effort: a host that rejects setStatus must not break the session.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function initStatus(pi: PiCtx, registry: TaskRegistry): void {
|
|
116
|
+
if (!tuiHost()) return;
|
|
117
|
+
reportActivity(pi, registry.activeCount());
|
|
118
|
+
refreshFooter(registry);
|
|
63
119
|
}
|