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/panel.ts CHANGED
@@ -1,11 +1,41 @@
1
- import { openSync, closeSync, fstatSync, readSync } from "node:fs";
2
1
  import type { PiCtx } from "../core/notify.ts";
3
2
  import type { TaskRegistry } from "../core/registry.ts";
4
3
  import type { TaskSnapshot } from "../core/task.ts";
5
- import { stripAnsi } from "../core/output.ts";
6
- import { clearDoneStatus } from "./status.ts";
4
+ import { stripAnsi, visibleText } from "../core/output.ts";
5
+ import { formatDuration } from "../core/duration.ts";
6
+ import { displayWidth, fitWidth, truncateToWidth } from "./width.ts";
7
+ import { clearDoneStatus, survivesClearCutoff } from "./status.ts";
7
8
  import { tuiHost } from "./host.ts";
8
9
 
10
+ type Theme = { fg?(color: string, text: string): string };
11
+
12
+ /** Paint with host theme; missing/throwing fg falls back to plain text (#41 / #75). */
13
+ export function paint(theme: Theme | null | undefined, token: string, text: string): string {
14
+ try {
15
+ const fg = theme?.fg;
16
+ if (typeof fg !== "function") return text;
17
+ return fg.call(theme, token, text) ?? text;
18
+ } catch {
19
+ return text;
20
+ }
21
+ }
22
+
23
+ function statusToken(status: string): string {
24
+ if (status === "completed") return "success";
25
+ if (status === "failed" || status === "killed") return "error";
26
+ return "accent";
27
+ }
28
+
29
+ function stringifyMetaValue(value: unknown): string {
30
+ if (typeof value === "string") return value;
31
+ try { return JSON.stringify(value); } catch { return String(value); }
32
+ }
33
+
34
+ function sectionDivider(theme: Theme | null | undefined, budget: number): string {
35
+ const bar = "─".repeat(Math.max(0, Math.min(budget, 64)));
36
+ return paint(theme, "dim", bar);
37
+ }
38
+
9
39
  const isTui = (pi: unknown): boolean => {
10
40
  // Event/command contexts carry mode; the factory API does not. Both paths must resolve.
11
41
  const asCtx = pi as { mode?: string } | undefined;
@@ -13,48 +43,138 @@ const isTui = (pi: unknown): boolean => {
13
43
  };
14
44
 
15
45
  const UI_TAIL_BYTES = 128 * 1024;
46
+ /** The list shows one live output line per task — a small tail window is enough (§2.9). */
47
+ const LIST_TAIL_BYTES = 16 * 1024;
16
48
  const PANEL_ROWS = 12;
17
- let historyClearedAt = 0;
18
- const visible = (value: unknown): string => stripAnsi(String(value ?? "")).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "").replace(/[\r\n\t]+/g, " ");
19
- function readUiOutput(task: TaskSnapshot, registry: TaskRegistry): { text: string; truncated: boolean } {
20
- const outputPath = task.metadata.outputFile;
21
- if (typeof outputPath !== "string") return { text: registry.logs(task.taskId, { maxBytes: UI_TAIL_BYTES, tail: true }).text, truncated: false };
49
+ /** PageUp/PageDown step for the detail body. */
50
+ const PAGE_SCROLL = PANEL_ROWS;
51
+ /** Identity lines kept above the `… (+K lines)` marker when the detail body overflows. */
52
+ const DETAIL_HEADER_LINES = 2;
53
+ /** 0.3.0 §2.9: kill moved off `k` (which now scrolls up) onto `x`/`X`/`K`. */
54
+ const KILL_KEYS = new Set(["x", "X", "K"]);
55
+ const visible = visibleText;
56
+ /** Per-line sanitizer for multiline command display — keeps LF, strips controls/tabs. */
57
+ const visibleLine = (value: unknown): string => stripAnsi(String(value ?? "")).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "").replace(/[\t]+/g, " ");
58
+
59
+ /**
60
+ * Mask a delayed task's env values but keep the keys: they are credentials-in-waiting,
61
+ * exactly like the async_status redaction (D-08). Kept local so the panel never imports src/tools.
62
+ */
63
+ function maskDelayedEnv(value: unknown): string {
64
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return "***";
65
+ const keys = Object.keys(value as Record<string, unknown>);
66
+ return keys.length === 0 ? "***" : `{ ${keys.map((key) => `${key}: "***"`).join(", ")} }`;
67
+ }
68
+
69
+ function metadataText(key: string, value: unknown): string {
70
+ return key === "delayedEnv" ? maskDelayedEnv(value) : stringifyMetaValue(value);
71
+ }
72
+
73
+ /** Known keys first (outputFile), then remaining keys sorted; each line truncated to budget. */
74
+ export function formatMetadataLines(metadata: Record<string, unknown>, budget: number): string[] {
75
+ const keys = Object.keys(metadata);
76
+ const ordered = [
77
+ ...(["outputFile"] as const).filter((key) => Object.prototype.hasOwnProperty.call(metadata, key)),
78
+ ...keys.filter((key) => key !== "outputFile").sort(),
79
+ ];
80
+ return ordered.map((key) => truncateToWidth(`${key}: ${metadataText(key, metadata[key])}`, Math.max(0, budget)));
81
+ }
82
+
83
+ export function detailTitleLines(task: { name: string; taskId: string }, theme: Theme | null | undefined): string[] {
84
+ const name = visibleText(task.name);
85
+ const id = `[${task.taskId}]`;
86
+ return [`${paint(theme, "accent", name)} ${paint(theme, "dim", id)}`];
87
+ }
88
+
89
+ export function detailStatusLines(task: TaskSnapshot, theme: Theme | null | undefined, now: number): string[] {
90
+ const status = paint(theme, statusToken(task.status), task.status);
91
+ const elapsed = paint(theme, "dim", formatDuration(task, now));
92
+ return [`${paint(theme, "dim", "status:")} ${status} · ${paint(theme, "dim", "elapsed:")} ${elapsed}`];
93
+ }
94
+
95
+ export function commandSection(command: unknown, theme: Theme | null | undefined, budget: number): string[] {
96
+ const label = paint(theme, "dim", "command:");
97
+ const lines = String(command ?? "").split(/\r?\n/);
98
+ const body = lines.map((line) => {
99
+ const plain = ` ${visibleLine(line)}`;
100
+ return paint(theme, "text", truncateToWidth(plain, Math.max(0, budget)));
101
+ });
102
+ return [label, ...body];
103
+ }
104
+
105
+ export function metadataSection(metadata: Record<string, unknown>, theme: Theme | null | undefined, budget: number): string[] {
106
+ const label = paint(theme, "dim", "metadata:");
107
+ const rows = formatMetadataLines(metadata, Math.max(0, budget - 2)).map((row) => paint(theme, "dim", ` ${row}`));
108
+ return [label, ...rows];
109
+ }
110
+
111
+ export function outputSection(tail: string[], truncated: boolean, theme: Theme | null | undefined, budget: number): string[] {
112
+ const label = paint(theme, "dim", `output${truncated ? " (tail truncated)" : ""}:`);
113
+ const rows = tail.map((line) => paint(theme, "dim", truncateToWidth(visible(line), Math.max(0, budget))));
114
+ return [label, ...rows];
115
+ }
116
+
117
+ /**
118
+ * Bounded output tail, always asked of the registry: it enforces the dataDir boundary
119
+ * (D-11) and the task may already be pruned, so a throw means "show nothing".
120
+ */
121
+ function logsTail(registry: TaskRegistry, taskId: string, maxBytes: number): { text: string; truncated: boolean } {
22
122
  try {
23
- const fd = openSync(outputPath, "r");
24
- try {
25
- const size = fstatSync(fd).size;
26
- const start = Math.max(0, size - UI_TAIL_BYTES);
27
- const buffer = Buffer.alloc(Math.min(size, UI_TAIL_BYTES));
28
- readSync(fd, buffer, 0, buffer.length, start);
29
- return { text: stripAnsi(buffer.toString("utf8")), truncated: start > 0 };
30
- } finally { closeSync(fd); }
123
+ const slice = registry.logs(taskId, { maxBytes, tail: true });
124
+ return { text: stripAnsi(slice.text), truncated: slice.truncated };
31
125
  } catch {
32
- return { text: registry.logs(task.taskId, { maxBytes: UI_TAIL_BYTES, tail: true }).text, truncated: false };
126
+ // Unknown/pruned task or an outputFile outside the data dir — never read it, never throw.
127
+ return { text: "", truncated: false };
33
128
  }
34
129
  }
