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/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 { inRailWindow, railLingerMs, railWindowCounts, summarySegments, type SummaryCounts } from "./status.ts";
7
+ import { displayWidth, 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,8 @@ 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
+ // 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.
12
18
  dispose?(): void;
13
19
  }
14
20
 
@@ -58,71 +64,143 @@ function typeColor(theme: Theme, mode: string, used: Swatch[]): string {
58
64
  }
59
65
 
60
66
  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, "");
67
+ /** Alias for tests / callers still importing elapsed; single source is core/duration. */
68
+ export { formatDuration as elapsed };
69
+ /** Fit a row to at most `width` code points; never exceed the budget (empty when width<=0). */
70
+ /** Truncate a rail row by terminal cell width (CJK/emoji-aware via #7). */
71
+ export function fitRailRow(row: string, width: number): string {
72
+ return truncateToWidth(row, Math.max(0, Math.floor(width)));
63
73
  }
64
74
 
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
- }
75
+ export const RAIL_MAX_LINES = 4;
76
+ export const RAIL_TASK_SLOTS = 3;
77
+ export const RAIL_TASK_SLOTS_OVERFLOW = 2;
71
78
 
72
- /** Pure, unstyled rail rows; active tasks first and a hard ten-row limit. */
79
+ /** Pure, unstyled rail rows; active tasks first and a hard three-row limit. */
73
80
  export function renderRailRows(snapshots: TaskSnapshot[], width: number, now = Date.now()): string[] {
74
- const rows = snapshots.filter((task) => task.status !== "pending")
81
+ const rows = snapshots
75
82
  .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)}`);
83
+ .slice(0, RAIL_TASK_SLOTS)
84
+ .map((task) => `▶ ${visibleText(task.name)} · ${task.status} · ${formatDuration(task, now)}`);
78
85
  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);
86
+ return rows.map((row) => truncateToWidth(row, limit));
80
87
  }
81
88
 
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;
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("");
86
114
  }
87
115
 
116
+ type SummarySegmentLike = { text: string; token: string };
117
+
88
118
  export function createRailFactory(pi: PiCtx, registry: TaskRegistry, opts: { onOpenTask?: (taskId: string) => void } = {}): (tui: TUI, theme: Theme) => RailWidget {
89
119
  // Factory signature includes the extension context for future host-specific rail integrations.
90
120
  void pi;
91
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?.();
92
129
  let scroll = 0;
93
130
  let hitRows: string[] = [];
94
131
  let latchedId: string | undefined;
95
132
  // Terminal tasks linger briefly so the user sees the outcome, then drop off
96
133
  // 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));
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))
142
+ .sort((a, b) => statePriority[a.status] - statePriority[b.status] || (b.startedAt ?? b.createdAt) - (a.startedAt ?? a.createdAt));
143
+ const clampScroll = (count: number, taskSlots: number): void => {
144
+ scroll = Math.max(0, Math.min(scroll, Math.max(0, count - taskSlots)));
145
+ };
102
146
  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);
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;
153
+ clampScroll(tasks.length, taskSlots);
154
+ const visible = tasks.slice(scroll, scroll + taskSlots);
155
+ const counts = railWindowCounts(all, now, lingerMs);
156
+ hitRows = ["", ...visible.map((task) => task.taskId)];
107
157
  const used = [swatch(theme, "error"), swatch(theme, "dim")];
108
- return renderRailRows(visible, width).map((row, index) => {
158
+ const rows = [coloredSummary(counts, now, width, theme), ...renderRailRows(visible, width).map((row, index) => {
109
159
  const task = visible[index];
110
160
  if (!task) return row;
111
161
  let token = "dim";
112
162
  if (task.status === "running") token = typeColor(theme, task.mode, used);
113
163
  else if (task.status === "completed") token = "success";
114
164
  else if (task.status === "failed") token = "error";
115
- return theme.fg(token, row);
116
- });
165
+ try {
166
+ return theme.fg(token, row) ?? row;
167
+ } catch {
168
+ return row;
169
+ }
170
+ })];
171
+ if (overflow) {
172
+ const hidden = tasks.length - visible.length;
173
+ const above = scroll;
174
+ const below = Math.max(0, tasks.length - scroll - visible.length);
175
+ const label = above > 0 && below > 0
176
+ ? `… +${hidden} more (↑${above} ↓${below})`
177
+ : above > 0
178
+ ? `… +${above} more ↑`
179
+ : `… +${below} more`;
180
+ const indicator = fitRailRow(label, width);
181
+ try {
182
+ rows.push(theme.fg("dim", indicator) ?? indicator);
183
+ } catch {
184
+ rows.push(indicator);
185
+ }
186
+ hitRows.push(""); // overflow row is not a task hit target
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
+ }
193
+ return rows;
117
194
  };
118
195
  return {
119
196
  render,
120
197
  handleMouse(event: TuiMouseEvent): { handled: boolean } | undefined {
121
198
  const mouse = event as TuiMouseEvent & { type: string; y: number; button?: string; wheelDelta?: number };
122
199
  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)));
200
+ const count = ordered(listAll()).length;
201
+ if (count <= RAIL_TASK_SLOTS) return undefined;
202
+ const taskSlots = RAIL_TASK_SLOTS_OVERFLOW;
203
+ const next = Math.max(0, Math.min(count - taskSlots, scroll + Math.trunc(mouse.wheelDelta ?? 0)));
126
204
  if (next === scroll) return undefined;
127
205
  scroll = next; latchedId = undefined; tui.requestRender(); return { handled: true };
128
206
  }
@@ -138,7 +216,13 @@ export function createRailFactory(pi: PiCtx, registry: TaskRegistry, opts: { onO
138
216
  if (!latchedId || id !== latchedId) { latchedId = undefined; return undefined; }
139
217
  const clicked = latchedId; latchedId = undefined; opts.onOpenTask?.(clicked); return { handled: true };
140
218
  },
141
- dispose(): void { latchedId = undefined; },
219
+ dispose(): void {
220
+ latchedId = undefined;
221
+ if (railTicker === ticker) {
222
+ clearInterval(ticker);
223
+ railTicker = undefined;
224
+ }
225
+ },
142
226
  };
143
227
  };
144
228
  }
package/src/tui/status.ts CHANGED
@@ -1,42 +1,119 @@
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
- export function formatStatus(active: { running: number; pending: number }, done: number): string {
7
- return ` ${active.running + active.pending} running · ${done} done `;
9
+ export interface SummaryCounts {
10
+ running: number;
11
+ pending: number;
12
+ completed: number;
13
+ failed: number;
14
+ longestRunningStartedAt?: number;
15
+ }
16
+
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("");
8
37
  }
9
38
 
10
39
  let clearedAt = 0;
11
- let refreshCurrent: (() => void) | undefined;
12
- let refreshInterval: ReturnType<typeof setInterval> | undefined;
13
40
 
14
- export function disposeStatus(): void {
15
- if (refreshInterval) clearInterval(refreshInterval);
16
- refreshInterval = undefined;
17
- refreshCurrent = undefined;
41
+ /** Shared /async-clear watermark used by footer and panel (#31). */
42
+ export function clearedAtMs(): number {
43
+ return clearedAt;
44
+ }
45
+
46
+ /**
47
+ * Shared clear predicate: running/pending always visible; otherwise compare
48
+ * finishedAt ?? createdAt against the watermark (footer semantics).
49
+ */
50
+ export function survivesClearCutoff(
51
+ task: { status: string; createdAt: number; finishedAt?: number },
52
+ cutoffMs: number = clearedAt,
53
+ ): boolean {
54
+ if (task.status === "running" || task.status === "pending") return true;
55
+ return (task.finishedAt ?? task.createdAt) >= cutoffMs;
18
56
  }
19
57
 
20
58
  export function clearDoneStatus(): void {
21
59
  clearedAt = Date.now();
22
- refreshCurrent?.();
23
60
  }
24
61
 
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);
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)) } : {}),
36
95
  };
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?.();
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);
42
119
  }
@@ -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
+ }