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/panel.ts CHANGED
@@ -1,13 +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
4
  import { stripAnsi, visibleText } from "../core/output.ts";
6
5
  import { formatDuration } from "../core/duration.ts";
7
- import { fitWidth } from "./width.ts";
6
+ import { displayWidth, fitWidth, truncateToWidth } from "./width.ts";
8
7
  import { clearDoneStatus, survivesClearCutoff } from "./status.ts";
9
8
  import { tuiHost } from "./host.ts";
10
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
+
11
39
  const isTui = (pi: unknown): boolean => {
12
40
  // Event/command contexts carry mode; the factory API does not. Both paths must resolve.
13
41
  const asCtx = pi as { mode?: string } | undefined;
@@ -15,34 +43,118 @@ const isTui = (pi: unknown): boolean => {
15
43
  };
16
44
 
17
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;
18
48
  const PANEL_ROWS = 12;
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"]);
19
55
  const visible = visibleText;
20
56
  /** Per-line sanitizer for multiline command display — keeps LF, strips controls/tabs. */
21
57
  const visibleLine = (value: unknown): string => stripAnsi(String(value ?? "")).replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "").replace(/[\t]+/g, " ");
22
- function readUiOutput(task: TaskSnapshot, registry: TaskRegistry): { text: string; truncated: boolean } {
23
- const outputPath = task.metadata.outputFile;
24
- if (typeof outputPath !== "string") return { text: registry.logs(task.taskId, { maxBytes: UI_TAIL_BYTES, tail: true }).text, truncated: false };
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 } {
25
122
  try {
26
- const fd = openSync(outputPath, "r");
27
- try {
28
- const size = fstatSync(fd).size;
29
- const start = Math.max(0, size - UI_TAIL_BYTES);
30
- const buffer = Buffer.alloc(Math.min(size, UI_TAIL_BYTES));
31
- readSync(fd, buffer, 0, buffer.length, start);
32
- return { text: stripAnsi(buffer.toString("utf8")), truncated: start > 0 };
33
- } finally { closeSync(fd); }
123
+ const slice = registry.logs(taskId, { maxBytes, tail: true });
124
+ return { text: stripAnsi(slice.text), truncated: slice.truncated };
34
125
  } catch {
35
- 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 };
36
128
  }
37
129
  }
38
130
 
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
+
39
150
  const frame = (title: string, body: string[], width: number, height: number): string[] => {
40
151
  const w = Math.max(8, Math.floor(width));
41
152
  const h = Math.max(4, Math.floor(height));
42
153
  // Title prefix is "╭─ " (3) + "─╮" (2) => budget w-5; body "│ "/" │" (2+2) => w-4.
43
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.
44
156
  const fitTitle = (text: string): string => fitWidth(text, w - 5);
45
- const fit = (text: string): string => fitWidth(text, w - 4);
157
+ const fit = (text: string): string => fitAnsiLine(text, w - 4);
46
158
  const content = [`╭─ ${fitTitle(title)}─╮`, ...body.slice(0, h - 2).map((line) => `│ ${fit(line)} │`), `╰${"─".repeat(w - 2)}╯`];
47
159
  while (content.length < height) content.splice(content.length - 1, 0, `│${" ".repeat(w - 2)}│`);
48
160
  // The solid background masks chat rows beneath the overlay, as in legion-viz's live overlay.
@@ -60,9 +172,9 @@ export function registerPanelCommands(pi: PiCtx, registry: TaskRegistry): void {
60
172
  // Commands/shortcuts are factory-time APIs (they exist on the factory pi); TUI
61
173
  // gating comes from the bound event context. Handlers receive a rich ctx with ui.
62
174
  const bound = tuiHost();
175
+ // SAFETY: the factory host may omit registration methods; optional chaining is intentional.
63
176
  const host = pi as unknown as {
64
177
  registerCommand?: (name: string, spec: { description: string; handler: (args: unknown, ctx: PiCtx) => void }) => void;
65
- registerShortcut?: (key: string, spec: { description: string; handler: (ctx: PiCtx) => void }) => void;
66
178
  };
67
179
  if (!bound) return;
68
180
  host.registerCommand?.("async", { description: "Open async task panel", handler: (_args, ctx) => openPanel(ctx, registry) });
@@ -73,21 +185,17 @@ export function registerPanelCommands(pi: PiCtx, registry: TaskRegistry): void {
73
185
  clearDoneStatus(); // shared watermark in status.ts (#31)
74
186
  },
75
187
  });