35
130
 
36
- const duration = (task: TaskSnapshot, now: number): string => {
37
- const end = task.finishedAt ?? now;
38
- const seconds = Math.floor(Math.max(0, end - (task.startedAt ?? task.createdAt)) / 1000);
39
- return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
40
- };
131
+ function readUiOutput(task: TaskSnapshot, registry: TaskRegistry): { text: string; truncated: boolean } {
132
+ return logsTail(registry, task.taskId, UI_TAIL_BYTES);
133
+ }
134
+
135
+ /** Newest non-empty output line for the list row (bounded tail, never a whole-file read). */
136
+ function latestOutputLine(task: TaskSnapshot, registry: TaskRegistry): string | undefined {
137
+ const text = logsTail(registry, task.taskId, LIST_TAIL_BYTES).text;
138
+ return text.split(/\r?\n/).filter(Boolean).at(-1);
139
+ }
140
+
141
+ /** Fit a possibly-ANSI line to a cell budget without counting escape bytes (#75). */
142
+ function fitAnsiLine(text: string, budget: number): string {
143
+ const plain = stripAnsi(text);
144
+ if (plain === text) return fitWidth(text, budget);
145
+ const used = displayWidth(plain);
146
+ if (used > budget) return fitWidth(plain, budget);
147
+ return text + " ".repeat(budget - used);
148
+ }
149
+
41
150
  const frame = (title: string, body: string[], width: number, height: number): string[] => {
42
151
  const w = Math.max(8, Math.floor(width));
43
152
  const h = Math.max(4, Math.floor(height));
44
- const fit = (text: string): string => Array.from(text).slice(0, w - 4).join("").padEnd(w - 4);
45
- const content = [`╭─ ${fit(title)}─╮`, ...body.slice(0, h - 2).map((line) => `│ ${fit(line)} │`), `╰${"─".repeat(w - 2)}╯`];
153
+ // Title prefix is "╭─ " (3) + "─╮" (2) => budget w-5; body "│ "/" │" (2+2) => w-4.
154
+ // #55 keeps fitTitle(w-5); #7/#62 routes both through fitWidth (cell-aware).
155
+ // #75: body lines may carry theme ANSI — fit by visible cells, not raw code points.
156
+ const fitTitle = (text: string): string => fitWidth(text, w - 5);
157
+ const fit = (text: string): string => fitAnsiLine(text, w - 4);
158
+ const content = [`╭─ ${fitTitle(title)}─╮`, ...body.slice(0, h - 2).map((line) => `│ ${fit(line)} │`), `╰${"─".repeat(w - 2)}╯`];
46
159
  while (content.length < height) content.splice(content.length - 1, 0, `│${" ".repeat(w - 2)}│`);
47
160
  // The solid background masks chat rows beneath the overlay, as in legion-viz's live overlay.
48
161
  return content;
49
162
  };
