pi-async 0.1.0 → 0.2.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 +54 -0
- package/README.md +20 -0
- package/docs/CONTRACT.md +68 -17
- package/extensions/index.ts +33 -2
- package/package.json +3 -2
- package/src/core/bus.ts +11 -3
- package/src/core/duration.ts +16 -0
- package/src/core/env.ts +23 -0
- package/src/core/notify.ts +19 -3
- package/src/core/output.ts +48 -2
- package/src/core/registry.ts +197 -70
- package/src/core/spawn.ts +54 -14
- package/src/core/task.ts +25 -8
- package/src/core/watch.ts +49 -13
- package/src/guard/settle-guard.ts +3 -6
- package/src/tools/index.ts +50 -11
- package/src/tui/panel.ts +99 -38
- package/src/tui/rail.ts +73 -20
- package/src/tui/status.ts +23 -2
- package/src/tui/width.ts +53 -0
package/src/tui/rail.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { PiCtx } from "../core/notify.ts";
|
|
2
2
|
import type { TaskRegistry } from "../core/registry.ts";
|
|
3
3
|
import type { TaskSnapshot } from "../core/task.ts";
|
|
4
|
+
import { visibleText } from "../core/output.ts";
|
|
5
|
+
import { formatDuration } from "../core/duration.ts";
|
|
6
|
+
import { envNumber } from "../core/env.ts";
|
|
7
|
+
import { truncateToWidth } from "./width.ts";
|
|
4
8
|
import type { TUI, TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
5
9
|
import { openPanel } from "./panel.ts";
|
|
6
10
|
import { tuiHost } from "./host.ts";
|
|
@@ -9,6 +13,7 @@ type Theme = { fg(color: string, text: string): string; getFgAnsi?(color: string
|
|
|
9
13
|
export interface RailWidget {
|
|
10
14
|
render(width?: number): string[];
|
|
11
15
|
handleMouse(event: TuiMouseEvent): { handled: boolean } | undefined;
|
|
16
|
+
handleInput?(data: string): { handled?: boolean } | void;
|
|
12
17
|
dispose?(): void;
|
|
13
18
|
}
|
|
14
19
|
|
|
@@ -58,33 +63,31 @@ function typeColor(theme: Theme, mode: string, used: Swatch[]): string {
|
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
const statePriority: Record<TaskSnapshot["status"], number> = { running: 0, pending: 1, completed: 2, failed: 3, killed: 4 };
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
function
|
|
66
|
-
|
|
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`;
|
|
66
|
+
/** Alias for tests / callers still importing elapsed; single source is core/duration. */
|
|
67
|
+
export { formatDuration as elapsed };
|
|
68
|
+
/** Fit a row to at most `width` code points; never exceed the budget (empty when width<=0). */
|
|
69
|
+
/** Truncate a rail row by terminal cell width (CJK/emoji-aware via #7). */
|
|
70
|
+
export function fitRailRow(row: string, width: number): string {
|
|
71
|
+
return truncateToWidth(row, Math.max(0, Math.floor(width)));
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
/** Pure, unstyled rail rows; active tasks first and a hard ten-row limit. */
|
|
73
75
|
export function renderRailRows(snapshots: TaskSnapshot[], width: number, now = Date.now()): string[] {
|
|
74
|
-
const rows = snapshots
|
|
76
|
+
const rows = snapshots
|
|
75
77
|
.slice().sort((a, b) => statePriority[a.status] - statePriority[b.status] || (b.startedAt ?? b.createdAt) - (a.startedAt ?? a.createdAt))
|
|
76
78
|
.slice(0, 10)
|
|
77
|
-
.map((task) => `▶ ${
|
|
79
|
+
.map((task) => `▶ ${visibleText(task.name)} · ${task.status} · ${formatDuration(task, now)}`);
|
|
78
80
|
const limit = Math.max(0, Math.floor(width));
|
|
79
|
-
return rows.map((row) =>
|
|
81
|
+
return rows.map((row) => truncateToWidth(row, limit));
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
/** Terminal-task linger window on the rail, overridable for tests. */
|
|
83
85
|
function lingerMsFromEnv(): number {
|
|
84
|
-
|
|
85
|
-
return Number.isFinite(raw) && raw >= 0 ? raw : 30_000;
|
|
86
|
+
return envNumber("PI_ASYNC_RAIL_LINGER_MS", 30_000, { integer: true, min: 0 });
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
const RAIL_PAGE = 5;
|
|
90
|
+
|
|
88
91
|
export function createRailFactory(pi: PiCtx, registry: TaskRegistry, opts: { onOpenTask?: (taskId: string) => void } = {}): (tui: TUI, theme: Theme) => RailWidget {
|
|
89
92
|
// Factory signature includes the extension context for future host-specific rail integrations.
|
|
90
93
|
void pi;
|
|
@@ -98,31 +101,81 @@ export function createRailFactory(pi: PiCtx, registry: TaskRegistry, opts: { onO
|
|
|
98
101
|
const ordered = (now = Date.now()): TaskSnapshot[] => registry.list({ includeHistory: true })
|
|
99
102
|
.filter((task) => task.status === "running" || task.status === "pending"
|
|
100
103
|
|| (now - (task.finishedAt ?? task.startedAt ?? task.createdAt)) < lingerMs)
|
|
101
|
-
.sort((a, b) => statePriority[a.status] - statePriority[b.status] || (b.startedAt ?? b.createdAt) - (a.startedAt ??
|
|
104
|
+
.sort((a, b) => statePriority[a.status] - statePriority[b.status] || (b.startedAt ?? b.createdAt) - (a.startedAt ?? a.createdAt));
|
|
105
|
+
const clampScroll = (count: number, taskSlots: number): void => {
|
|
106
|
+
scroll = Math.max(0, Math.min(scroll, Math.max(0, count - taskSlots)));
|
|
107
|
+
};
|
|
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
|
+
};
|
|
102
119
|
const render = (width = 120): string[] => {
|
|
103
120
|
const tasks = ordered();
|
|
104
|
-
|
|
105
|
-
|
|
121
|
+
const overflow = tasks.length > 10;
|
|
122
|
+
// Reserve one of the ≤10 rows for the overflow indicator when needed.
|
|
123
|
+
const taskSlots = overflow ? 9 : 10;
|
|
124
|
+
clampScroll(tasks.length, taskSlots);
|
|
125
|
+
const visible = tasks.slice(scroll, scroll + taskSlots);
|
|
106
126
|
hitRows = visible.map((task) => task.taskId);
|
|
107
127
|
const used = [swatch(theme, "error"), swatch(theme, "dim")];
|
|
108
|
-
|
|
128
|
+
const rows = renderRailRows(visible, width).map((row, index) => {
|
|
109
129
|
const task = visible[index];
|
|
110
130
|
if (!task) return row;
|
|
111
131
|
let token = "dim";
|
|
112
132
|
if (task.status === "running") token = typeColor(theme, task.mode, used);
|
|
113
133
|
else if (task.status === "completed") token = "success";
|
|
114
134
|
else if (task.status === "failed") token = "error";
|
|
115
|
-
|
|
135
|
+
try {
|
|
136
|
+
return theme.fg(token, row) ?? row;
|
|
137
|
+
} catch {
|
|
138
|
+
return row;
|
|
139
|
+
}
|
|
116
140
|
});
|
|
141
|
+
if (overflow) {
|
|
142
|
+
const hidden = tasks.length - visible.length;
|
|
143
|
+
const above = scroll;
|
|
144
|
+
const below = Math.max(0, tasks.length - scroll - visible.length);
|
|
145
|
+
const label = above > 0 && below > 0
|
|
146
|
+
? `… +${hidden} more (↑${above} ↓${below})`
|
|
147
|
+
: above > 0
|
|
148
|
+
? `… +${above} more ↑`
|
|
149
|
+
: `… +${below} more`;
|
|
150
|
+
const indicator = fitRailRow(label, width);
|
|
151
|
+
try {
|
|
152
|
+
rows.push(theme.fg("dim", indicator) ?? indicator);
|
|
153
|
+
} catch {
|
|
154
|
+
rows.push(indicator);
|
|
155
|
+
}
|
|
156
|
+
hitRows.push(""); // overflow row is not a task hit target
|
|
157
|
+
}
|
|
158
|
+
return rows;
|
|
117
159
|
};
|
|
118
160
|
return {
|
|
119
161
|
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
|
+
},
|
|
120
172
|
handleMouse(event: TuiMouseEvent): { handled: boolean } | undefined {
|
|
121
173
|
const mouse = event as TuiMouseEvent & { type: string; y: number; button?: string; wheelDelta?: number };
|
|
122
174
|
if (mouse.type === "wheel") {
|
|
123
175
|
const count = ordered().length;
|
|
124
176
|
if (count <= 10) return undefined;
|
|
125
|
-
const
|
|
177
|
+
const taskSlots = 9;
|
|
178
|
+
const next = Math.max(0, Math.min(count - taskSlots, scroll + Math.trunc(mouse.wheelDelta ?? 0)));
|
|
126
179
|
if (next === scroll) return undefined;
|
|
127
180
|
scroll = next; latchedId = undefined; tui.requestRender(); return { handled: true };
|
|
128
181
|
}
|
package/src/tui/status.ts
CHANGED
|
@@ -3,14 +3,35 @@ import type { TaskRegistry } from "../core/registry.ts";
|
|
|
3
3
|
import { reportActivity } from "../core/bus.ts";
|
|
4
4
|
import { tuiHost } from "./host.ts";
|
|
5
5
|
|
|
6
|
+
const TERMINAL = new Set(["completed", "failed", "killed"]);
|
|
7
|
+
|
|
8
|
+
/** Footer: running excludes pending; pending segment only when > 0; done = all terminal. */
|
|
6
9
|
export function formatStatus(active: { running: number; pending: number }, done: number): string {
|
|
7
|
-
|
|
10
|
+
const pending = active.pending > 0 ? ` · ${active.pending} pending` : "";
|
|
11
|
+
return ` ${active.running} running${pending} · ${done} done `;
|
|
8
12
|
}
|
|
9
13
|
|
|
10
14
|
let clearedAt = 0;
|
|
11
15
|
let refreshCurrent: (() => void) | undefined;
|
|
12
16
|
let refreshInterval: ReturnType<typeof setInterval> | undefined;
|
|
13
17
|
|
|
18
|
+
/** Shared /async-clear watermark used by footer and panel (#31). */
|
|
19
|
+
export function clearedAtMs(): number {
|
|
20
|
+
return clearedAt;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Shared clear predicate: running/pending always visible; otherwise compare
|
|
25
|
+
* finishedAt ?? createdAt against the watermark (footer semantics).
|
|
26
|
+
*/
|
|
27
|
+
export function survivesClearCutoff(
|
|
28
|
+
task: { status: string; createdAt: number; finishedAt?: number },
|
|
29
|
+
cutoffMs: number = clearedAt,
|
|
30
|
+
): boolean {
|
|
31
|
+
if (task.status === "running" || task.status === "pending") return true;
|
|
32
|
+
return (task.finishedAt ?? task.createdAt) >= cutoffMs;
|
|
33
|
+
}
|
|
34
|
+
|
|
14
35
|
export function disposeStatus(): void {
|
|
15
36
|
if (refreshInterval) clearInterval(refreshInterval);
|
|
16
37
|
refreshInterval = undefined;
|
|
@@ -30,7 +51,7 @@ export function initStatus(pi: PiCtx, registry: TaskRegistry): void {
|
|
|
30
51
|
const setStatus = bound.ui["setStatus"] as ((key: string, text: string | undefined) => void) | undefined;
|
|
31
52
|
const refresh = (): void => {
|
|
32
53
|
const active = registry.activeCount();
|
|
33
|
-
const done = registry.list({ includeHistory: true }).filter((task) => task.status
|
|
54
|
+
const done = registry.list({ includeHistory: true }).filter((task) => TERMINAL.has(task.status) && survivesClearCutoff(task)).length;
|
|
34
55
|
setStatus?.("pi-async", formatStatus(active, done));
|
|
35
56
|
reportActivity(pi, active);
|
|
36
57
|
};
|
package/src/tui/width.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** East-Asian wide / fullwidth / common emoji ranges → 2 cells; else 1. */
|
|
2
|
+
function charWidth(ch: string): number {
|
|
3
|
+
const cp = ch.codePointAt(0)!;
|
|
4
|
+
const wide =
|
|
5
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
6
|
+
(cp >= 0x2e80 && cp <= 0xa4cf) ||
|
|
7
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
8
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
9
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) ||
|
|
10
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
11
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
12
|
+
(cp >= 0x1f300 && cp <= 0x1f9ff);
|
|
13
|
+
return wide ? 2 : 1;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Terminal cell width of a string (CJK/emoji ≈ 2 cells). */
|
|
17
|
+
export function displayWidth(text: string): number {
|
|
18
|
+
let w = 0;
|
|
19
|
+
for (const ch of text) w += charWidth(ch);
|
|
20
|
+
return w;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Truncate to a cell budget, never splitting a wide char, appending `…`. */
|
|
24
|
+
export function truncateToWidth(text: string, limit: number): string {
|
|
25
|
+
const budget = Math.max(0, Math.floor(limit));
|
|
26
|
+
if (budget <= 0) return "";
|
|
27
|
+
if (displayWidth(text) <= budget) return text;
|
|
28
|
+
if (budget === 1) return "…";
|
|
29
|
+
let out = "";
|
|
30
|
+
let used = 0;
|
|
31
|
+
for (const ch of text) {
|
|
32
|
+
const cw = charWidth(ch);
|
|
33
|
+
if (used + cw > budget - 1) break;
|
|
34
|
+
out += ch;
|
|
35
|
+
used += cw;
|
|
36
|
+
}
|
|
37
|
+
return `${out}…`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Hard-truncate by cells (no ellipsis) then pad with spaces to exact cell width. */
|
|
41
|
+
export function fitWidth(text: string, limit: number): string {
|
|
42
|
+
const budget = Math.max(0, Math.floor(limit));
|
|
43
|
+
if (budget <= 0) return "";
|
|
44
|
+
let out = "";
|
|
45
|
+
let used = 0;
|
|
46
|
+
for (const ch of text) {
|
|
47
|
+
const cw = charWidth(ch);
|
|
48
|
+
if (used + cw > budget) break;
|
|
49
|
+
out += ch;
|
|
50
|
+
used += cw;
|
|
51
|
+
}
|
|
52
|
+
return out + " ".repeat(budget - used);
|
|
53
|
+
}
|