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/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
- 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`;
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.filter((task) => task.status !== "pending")
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) => `▶ ${stripAnsi(task.name).replace(/[\r\n]+/g, " ")} · ${task.status} · ${elapsed(task, now)}`);
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) => Array.from(row).length > limit ? `${Array.from(row).slice(0, Math.max(0, limit - 1)).join("")}…` : 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
- const raw = Number(process.env.PI_ASYNC_RAIL_LINGER_MS ?? 30_000);
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 ?? b.createdAt));
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
- scroll = Math.max(0, Math.min(scroll, Math.max(0, tasks.length - 10)));
105
- const visible = tasks.slice(scroll, scroll + 10);
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
- return renderRailRows(visible, width).map((row, index) => {
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
- return theme.fg(token, row);
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 next = Math.max(0, Math.min(count - 10, scroll + Math.trunc(mouse.wheelDelta ?? 0)));
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
- return ` ${active.running + active.pending} running · ${done} done `;
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 === "completed" && (task.finishedAt ?? task.createdAt) >= clearedAt).length;
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
  };
@@ -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
+ }