50
163
 
164
+ /** Bare Esc, Kitty CSI-u Escape (key 27), and a lone CSI introducer. */
165
+ export function isEscapeKey(data: string): boolean {
166
+ if (data === "\u001b") return true;
167
+ if (data === "\u001b[") return true;
168
+ return /^\u001b\[27(?:;\d+)*u$/.test(data);
169
+ }
170
+
51
171
  export function registerPanelCommands(pi: PiCtx, registry: TaskRegistry): void {
52
172
  // Commands/shortcuts are factory-time APIs (they exist on the factory pi); TUI
53
173
  // gating comes from the bound event context. Handlers receive a rich ctx with ui.
54
174
  const bound = tuiHost();
175
+ // SAFETY: the factory host may omit registration methods; optional chaining is intentional.
55
176
  const host = pi as unknown as {
56
177
  registerCommand?: (name: string, spec: { description: string; handler: (args: unknown, ctx: PiCtx) => void }) => void;
57
- registerShortcut?: (key: string, spec: { description: string; handler: (ctx: PiCtx) => void }) => void;
58
178
  };
59
179
  if (!bound) return;
60
180
  host.registerCommand?.("async", { description: "Open async task panel", handler: (_args, ctx) => openPanel(ctx, registry) });
@@ -62,15 +182,20 @@ export function registerPanelCommands(pi: PiCtx, registry: TaskRegistry): void {
62
182
  description: "Clear async task history",
63
183
  handler: (_args, ctx) => {
64
184
  if (!isTui(ctx)) return;
65
- historyClearedAt = Date.now();
66
- clearDoneStatus();
185
+ clearDoneStatus(); // shared watermark in status.ts (#31)
67
186
  },
68
187
  });
69
- try {
70
- host.registerShortcut?.(process.env.PI_ASYNC_DOCK_SHORTCUT ?? "ctrl+alt+a", { description: "Open async task panel", handler: (ctx) => openPanel(ctx, registry) });
71
- } catch {
72
- // The /async command remains the fallback when Pi reserves this shortcut.
73
- }
188
+ }
189
+
190
+ /**
191
+ * D-14: the overlay is a module-level singleton. A second `/async` (or a rail click
192
+ * while the panel is open) must not stack another overlay.
193
+ */
194
+ let panelOpen: symbol | undefined;
195
+
196
+ /** @internal Test-only: forget an open panel so isolated unit tests can re-open one. */
197
+ export function resetPanelForTests(): void {
198
+ panelOpen = undefined;
74
199
  }