76
- // CONTRACT: PI_ASYNC_DOCK_SHORTCUT is disableable ("可 off"). Treat
77
- // off/none/false/empty (case-insensitive, trimmed) as skip registration —
78
- // otherwise "off" would be registered as a literal key name (#30).
79
- // Unset stays the default; only an explicitly set disable value skips.
80
- const rawShortcut = process.env.PI_ASYNC_DOCK_SHORTCUT;
81
- const normalized = rawShortcut === undefined ? undefined : rawShortcut.trim().toLowerCase();
82
- const disabled = normalized !== undefined && ["off", "none", "false", ""].includes(normalized);
83
- if (!disabled) {
84
- const key = rawShortcut && rawShortcut.trim() ? rawShortcut.trim() : "ctrl+alt+a";
85
- try {
86
- host.registerShortcut?.(key, { description: "Open async task panel", handler: (ctx) => openPanel(ctx, registry) });
87
- } catch {
88
- // The /async command remains the fallback when Pi reserves this shortcut.
89
- }
90
- }
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;
91
199
  }
92
200
 
93
201
  export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: string): void {
@@ -96,18 +204,31 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
96
204
  const ctxUi = (pi as { ui?: Record<string, unknown> }).ui ?? tuiHost()?.ui;
97
205
  const host = {
98
206
  ui: ctxUi as {
99
- copyToClipboard?: (text: string) => void;
100
207
  custom?: (factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value?: unknown) => void) => unknown, options: unknown) => Promise<unknown>;
101
208
  notify?: (message: string, level?: string) => void;
102
209
  setStatus?: (key: string, text: string | undefined) => void;
103
210
  } | undefined,
104
211
  };
105
212
  if (!isTui(pi) || typeof host.ui?.custom !== "function") return;
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
+ };
106
223
  const snapshots = (): TaskSnapshot[] => registry.list({ includeHistory: true }).filter((task) => survivesClearCutoff(task));
107
224
  const custom = host.ui.custom;
108
- 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;
109
229
  let selected = initialTaskId ?? snapshots()[0]?.taskId;
110
230
  let detail = Boolean(initialTaskId);
231
+ const origin: "rail" | "list" = initialTaskId ? "rail" : "list";
111
232
  let history = true;
112
233
  let confirmKill = false;
113
234
  let scroll = 0;
@@ -117,9 +238,45 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
117
238
  let height = process.stdout.rows ?? 24;
118
239
  let lastLines: string[] = [];
119
240
  let actionError: string | undefined;
241
+ let closed = false;
242
+ let unsubscribe: (() => void) | undefined;
120
243
  const requestRender = (): void => {
121
- (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
+ }
122
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
+ }
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
+ }
123
280
  const filtered = (): TaskSnapshot[] => snapshots().filter((task) => history || task.status === "running" || task.status === "pending");
124
281
  const choose = (): TaskSnapshot | undefined => snapshots().find((task) => task.taskId === selected);
125
282
  const listBudget = (): number => Math.max(1, height - 3); // frame borders + hint row
@@ -132,47 +289,75 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
132
289
  listScroll = Math.max(0, Math.min(listScroll, Math.max(0, tasks.length - budget)));
133
290
  return listScroll;
134
291
  };
