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/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 { envNumber } from "../core/env.ts";
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
- handleInput?(data: string): { handled?: boolean } | void;
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
- /** Pure, unstyled rail rows; active tasks first and a hard ten-row limit. */
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, 10)
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
- /** Terminal-task linger window on the rail, overridable for tests. */
85
- function lingerMsFromEnv(): number {
86
- return envNumber("PI_ASYNC_RAIL_LINGER_MS", 30_000, { integer: true, min: 0 });
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
- const RAIL_PAGE = 5;
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 = lingerMsFromEnv();
101
- const ordered = (now = Date.now()): TaskSnapshot[] => registry.list({ includeHistory: true })
102
- .filter((task) => task.status === "running" || task.status === "pending"
103
- || (now - (task.finishedAt ?? task.startedAt ?? task.createdAt)) < lingerMs)
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 tasks = ordered();
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;
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
- hitRows = visible.map((task) => task.taskId);
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 <= 10) return undefined;
177
- const taskSlots = 9;
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 { latchedId = undefined; },
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
- const TERMINAL = new Set(["completed", "failed", "killed"]);
9
+ export interface SummaryCounts {
10
+ running: number;
11
+ pending: number;
12
+ completed: number;
13
+ failed: number;
14
+ longestRunningStartedAt?: number;
15
+ }
7
16
 
8
- /** Footer: running excludes pending; pending segment only when > 0; done = all terminal. */
9
- export function formatStatus(active: { running: number; pending: number }, done: number): string {
10
- const pending = active.pending > 0 ? ` · ${active.pending} pending` : "";
11
- return ` ${active.running} running${pending} · ${done} done `;
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
- export function initStatus(pi: PiCtx, registry: TaskRegistry): void {
47
- disposeStatus();
48
- // TUI surfaces bind from event contexts; the factory API has no mode/ui.
49
- const bound = tuiHost();
50
- if (!bound) return;
51
- const setStatus = bound.ui["setStatus"] as ((key: string, text: string | undefined) => void) | undefined;
52
- const refresh = (): void => {
53
- const active = registry.activeCount();
54
- const done = registry.list({ includeHistory: true }).filter((task) => TERMINAL.has(task.status) && survivesClearCutoff(task)).length;
55
- setStatus?.("pi-async", formatStatus(active, done));
56
- 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)) } : {}),
57
95
  };
58
- refreshCurrent = refresh;
59
- refresh();
60
- // Registry intentionally exposes no event subscription API; keep the footer current without replacing its core events.
61
- refreshInterval = setInterval(refresh, 1_000);
62
- 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);
63
119
  }