75
200
 
76
201
  export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: string): void {
@@ -79,101 +204,256 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
79
204
  const ctxUi = (pi as { ui?: Record<string, unknown> }).ui ?? tuiHost()?.ui;
80
205
  const host = {
81
206
  ui: ctxUi as {
82
- copyToClipboard?: (text: string) => void;
83
207
  custom?: (factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value?: unknown) => void) => unknown, options: unknown) => Promise<unknown>;
84
208
  notify?: (message: string, level?: string) => void;
85
209
  setStatus?: (key: string, text: string | undefined) => void;
86
210
  } | undefined,
87
211
  };
88
212
  if (!isTui(pi) || typeof host.ui?.custom !== "function") return;
89
- const snapshots = (): TaskSnapshot[] => registry.list({ includeHistory: true }).filter((task) => task.createdAt >= historyClearedAt || task.status === "running" || task.status === "pending");
213
+ if (panelOpen !== undefined) {
214
+ host.ui.notify?.("Async panel is already open", "info");
215
+ return;
216
+ }
217
+ // Set before custom(): the host may render synchronously, and a reject must not leak the flag.
218
+ const token = Symbol("pi-async-panel");
219
+ panelOpen = token;
220
+ const release = (): void => {
221
+ if (panelOpen === token) panelOpen = undefined;
222
+ };
223
+ const snapshots = (): TaskSnapshot[] => registry.list({ includeHistory: true }).filter((task) => survivesClearCutoff(task));
90
224
  const custom = host.ui.custom;