135
- const renderBody = (): string[] => {
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[] => {
136
317
  const tasks = filtered();
137
318
  if (!detail) {
138
319
  const start = ensureListWindow(tasks);
139
- const budget = listBudget();
140
- const rows = tasks.slice(start, start + budget).map((task) => {
141
- const output = task.status === "running" ? registry.logs(task.taskId, { maxBytes: UI_TAIL_BYTES, tail: true }).text.split(/\r?\n/).filter(Boolean).at(-1) : undefined;
320
+ const rows = tasks.slice(start, start + Math.max(1, budget - 1)).map((task) => {
321
+ const output = task.status === "running" ? latestOutputLine(task, registry) : undefined;
142
322
  return `${task.taskId === selected ? "▶" : " "} ${visible(task.name)} · ${task.status} · ${formatDuration(task, Date.now())}${output ? ` · ${visible(output)}` : ""}`;
143
323
  });
144
324
  return ["↑/↓ select · Enter details · h history · q close", ...rows];
145
325
  }
146
326
  const task = choose();
147
- 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`];
148
328
  const output = readUiOutput(task, registry);
149
329
  const outputLines = output.text.split(/\r?\n/).filter((line) => line.length > 0);
150
330
  scroll = Math.min(scroll, Math.max(0, outputLines.length - PANEL_ROWS));
151
331
  const tail = outputLines.slice(Math.max(0, outputLines.length - PANEL_ROWS - scroll), outputLines.length - scroll);
152
- const meta = JSON.stringify(task.metadata);
153
- const commandLines = String(task.command ?? "").split(/\r?\n/);
332
+ const lineBudget = Math.max(1, Math.floor(width) - 4);
154
333
  const killHint = task.status === "pending"
155
- ? "Cancel this pending task? press k again to confirm; any other key cancels"
156
- : "Kill this task? press k again to confirm; any other key cancels";
157
- return [
158
- `${visible(task.name)} [${task.taskId}]`, `status: ${task.status} · elapsed: ${formatDuration(task, Date.now())}`,
159
- "command:",
160
- ...commandLines.map((line) => ` ${visibleLine(line)}`),
161
- `metadata: ${meta}`, `output${output.truncated ? " (tail truncated)" : ""}:`,
162
- // registry.logs caps its own data at 50 KiB; the TUI intentionally reads a larger 128 KiB tail directly from the output file.
163
- ...tail.map(visible),
164
- ...(actionError ? [`error: ${visible(actionError)}`] : []),
165
- ...(confirmKill ? [killHint] : []),
166
- "Esc return · k kill/cancel · R rerun · c copy output path · ↑/↓ scroll",
167
- ];
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);
168
350
  };
169
351
  const render = (nextWidth?: number): string[] => {
170
- width = nextWidth ?? width;
171
- height = process.stdout.rows ?? height;
172
- lastLines = renderBody();
173
- 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.
174
360
  if (follow) scroll = 0;
175
- else scroll = Math.min(scroll, maxScroll);
176
361
  return frame(detail ? "Async task detail" : "Async tasks", lastLines, width, height);
177
362
  };
178
363
  const move = (delta: number): void => {
@@ -186,14 +371,34 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
186
371
  else if (detail && delta > 0) { scroll = Math.max(0, scroll - delta); follow = scroll === 0; }
187
372
  requestRender();
188
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
+ };
189
390
  let latchedId: string | undefined;
190
391
  return {
191
392
  render,
192
393
  handleInput(data: string): void {
193
394
  const key = data.toLowerCase();
194
- if (confirmKill && key !== "k") { confirmKill = false; requestRender(); return; }
195
- if (key === "q") { done(undefined); return; }
196
- if (isEscapeKey(data)) { if (detail) { detail = false; confirmKill = false; actionError = undefined; requestRender(); } else done(undefined); return; }
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
+ }
197
402
  if (key === "h" && !detail) {
198
403
  history = !history;
199
404
  const tasks = filtered();
@@ -203,19 +408,35 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
203
408
  return;
204
409
  }
205
410
  if (data === "\r" || data === "\n") { detail = true; scroll = 0; follow = true; actionError = undefined; requestRender(); return; }
206
- if (data === "\u001b[A" || data === "k") { if (detail && key === "k") {
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)) {
207
421
  if (!confirmKill) { confirmKill = true; requestRender(); return; }
208
422
  confirmKill = false;
209
- const task = choose();
210
- if (task?.status === "running" || task?.status === "pending") void registry.kill(task.taskId, "user");
211
- requestRender(); return;
212
- } move(-1); return; }
423
+ killSelected();
424
+ requestRender();
425
+ return;
426
+ }
427
+ if (data === "\u001b[A" || key === "k") { move(-1); return; }
213
428
  if (data === "\u001b[B" || key === "j") { move(1); return; }
214
429
  if (data === "R" && detail) {
215
430
  const task = choose();
216
431
  if (task) {
217
432
  try {
218
- const created = registry.submit({ command: task.command, name: task.name, mode: "background" });
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
+ });
219
440
  selected = created.taskId;
220
441
  scroll = 0;
221
442
  follow = true;
@@ -229,9 +450,10 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
229
450
  }
230
451
  if (data === "c" && detail) {
231
452
  const path = choose()?.metadata.outputFile;
453
+ // §2.9: pi has no copyToClipboard API — show the path so the user can copy it.
232
454
  if (typeof path === "string") {
233
- if (host.ui?.copyToClipboard) host.ui.copyToClipboard(path);
234
- else host.ui?.notify?.(path, "info");
455
+ host.ui?.notify?.(path, "info");
456
+ requestRender();
235
457
  }
236
458
  }
237
459
  },
@@ -270,10 +492,22 @@ export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: str
270
492
  requestRender();
271
493
  return { handled: true };
272
494
  },
273
- dispose(): void { void width; },
495
+ dispose(): void { teardown(); },
274
496
  };
275
497
  };
276
- // Required full-screen overlay geometry. custom() resolves undefined outside supported TUI contexts.
277
- // The RPC peer's custom() returns a Promise even though the overlay is unavailable there; mode guards above prevent invocation.
278
- 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
+ }
279
513
  }