91
- const factory = (tui: unknown, _theme: unknown, _keybindings: unknown, done: (value?: unknown) => void) => {
225
+ const kind = initialTaskId ? "detail" : "list";
226
+ const ratio = kind === "detail" ? { w: 0.85, h: 0.8 } : { w: 0.8, h: 0.7 };
227
+ const factory = (tui: unknown, themeArg: unknown, _keybindings: unknown, done: (value?: unknown) => void) => {
228
+ const theme = (themeArg ?? {}) as Theme;
92
229
  let selected = initialTaskId ?? snapshots()[0]?.taskId;
93
230
  let detail = Boolean(initialTaskId);
231
+ const origin: "rail" | "list" = initialTaskId ? "rail" : "list";
94
232
  let history = true;
95
233
  let confirmKill = false;
96
234
  let scroll = 0;
235
+ let listScroll = 0;
97
236
  let follow = true;
98
237
  let width = 100;
99
238
  let height = process.stdout.rows ?? 24;
100
239
  let lastLines: string[] = [];
240
+ let actionError: string | undefined;
241
+ let closed = false;
242
+ let unsubscribe: (() => void) | undefined;
101
243
  const requestRender = (): void => {
102
- (tui as { requestRender?: () => void } | undefined)?.requestRender?.();
244
+ // A stale subscription (registry still holding our listener) must not render.
245
+ if (closed) return;
246
+ try {
247
+ (tui as { requestRender?: () => void } | undefined)?.requestRender?.();
248
+ } catch {
249
+ // A dead overlay host must not break the render loop.
250
+ }
251
+ };
252
+ /** Single teardown path for done / dispose / custom() failure — keeps the subscription leak-free. */
253
+ const teardown = (): void => {
254
+ if (closed) return;
255
+ closed = true;
256
+ release();
257
+ const off = unsubscribe;
258
+ unsubscribe = undefined;
259
+ if (!off) return;
260
+ try {
261
+ off();
262
+ } catch {
263
+ // Registry may already be torn down (reload/quit); unsubscribing is best-effort.
264
+ }
103
265
  };
266
+ const close = (value?: unknown): void => {
267
+ teardown();
268
+ done(value);
269
+ };
270
+ // W1a adds `subscribe`; older/mock registries (0.2.x hosts) do not have it.
271
+ const subscribe = (registry as unknown as { subscribe?: (listener: () => void) => () => void }).subscribe;
272
+ if (typeof subscribe === "function") {
273
+ try {
274
+ const off = subscribe.call(registry, () => requestRender());
275
+ if (typeof off === "function") unsubscribe = off;
276
+ } catch {
277
+ // A registry that cannot publish events simply never refreshes us.
278
+ }
279
+ }
280
+ const filtered = (): TaskSnapshot[] => snapshots().filter((task) => history || task.status === "running" || task.status === "pending");
104
281
  const choose = (): TaskSnapshot | undefined => snapshots().find((task) => task.taskId === selected);
105
- const renderBody = (): string[] => {
106
- const tasks = snapshots().filter((task) => history || task.status === "running" || task.status === "pending");
282
+ const listBudget = (): number => Math.max(1, height - 3); // frame borders + hint row
283
+ const ensureListWindow = (tasks: TaskSnapshot[]): number => {
284
+ const budget = listBudget();
285
+ const selIndex = tasks.findIndex((task) => task.taskId === selected);
286
+ const index = selIndex < 0 ? 0 : selIndex;
287
+ if (index < listScroll) listScroll = index;
288
+ if (index >= listScroll + budget) listScroll = index - budget + 1;
289
+ listScroll = Math.max(0, Math.min(listScroll, Math.max(0, tasks.length - budget)));
290
+ return listScroll;
291
+ };
292
+ const footerLine = (): string => paint(
293
+ theme,
294
+ "dim",
295
+ `${origin === "rail" ? "Esc close" : "Esc return"} · x kill/cancel · R rerun · c show output path · ↑/↓ scroll`,
296
+ );
297
+ /**
298
+ * Keep the header and the footer visible; collapse the middle into `… (+K lines)`.
299
+ * The footer is always the last emitted line, so `frame` can never silently cut it.
300
+ */
301
+ const withBodyBudget = (lines: string[], budget: number): string[] => {
302
+ const limit = Math.max(1, Math.floor(budget));
303
+ if (lines.length <= limit) return lines;
304
+ const headerCount = Math.min(DETAIL_HEADER_LINES, Math.max(0, limit - 1));
305
+ const tailCount = limit - headerCount - 1;
306
+ const last = lines[lines.length - 1]!;
307
+ // Degenerate viewport: the key hints outrank every other line.
308
+ if (tailCount <= 0) return [last];
309
+ const hidden = lines.length - headerCount - tailCount;
310
+ return [
311
+ ...lines.slice(0, headerCount),
312
+ paint(theme, "dim", `… (+${hidden} lines)`),
313
+ ...lines.slice(lines.length - tailCount),
314
+ ];
315
+ };
316
+ const renderBody = (budget: number): string[] => {
317
+ const tasks = filtered();
107
318
  if (!detail) {
108
- return ["↑/↓ select · Enter details · h history · q close", ...tasks.map((task) => {
109
- const output = task.status === "running" ? registry.logs(task.taskId, { maxBytes: UI_TAIL_BYTES, tail: true }).text.split(/\r?\n/).filter(Boolean).at(-1) : undefined;
110
- return `${task.taskId === selected ? "▶" : " "} ${visible(task.name)} · ${task.status} · ${duration(task, Date.now())}${output ? ` · ${visible(output)}` : ""}`;
111
- })];
319
+ const start = ensureListWindow(tasks);
320
+ const rows = tasks.slice(start, start + Math.max(1, budget - 1)).map((task) => {
321
+ const output = task.status === "running" ? latestOutputLine(task, registry) : undefined;
322
+ return `${task.taskId === selected ? "▶" : " "} ${visible(task.name)} · ${task.status} · ${formatDuration(task, Date.now())}${output ? ` · ${visible(output)}` : ""}`;
323
+ });
324
+ return ["↑/↓ select · Enter details · h history · q close", ...rows];
112
325
  }
113
326
  const task = choose();
114
- if (!task) return ["Task not found", "Esc return · q close"];
327
+ if (!task) return ["Task not found", `${origin === "rail" ? "Esc close" : "Esc return"} · q close`];
115
328
  const output = readUiOutput(task, registry);
116
329
  const outputLines = output.text.split(/\r?\n/).filter((line) => line.length > 0);
117
330
  scroll = Math.min(scroll, Math.max(0, outputLines.length - PANEL_ROWS));
118
331
  const tail = outputLines.slice(Math.max(0, outputLines.length - PANEL_ROWS - scroll), outputLines.length - scroll);
119
- const meta = JSON.stringify(task.metadata);
120
- return [
121
- `${visible(task.name)} [${task.taskId}]`, `status: ${task.status} · elapsed: ${duration(task, Date.now())}`,
122
- `command: ${visible(task.command)}`, `metadata: ${meta}`, `output${output.truncated ? " (tail truncated)" : ""}:`,
123
- // registry.logs caps its own data at 50 KiB; the TUI intentionally reads a larger 128 KiB tail directly from the output file.
124
- ...tail.map(visible), ...(confirmKill ? ["Kill this task? press k again to confirm; any other key cancels"] : []),
125
- "Esc return · k kill · R rerun · c copy output path · ↑/↓ scroll",
126
- ];
332
+ const lineBudget = Math.max(1, Math.floor(width) - 4);
333
+ const killHint = task.status === "pending"
334
+ ? "Cancel this pending task? press x again to confirm; any other key cancels"
335
+ : "Kill this task? press x again to confirm; any other key cancels";
336
+ const divider = sectionDivider(theme, lineBudget);
337
+ return withBodyBudget([
338
+ ...detailTitleLines(task, theme),
339
+ ...detailStatusLines(task, theme, Date.now()),
340
+ divider,
341
+ ...commandSection(task.command, theme, lineBudget),
342
+ divider,
343
+ ...metadataSection(task.metadata as Record<string, unknown>, theme, lineBudget),
344
+ divider,
345
+ ...outputSection(tail, output.truncated, theme, lineBudget),
346
+ ...(actionError ? [paint(theme, "error", `error: ${visible(actionError)}`)] : []),
347
+ ...(confirmKill ? [paint(theme, "warning", killHint)] : []),
348
+ footerLine(),
349
+ ], budget);
127
350
  };
128
351
  const render = (nextWidth?: number): string[] => {
129
- width = nextWidth ?? width;
130
- height = process.stdout.rows ?? height;
131
- lastLines = renderBody();
132
- const maxScroll = Math.max(0, lastLines.length - PANEL_ROWS);
352
+ width = nextWidth ?? Math.max(20, Math.floor((process.stdout.columns ?? 120) * ratio.w));
353
+ // Cap by the overlay's own percentage budget too: on very short terminals a fixed floor of 6
354
+ // exceeds what the host will render and the bottom border gets sliced off.
355
+ const rows = process.stdout.rows ?? 24;
356
+ height = Math.min(Math.max(6, Math.floor(rows * ratio.h)), Math.max(4, rows - 2));
357
+ lastLines = renderBody(Math.max(1, height - 2));
358
+ // The body is already budgeted, so its length no longer measures the scroll range;
359
+ // renderBody clamps `scroll` against the real output-line count instead.
133
360
  if (follow) scroll = 0;
134
- else scroll = Math.min(scroll, maxScroll);
135
361
  return frame(detail ? "Async task detail" : "Async tasks", lastLines, width, height);
136
362
  };
137
363
  const move = (delta: number): void => {
138
- const tasks = snapshots().filter((task) => history || task.status === "running" || task.status === "pending");
364
+ const tasks = filtered();
139
365
  const index = tasks.findIndex((task) => task.taskId === selected);
140
- const next = tasks[Math.max(0, Math.min(tasks.length - 1, index + delta))];
366
+ // Harden dangling selection (filtered away): start from edge rather than relying on -1+delta===0 alone.
367
+ const base = index < 0 ? (delta > 0 ? -1 : 0) : index;
368
+ const next = tasks[Math.max(0, Math.min(tasks.length - 1, base + delta))];
141
369
  if (next) selected = next.taskId;
142
370
  if (detail && delta < 0) { scroll += -delta; follow = false; }
143
371
  else if (detail && delta > 0) { scroll = Math.max(0, scroll - delta); follow = scroll === 0; }
144
372
  requestRender();
145
373
  };
374
+ const killSelected = (): void => {
375
+ const task = choose();
376
+ if (!task || (task.status !== "running" && task.status !== "pending")) return;
377
+ const fail = (error: unknown): void => {
378
+ actionError = String(error);
379
+ host.ui?.notify?.(actionError, "error");
380
+ requestRender();
381
+ };
382
+ // §2.9: a rejected kill must surface in the panel instead of an unhandled rejection.
383
+ try {
384
+ void Promise.resolve(registry.kill(task.taskId, "user")).catch(fail);
385
+ } catch (error) {
386
+ // Some hosts throw synchronously instead of rejecting; treat both the same.
387
+ fail(error);
388
+ }
389
+ };
146
390
  let latchedId: string | undefined;
147
391
  return {
148
392
  render,
149
393
  handleInput(data: string): void {
150
394
  const key = data.toLowerCase();
151
- if (confirmKill && key !== "k") { confirmKill = false; requestRender(); return; }
152
- if (key === "q") { done(undefined); return; }
153
- if (data === "\u001b") { if (detail) { detail = false; confirmKill = false; requestRender(); } else done(undefined); return; }
154
- if (key === "h" && !detail) { history = !history; requestRender(); return; }
155
- if (data === "\r" || data === "\n") { detail = true; scroll = 0; follow = true; requestRender(); return; }
156
- if (data === "\u001b[A" || data === "k") { if (detail && key === "k") {
395
+ if (confirmKill && !KILL_KEYS.has(data)) { confirmKill = false; requestRender(); return; }
396
+ if (key === "q") { close(undefined); return; }
397
+ if (isEscapeKey(data)) {
398
+ if (detail && origin === "list") { detail = false; confirmKill = false; actionError = undefined; requestRender(); }
399
+ else close(undefined);
400
+ return;
401
+ }
402
+ if (key === "h" && !detail) {
403
+ history = !history;
404
+ const tasks = filtered();
405
+ if (!tasks.some((task) => task.taskId === selected)) selected = tasks[0]?.taskId;
406
+ listScroll = 0;
407
+ requestRender();
408
+ return;
409
+ }
410
+ if (data === "\r" || data === "\n") { detail = true; scroll = 0; follow = true; actionError = undefined; requestRender(); return; }
411
+ // PLAN §2.9: PageUp/PageDown page the detail body (PageUp = older lines).
412
+ // renderBody clamps the actual offset against the output length.
413
+ if (detail && (data === "\u001b[5~" || data === "\u001b[6~")) {
414
+ scroll = Math.max(0, scroll + (data === "\u001b[5~" ? PAGE_SCROLL : -PAGE_SCROLL));
415
+ follow = scroll === 0;
416
+ requestRender();
417
+ return;
418
+ }
419
+ // Kill lives above the movement keys: `K` is a kill key, `k` alone scrolls up.
420
+ if (detail && KILL_KEYS.has(data)) {
157
421
  if (!confirmKill) { confirmKill = true; requestRender(); return; }
158
422
  confirmKill = false;
159
- const task = choose();
160
- if (task?.status === "running") void registry.kill(task.taskId, "user");
161
- requestRender(); return;
162
- } move(-1); return; }
423
+ killSelected();
424
+ requestRender();
425
+ return;
426
+ }
427
+ if (data === "\u001b[A" || key === "k") { move(-1); return; }
163
428
  if (data === "\u001b[B" || key === "j") { move(1); return; }
164
429
  if (data === "R" && detail) {
165
430
  const task = choose();
166
431
  if (task) {
167
- try { registry.submit({ command: task.command, name: task.name, mode: "background" }); }
168
- catch (error) { host.ui?.notify?.(String(error), "error"); }
432
+ try {
433
+ // Rerun keeps the original task's cwd (REV-13) instead of the host process cwd.
434
+ const created = registry.submit({
435
+ command: task.command,
436
+ name: task.name,
437
+ mode: "background",
438
+ ...(task.cwd ? { cwd: task.cwd } : {}),
439
+ });
440
+ selected = created.taskId;
441
+ scroll = 0;
442
+ follow = true;
443
+ actionError = undefined;
444
+ } catch (error) {
445
+ actionError = String(error);
446
+ host.ui?.notify?.(actionError, "error");
447
+ }
169
448
  }
170
449
  requestRender(); return;
171
450
  }
172
451
  if (data === "c" && detail) {
173
452
  const path = choose()?.metadata.outputFile;
453
+ // §2.9: pi has no copyToClipboard API — show the path so the user can copy it.
174
454
  if (typeof path === "string") {
175
- if (host.ui?.copyToClipboard) host.ui.copyToClipboard(path);
176
- else host.ui?.notify?.(path, "info");
455
+ host.ui?.notify?.(path, "info");
456
+ requestRender();
177
457
  }
178
458
  }
179
459
  },
@@ -189,30 +469,45 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
189
469
  latchedId = undefined;
190
470
  if (detail) return undefined;
191
471
  if (event.button !== "left") return undefined;
192
- const index = (event.y ?? -1) - 2;
193
- const tasks = snapshots().filter((task) => history || task.status === "running" || task.status === "pending");
472
+ const tasks = filtered();
473
+ const start = ensureListWindow(tasks);
474
+ const index = (event.y ?? -1) - 2 + start;
194
475
  const task = tasks[index];
195
476
  if (!task) return undefined;
196
477
  latchedId = task.taskId;
197
478
  return { handled: true };
198
479
  }
199
480
  if (event.type !== "click" || event.button !== "left" || detail) return undefined;
200
- const index = (event.y ?? -1) - 2;
201
- const tasks = snapshots().filter((task) => history || task.status === "running" || task.status === "pending");
481
+ const tasks = filtered();
482
+ const start = ensureListWindow(tasks);
483
+ const index = (event.y ?? -1) - 2 + start;
202
484
  const task = tasks[index];
203
485
  if (!latchedId || !task || task.taskId !== latchedId) { latchedId = undefined; return undefined; }
204
486
  selected = latchedId;
205
487
  detail = true;
206
488
  scroll = 0;
207
489
  follow = true;
490
+ actionError = undefined;
208
491
  latchedId = undefined;
209
492
  requestRender();
210
493
  return { handled: true };
211
494
  },
212
- dispose(): void { void width; },
495
+ dispose(): void { teardown(); },
213
496
  };
214
497
  };
215
- // Required full-screen overlay geometry. custom() resolves undefined outside supported TUI contexts.
216
- // The RPC peer's custom() returns a Promise even though the overlay is unavailable there; mode guards above prevent invocation.
217
- void custom(factory, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%", anchor: "top-left", row: 0, col: 0 } }).catch(() => undefined);
498
+ // custom() resolves undefined outside supported TUI contexts. The RPC peer's
499
+ // custom() returns a Promise even though mode guards prevent invocation there.
500
+ try {
501
+ void Promise.resolve(custom.call(host.ui, factory, {
502
+ overlay: true,
503
+ overlayOptions: {
504
+ width: kind === "detail" ? "85%" : "80%",
505
+ maxHeight: kind === "detail" ? "80%" : "70%",
506
+ anchor: "center",
507
+ },
508
+ })).catch(() => release());
509
+ } catch {
510
+ // A synchronously throwing custom() must not leave the singleton latched open.
511
+ release();
512
+ }
218
513
  }