pi-async 0.0.0-stage → 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/CHANGELOG.md +54 -0
- package/LICENSE +21 -0
- package/README.md +100 -2
- package/docs/CONTRACT.md +308 -0
- package/extensions/index.ts +138 -0
- package/package.json +55 -4
- package/src/core/bus.ts +36 -0
- package/src/core/duration.ts +16 -0
- package/src/core/env.ts +23 -0
- package/src/core/notify.ts +57 -0
- package/src/core/output.ts +52 -0
- package/src/core/registry.ts +543 -0
- package/src/core/spawn.ts +138 -0
- package/src/core/task.ts +102 -0
- package/src/core/watch.ts +308 -0
- package/src/guard/settle-guard.ts +75 -0
- package/src/tools/index.ts +131 -0
- package/src/tui/host.ts +30 -0
- package/src/tui/panel.ts +279 -0
- package/src/tui/rail.ts +206 -0
- package/src/tui/status.ts +63 -0
- package/src/tui/width.ts +53 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { PiCtx } from "../core/notify.ts";
|
|
2
|
+
import type { TaskRegistry } from "../core/registry.ts";
|
|
3
|
+
import { isTerminal, type WatchSpec } from "../core/task.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Wrap an extension-tool return value into pi's ToolResultMessage shape.
|
|
7
|
+
*
|
|
8
|
+
* pi 1.2.0 does NOT auto-wrap extension tool return values (the executor
|
|
9
|
+
* passes `await tool.execute(...)` through verbatim; only builtin tools
|
|
10
|
+
* return `{content:[{type:"text",...}]}`). A bare object without `content`
|
|
11
|
+
* crashes the TUI's ToolExecutionComponent.getTextOutput
|
|
12
|
+
* (`result.content.filter` on undefined) and persists as content:[] —
|
|
13
|
+
* see the user-reported uncaughtException on 2026-10-15.
|
|
14
|
+
*
|
|
15
|
+
* Every async_* tool MUST route its return value through this helper so the
|
|
16
|
+
* result satisfies the ToolResultMessage contract: content is an array of
|
|
17
|
+
* typed blocks, and the original payload rides along in `details` for tests
|
|
18
|
+
* and structured consumers.
|
|
19
|
+
*/
|
|
20
|
+
function toolResult(payload: unknown, isError = false): { content: Array<{ type: "text"; text: string }>; details: unknown; isError: boolean } {
|
|
21
|
+
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], details: { payload }, isError };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const promptGuidelines = "Check commands must not swallow errors with || true; map unknown states to failure; prefer structured output and jq -e; anchor output_matches regexes (compiled with multiline `m`: ^/$ are line boundaries) to avoid premature matches; prefer stdout_contains for simple cases and avoid nested quantifiers.";
|
|
25
|
+
const MAX_LOG_BYTES = 50 * 1024;
|
|
26
|
+
const schema = (properties: Record<string, unknown>, required: string[] = [], allOf: unknown[] = []) => ({ type: "object", properties, required, additionalProperties: false, ...(allOf.length ? { allOf } : {}) });
|
|
27
|
+
const stringField = { type: "string" };
|
|
28
|
+
const timeoutField = { type: "number", minimum: 0 };
|
|
29
|
+
const conditionSchema = {
|
|
30
|
+
oneOf: [
|
|
31
|
+
{ type: "object", properties: { type: { const: "exit_code" }, equals: { type: "number" } }, required: ["type", "equals"], additionalProperties: false },
|
|
32
|
+
...["stdout_contains", "stderr_contains"].map((type) => ({ type: "object", properties: { type: { const: type }, value: stringField }, required: ["type", "value"], additionalProperties: false })),
|
|
33
|
+
{ type: "object", properties: {
|
|
34
|
+
type: { const: "json_path_exists" },
|
|
35
|
+
path: { type: "string", pattern: "^\\$", description: "JSONPath rooted at $; e.g. $.ready or $.items[0].state (bare names like \"ready\" are rejected)" },
|
|
36
|
+
}, required: ["type", "path"], additionalProperties: false },
|
|
37
|
+
{ type: "object", properties: { type: { const: "output_matches" }, regex: stringField }, required: ["type", "regex"], additionalProperties: false },
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
const watchSchema = {
|
|
41
|
+
type: "object", properties: {
|
|
42
|
+
intervalMs: { type: "number", minimum: 1000 }, timeoutMs: { type: "number", minimum: 1 },
|
|
43
|
+
success_when: { type: "array", items: conditionSchema }, failure_when: { type: "array", items: conditionSchema },
|
|
44
|
+
blind_checks: { type: "number", minimum: 1 },
|
|
45
|
+
}, required: ["intervalMs", "timeoutMs", "success_when", "failure_when"], additionalProperties: false,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Register the frozen five-tool surface. */
|
|
49
|
+
export function registerTools(pi: PiCtx, registry: TaskRegistry): void {
|
|
50
|
+
type ToolDef = { name: string; label: string; description: string; parameters: unknown; execute: (toolCallId: string, params: Record<string, unknown>, signal?: AbortSignal) => Promise<unknown> };
|
|
51
|
+
// SAFETY: ExtensionAPI exposes registerTool at runtime; this local structural view keeps schemas dependency-free.
|
|
52
|
+
const api = pi as unknown as { registerTool?: (tool: ToolDef) => void };
|
|
53
|
+
if (!api.registerTool) throw new Error("Pi host does not expose registerTool");
|
|
54
|
+
api.registerTool({
|
|
55
|
+
name: "async_run", label: "Run async command",
|
|
56
|
+
description: `Run a background, delayed, or synchronous shell command. mode=sync requires timeoutMs; mode=delayed requires delayMs. onTimeout (sync only): "promote" (default) hands the still-running task to background and returns; "abort" stops WAITING only — the command keeps running in the background and will still notify on completion (Esc semantics; does not kill). ${promptGuidelines}`,
|
|
57
|
+
// Mode-conditional required fields + mode-scoped rejects enforced in execute (#45/#42),
|
|
58
|
+
// not allOf/then (host validator emits opaque "must match then schema").
|
|
59
|
+
parameters: schema({ command: stringField, name: stringField, mode: { type: "string", enum: ["background", "sync", "delayed"], default: "background" }, timeoutMs: timeoutField, delayMs: timeoutField, taskTimeoutMs: timeoutField, onTimeout: { type: "string", enum: ["promote", "abort"], default: "promote" }, notifyOnCompletion: { type: "boolean" }, triggerOnCompletion: { type: "boolean" }, env: { type: "object", additionalProperties: { type: "string" } }, watch: watchSchema }, ["command"]),
|
|
60
|
+
execute: async (_id, params, signal) => {
|
|
61
|
+
const mode = String(params.mode ?? "background");
|
|
62
|
+
if (mode !== "background" && mode !== "sync" && mode !== "delayed") throw new Error(`Unsupported async_run mode: ${mode}`);
|
|
63
|
+
const command = String(params.command ?? "");
|
|
64
|
+
if (mode === "sync" && params.timeoutMs === undefined) throw new Error("timeoutMs is required for sync mode");
|
|
65
|
+
if (mode === "delayed" && params.delayMs === undefined) throw new Error("delayMs is required for delayed mode");
|
|
66
|
+
if (params.watch !== undefined && mode === "sync") throw new Error("watch is only supported for background and delayed modes");
|
|
67
|
+
if (mode !== "sync" && params.timeoutMs !== undefined) throw new Error("timeoutMs is only valid for sync mode (use taskTimeoutMs to bound background/delayed tasks)");
|
|
68
|
+
if (mode !== "delayed" && params.delayMs !== undefined) throw new Error("delayMs is only valid for delayed mode");
|
|
69
|
+
const options = {
|
|
70
|
+
command,
|
|
71
|
+
...(typeof params.name === "string" ? { name: params.name } : {}),
|
|
72
|
+
...(typeof params.taskTimeoutMs === "number" ? { taskTimeoutMs: params.taskTimeoutMs } : {}),
|
|
73
|
+
...(typeof params.notifyOnCompletion === "boolean" ? { notifyOnCompletion: params.notifyOnCompletion } : {}),
|
|
74
|
+
...(typeof params.triggerOnCompletion === "boolean" ? { triggerOnCompletion: params.triggerOnCompletion } : {}),
|
|
75
|
+
...(params.env && typeof params.env === "object" ? { env: params.env as Record<string, string> } : {}),
|
|
76
|
+
...(params.watch && typeof params.watch === "object" ? { watch: params.watch as WatchSpec } : {}),
|
|
77
|
+
};
|
|
78
|
+
if (mode === "sync") { const r = await registry.runSync({ ...options, mode: "sync" }, Number(params.timeoutMs), params.onTimeout === "abort" ? "abort" : "promote", signal); return toolResult(r); }
|
|
79
|
+
const task = mode === "delayed"
|
|
80
|
+
? registry.scheduleDelayed({ ...options, mode: "delayed" }, Number(params.delayMs))
|
|
81
|
+
: registry.submit({ ...options, mode: "background" });
|
|
82
|
+
return toolResult({ taskId: task.taskId, name: task.name, status: task.status, mode: task.mode, outputHint: `Use async_logs with taskId ${task.taskId} to inspect early output.` });
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
api.registerTool({ name: "async_wait", label: "Wait for async tasks", description: "Wait for tasks to finish or return notReady; waiting does not kill tasks.", parameters: schema({ taskIds: { type: "array", items: stringField }, timeoutMs: timeoutField }, ["taskIds", "timeoutMs"]), execute: async (_id, params, signal) => {
|
|
86
|
+
if (params.timeoutMs === undefined) throw new Error("timeoutMs is required");
|
|
87
|
+
return toolResult(await registry.wait(params.taskIds as string[], Number(params.timeoutMs), signal));
|
|
88
|
+
} });
|
|
89
|
+
api.registerTool({ name: "async_logs", label: "Read async output", description: "Read bounded, ANSI-stripped task output.", parameters: schema({ taskId: stringField, maxBytes: { type: "number", minimum: 0, maximum: MAX_LOG_BYTES }, tail: { type: "boolean" } }, ["taskId"]), execute: async (_id, params) => {
|
|
90
|
+
const maxBytes = params.maxBytes === undefined ? MAX_LOG_BYTES : Number(params.maxBytes);
|
|
91
|
+
if (maxBytes > MAX_LOG_BYTES) throw new Error("maxBytes must be at most 50 KiB");
|
|
92
|
+
return toolResult(registry.logs(String(params.taskId), { maxBytes, tail: params.tail !== false }));
|
|
93
|
+
} });
|
|
94
|
+
api.registerTool({
|
|
95
|
+
name: "async_status", label: "Async task status",
|
|
96
|
+
description: "List async tasks and current status. Defaults to active (running/pending) only; pass includeHistory:true for up to 50 recent tasks.",
|
|
97
|
+
parameters: schema({ includeHistory: { type: "boolean" } }),
|
|
98
|
+
execute: async (_id, params) => {
|
|
99
|
+
const MAX_HISTORY = 50;
|
|
100
|
+
if (params.includeHistory === true) {
|
|
101
|
+
const all = registry.list({ includeHistory: true });
|
|
102
|
+
return toolResult(all.slice(-MAX_HISTORY));
|
|
103
|
+
}
|
|
104
|
+
return toolResult(registry.list({ includeHistory: false }));
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
api.registerTool({
|
|
108
|
+
name: "async_kill", label: "Kill async task", description: "Terminate an async task and its process group.",
|
|
109
|
+
parameters: schema({ taskId: stringField }, ["taskId"]),
|
|
110
|
+
execute: async (_id, params) => {
|
|
111
|
+
const taskId = String(params.taskId);
|
|
112
|
+
const before = registry.get(taskId);
|
|
113
|
+
const alreadyTerminal = before !== undefined && isTerminal(before.status);
|
|
114
|
+
let warning: string | undefined;
|
|
115
|
+
try {
|
|
116
|
+
await registry.kill(taskId, "user");
|
|
117
|
+
} catch (error) {
|
|
118
|
+
// Kill may still have finalized the task; surface confirmation failures as warning (#6).
|
|
119
|
+
warning = error instanceof Error ? error.message : String(error);
|
|
120
|
+
}
|
|
121
|
+
const after = registry.get(taskId);
|
|
122
|
+
return toolResult({
|
|
123
|
+
taskId,
|
|
124
|
+
status: after?.status,
|
|
125
|
+
...(after?.killKind ? { killKind: after.killKind } : {}),
|
|
126
|
+
alreadyTerminal,
|
|
127
|
+
...(warning ? { warning } : {}),
|
|
128
|
+
}, Boolean(warning && after?.status !== "killed"));
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
}
|
package/src/tui/host.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bound TUI host context.
|
|
3
|
+
*
|
|
4
|
+
* pi's factory-time ExtensionAPI exposes neither `mode` nor `ui` — those live
|
|
5
|
+
* on event-handler contexts (`pi.on(event, (event, ctx) => ...)`), and `ctx.ui`
|
|
6
|
+
* is the only object whose setWidget/setStatus/custom actually work. All TUI
|
|
7
|
+
* surfaces therefore bind lazily from the first event context whose
|
|
8
|
+
* `mode === "tui"`, and read `tuiHost()` afterwards.
|
|
9
|
+
*/
|
|
10
|
+
export type TuiHost = { mode: string; ui: Record<string, unknown> };
|
|
11
|
+
|
|
12
|
+
let bound: TuiHost | undefined;
|
|
13
|
+
|
|
14
|
+
/** Bind the first TUI-capable event context. Idempotent; only the first wins. */
|
|
15
|
+
export function bindTuiHost(ctx: unknown): boolean {
|
|
16
|
+
const c = ctx as { mode?: string; ui?: Record<string, unknown> } | undefined;
|
|
17
|
+
if (bound || c?.mode !== "tui" || !c.ui) return false;
|
|
18
|
+
bound = { mode: c.mode, ui: c.ui };
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The bound TUI host, or undefined when no TUI context has been seen. */
|
|
23
|
+
export function tuiHost(): TuiHost | undefined {
|
|
24
|
+
return bound;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Test-only: forget the bound host so isolated unit tests can re-bind. */
|
|
28
|
+
export function resetTuiHostForTests(): void {
|
|
29
|
+
bound = undefined;
|
|
30
|
+
}
|
package/src/tui/panel.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { openSync, closeSync, fstatSync, readSync } from "node:fs";
|
|
2
|
+
import type { PiCtx } from "../core/notify.ts";
|
|
3
|
+
import type { TaskRegistry } from "../core/registry.ts";
|
|
4
|
+
import type { TaskSnapshot } from "../core/task.ts";
|
|
5
|
+
import { stripAnsi, visibleText } from "../core/output.ts";
|
|
6
|
+
import { formatDuration } from "../core/duration.ts";
|
|
7
|
+
import { fitWidth } from "./width.ts";
|
|
8
|
+
import { clearDoneStatus, survivesClearCutoff } from "./status.ts";
|
|
9
|
+
import { tuiHost } from "./host.ts";
|
|
10
|
+
|
|
11
|
+
const isTui = (pi: unknown): boolean => {
|
|
12
|
+
// Event/command contexts carry mode; the factory API does not. Both paths must resolve.
|
|
13
|
+
const asCtx = pi as { mode?: string } | undefined;
|
|
14
|
+
return asCtx?.mode === "tui" || tuiHost()?.mode === "tui";
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const UI_TAIL_BYTES = 128 * 1024;
|
|
18
|
+
const PANEL_ROWS = 12;
|
|
19
|
+
const visible = visibleText;
|
|
20
|
+
/** Per-line sanitizer for multiline command display — keeps LF, strips controls/tabs. */
|
|
21
|
+
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 };
|
|
25
|
+
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); }
|
|
34
|
+
} catch {
|
|
35
|
+
return { text: registry.logs(task.taskId, { maxBytes: UI_TAIL_BYTES, tail: true }).text, truncated: false };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const frame = (title: string, body: string[], width: number, height: number): string[] => {
|
|
40
|
+
const w = Math.max(8, Math.floor(width));
|
|
41
|
+
const h = Math.max(4, Math.floor(height));
|
|
42
|
+
// Title prefix is "╭─ " (3) + "─╮" (2) => budget w-5; body "│ "/" │" (2+2) => w-4.
|
|
43
|
+
// #55 keeps fitTitle(w-5); #7/#62 routes both through fitWidth (cell-aware).
|
|
44
|
+
const fitTitle = (text: string): string => fitWidth(text, w - 5);
|
|
45
|
+
const fit = (text: string): string => fitWidth(text, w - 4);
|
|
46
|
+
const content = [`╭─ ${fitTitle(title)}─╮`, ...body.slice(0, h - 2).map((line) => `│ ${fit(line)} │`), `╰${"─".repeat(w - 2)}╯`];
|
|
47
|
+
while (content.length < height) content.splice(content.length - 1, 0, `│${" ".repeat(w - 2)}│`);
|
|
48
|
+
// The solid background masks chat rows beneath the overlay, as in legion-viz's live overlay.
|
|
49
|
+
return content;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** Bare Esc, Kitty CSI-u Escape (key 27), and a lone CSI introducer. */
|
|
53
|
+
export function isEscapeKey(data: string): boolean {
|
|
54
|
+
if (data === "\u001b") return true;
|
|
55
|
+
if (data === "\u001b[") return true;
|
|
56
|
+
return /^\u001b\[27(?:;\d+)*u$/.test(data);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function registerPanelCommands(pi: PiCtx, registry: TaskRegistry): void {
|
|
60
|
+
// Commands/shortcuts are factory-time APIs (they exist on the factory pi); TUI
|
|
61
|
+
// gating comes from the bound event context. Handlers receive a rich ctx with ui.
|
|
62
|
+
const bound = tuiHost();
|
|
63
|
+
const host = pi as unknown as {
|
|
64
|
+
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
|
+
};
|
|
67
|
+
if (!bound) return;
|
|
68
|
+
host.registerCommand?.("async", { description: "Open async task panel", handler: (_args, ctx) => openPanel(ctx, registry) });
|
|
69
|
+
host.registerCommand?.("async-clear", {
|
|
70
|
+
description: "Clear async task history",
|
|
71
|
+
handler: (_args, ctx) => {
|
|
72
|
+
if (!isTui(ctx)) return;
|
|
73
|
+
clearDoneStatus(); // shared watermark in status.ts (#31)
|
|
74
|
+
},
|
|
75
|
+
});
|
|
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
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function openPanel(pi: PiCtx, registry: TaskRegistry, initialTaskId?: string): void {
|
|
94
|
+
// Event/command contexts carry a working ui; rail-click paths pass the factory
|
|
95
|
+
// pi, whose ui is undefined — fall back to the bound TUI host in that case.
|
|
96
|
+
const ctxUi = (pi as { ui?: Record<string, unknown> }).ui ?? tuiHost()?.ui;
|
|
97
|
+
const host = {
|
|
98
|
+
ui: ctxUi as {
|
|
99
|
+
copyToClipboard?: (text: string) => void;
|
|
100
|
+
custom?: (factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value?: unknown) => void) => unknown, options: unknown) => Promise<unknown>;
|
|
101
|
+
notify?: (message: string, level?: string) => void;
|
|
102
|
+
setStatus?: (key: string, text: string | undefined) => void;
|
|
103
|
+
} | undefined,
|
|
104
|
+
};
|
|
105
|
+
if (!isTui(pi) || typeof host.ui?.custom !== "function") return;
|
|
106
|
+
const snapshots = (): TaskSnapshot[] => registry.list({ includeHistory: true }).filter((task) => survivesClearCutoff(task));
|
|
107
|
+
const custom = host.ui.custom;
|
|
108
|
+
const factory = (tui: unknown, _theme: unknown, _keybindings: unknown, done: (value?: unknown) => void) => {
|
|
109
|
+
let selected = initialTaskId ?? snapshots()[0]?.taskId;
|
|
110
|
+
let detail = Boolean(initialTaskId);
|
|
111
|
+
let history = true;
|
|
112
|
+
let confirmKill = false;
|
|
113
|
+
let scroll = 0;
|
|
114
|
+
let listScroll = 0;
|
|
115
|
+
let follow = true;
|
|
116
|
+
let width = 100;
|
|
117
|
+
let height = process.stdout.rows ?? 24;
|
|
118
|
+
let lastLines: string[] = [];
|
|
119
|
+
let actionError: string | undefined;
|
|
120
|
+
const requestRender = (): void => {
|
|
121
|
+
(tui as { requestRender?: () => void } | undefined)?.requestRender?.();
|
|
122
|
+
};
|
|
123
|
+
const filtered = (): TaskSnapshot[] => snapshots().filter((task) => history || task.status === "running" || task.status === "pending");
|
|
124
|
+
const choose = (): TaskSnapshot | undefined => snapshots().find((task) => task.taskId === selected);
|
|
125
|
+
const listBudget = (): number => Math.max(1, height - 3); // frame borders + hint row
|
|
126
|
+
const ensureListWindow = (tasks: TaskSnapshot[]): number => {
|
|
127
|
+
const budget = listBudget();
|
|
128
|
+
const selIndex = tasks.findIndex((task) => task.taskId === selected);
|
|
129
|
+
const index = selIndex < 0 ? 0 : selIndex;
|
|
130
|
+
if (index < listScroll) listScroll = index;
|
|
131
|
+
if (index >= listScroll + budget) listScroll = index - budget + 1;
|
|
132
|
+
listScroll = Math.max(0, Math.min(listScroll, Math.max(0, tasks.length - budget)));
|
|
133
|
+
return listScroll;
|
|
134
|
+
};
|
|
135
|
+
const renderBody = (): string[] => {
|
|
136
|
+
const tasks = filtered();
|
|
137
|
+
if (!detail) {
|
|
138
|
+
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;
|
|
142
|
+
return `${task.taskId === selected ? "▶" : " "} ${visible(task.name)} · ${task.status} · ${formatDuration(task, Date.now())}${output ? ` · ${visible(output)}` : ""}`;
|
|
143
|
+
});
|
|
144
|
+
return ["↑/↓ select · Enter details · h history · q close", ...rows];
|
|
145
|
+
}
|
|
146
|
+
const task = choose();
|
|
147
|
+
if (!task) return ["Task not found", "Esc return · q close"];
|
|
148
|
+
const output = readUiOutput(task, registry);
|
|
149
|
+
const outputLines = output.text.split(/\r?\n/).filter((line) => line.length > 0);
|
|
150
|
+
scroll = Math.min(scroll, Math.max(0, outputLines.length - PANEL_ROWS));
|
|
151
|
+
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/);
|
|
154
|
+
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
|
+
];
|
|
168
|
+
};
|
|
169
|
+
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);
|
|
174
|
+
if (follow) scroll = 0;
|
|
175
|
+
else scroll = Math.min(scroll, maxScroll);
|
|
176
|
+
return frame(detail ? "Async task detail" : "Async tasks", lastLines, width, height);
|
|
177
|
+
};
|
|
178
|
+
const move = (delta: number): void => {
|
|
179
|
+
const tasks = filtered();
|
|
180
|
+
const index = tasks.findIndex((task) => task.taskId === selected);
|
|
181
|
+
// Harden dangling selection (filtered away): start from edge rather than relying on -1+delta===0 alone.
|
|
182
|
+
const base = index < 0 ? (delta > 0 ? -1 : 0) : index;
|
|
183
|
+
const next = tasks[Math.max(0, Math.min(tasks.length - 1, base + delta))];
|
|
184
|
+
if (next) selected = next.taskId;
|
|
185
|
+
if (detail && delta < 0) { scroll += -delta; follow = false; }
|
|
186
|
+
else if (detail && delta > 0) { scroll = Math.max(0, scroll - delta); follow = scroll === 0; }
|
|
187
|
+
requestRender();
|
|
188
|
+
};
|
|
189
|
+
let latchedId: string | undefined;
|
|
190
|
+
return {
|
|
191
|
+
render,
|
|
192
|
+
handleInput(data: string): void {
|
|
193
|
+
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; }
|
|
197
|
+
if (key === "h" && !detail) {
|
|
198
|
+
history = !history;
|
|
199
|
+
const tasks = filtered();
|
|
200
|
+
if (!tasks.some((task) => task.taskId === selected)) selected = tasks[0]?.taskId;
|
|
201
|
+
listScroll = 0;
|
|
202
|
+
requestRender();
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
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") {
|
|
207
|
+
if (!confirmKill) { confirmKill = true; requestRender(); return; }
|
|
208
|
+
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; }
|
|
213
|
+
if (data === "\u001b[B" || key === "j") { move(1); return; }
|
|
214
|
+
if (data === "R" && detail) {
|
|
215
|
+
const task = choose();
|
|
216
|
+
if (task) {
|
|
217
|
+
try {
|
|
218
|
+
const created = registry.submit({ command: task.command, name: task.name, mode: "background" });
|
|
219
|
+
selected = created.taskId;
|
|
220
|
+
scroll = 0;
|
|
221
|
+
follow = true;
|
|
222
|
+
actionError = undefined;
|
|
223
|
+
} catch (error) {
|
|
224
|
+
actionError = String(error);
|
|
225
|
+
host.ui?.notify?.(actionError, "error");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
requestRender(); return;
|
|
229
|
+
}
|
|
230
|
+
if (data === "c" && detail) {
|
|
231
|
+
const path = choose()?.metadata.outputFile;
|
|
232
|
+
if (typeof path === "string") {
|
|
233
|
+
if (host.ui?.copyToClipboard) host.ui.copyToClipboard(path);
|
|
234
|
+
else host.ui?.notify?.(path, "info");
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
handleMouse(event: { type: string; y?: number; button?: string; wheelDelta?: number }): { handled: boolean } | undefined {
|
|
239
|
+
if (event.type === "wheel") {
|
|
240
|
+
const delta = Math.trunc(event.wheelDelta ?? 0);
|
|
241
|
+
if (detail) { scroll = Math.max(0, scroll + delta); follow = scroll === 0; }
|
|
242
|
+
else move(delta);
|
|
243
|
+
requestRender();
|
|
244
|
+
return { handled: true };
|
|
245
|
+
}
|
|
246
|
+
if (event.type === "press") {
|
|
247
|
+
latchedId = undefined;
|
|
248
|
+
if (detail) return undefined;
|
|
249
|
+
if (event.button !== "left") return undefined;
|
|
250
|
+
const tasks = filtered();
|
|
251
|
+
const start = ensureListWindow(tasks);
|
|
252
|
+
const index = (event.y ?? -1) - 2 + start;
|
|
253
|
+
const task = tasks[index];
|
|
254
|
+
if (!task) return undefined;
|
|
255
|
+
latchedId = task.taskId;
|
|
256
|
+
return { handled: true };
|
|
257
|
+
}
|
|
258
|
+
if (event.type !== "click" || event.button !== "left" || detail) return undefined;
|
|
259
|
+
const tasks = filtered();
|
|
260
|
+
const start = ensureListWindow(tasks);
|
|
261
|
+
const index = (event.y ?? -1) - 2 + start;
|
|
262
|
+
const task = tasks[index];
|
|
263
|
+
if (!latchedId || !task || task.taskId !== latchedId) { latchedId = undefined; return undefined; }
|
|
264
|
+
selected = latchedId;
|
|
265
|
+
detail = true;
|
|
266
|
+
scroll = 0;
|
|
267
|
+
follow = true;
|
|
268
|
+
actionError = undefined;
|
|
269
|
+
latchedId = undefined;
|
|
270
|
+
requestRender();
|
|
271
|
+
return { handled: true };
|
|
272
|
+
},
|
|
273
|
+
dispose(): void { void width; },
|
|
274
|
+
};
|
|
275
|
+
};
|
|
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);
|
|
279
|
+
}
|
package/src/tui/rail.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import type { PiCtx } from "../core/notify.ts";
|
|
2
|
+
import type { TaskRegistry } from "../core/registry.ts";
|
|
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";
|
|
8
|
+
import type { TUI, TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
9
|
+
import { openPanel } from "./panel.ts";
|
|
10
|
+
import { tuiHost } from "./host.ts";
|
|
11
|
+
|
|
12
|
+
type Theme = { fg(color: string, text: string): string; getFgAnsi?(color: string): string };
|
|
13
|
+
export interface RailWidget {
|
|
14
|
+
render(width?: number): string[];
|
|
15
|
+
handleMouse(event: TuiMouseEvent): { handled: boolean } | undefined;
|
|
16
|
+
handleInput?(data: string): { handled?: boolean } | void;
|
|
17
|
+
dispose?(): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Palette color selection adapted from legion-viz/palette.ts (MIT License). */
|
|
21
|
+
const TYPE_TOKENS = ["syntaxVariable", "syntaxKeyword", "syntaxNumber", "syntaxFunction", "syntaxString", "syntaxType"];
|
|
22
|
+
type Swatch = { kind: "rgb"; l: number; a: number; b: number } | { kind: "key"; key: string };
|
|
23
|
+
function ansiFor(theme: Theme, token: string): string {
|
|
24
|
+
// SAFETY: The peer theme exposes fg(); getFgAnsi is feature-detected for newer TUI versions.
|
|
25
|
+
const color = theme as Theme;
|
|
26
|
+
try { return color.getFgAnsi?.(token) ?? color.fg(token, "\0").split("\0")[0] ?? token; } catch { return token; }
|
|
27
|
+
}
|
|
28
|
+
function oklab(r: number, g: number, b: number): Swatch {
|
|
29
|
+
const linear = (n: number): number => { const v = n / 255; return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); };
|
|
30
|
+
const R = linear(r), G = linear(g), B = linear(b);
|
|
31
|
+
const l = Math.cbrt(0.4122214708 * R + 0.5363325363 * G + 0.0514459929 * B);
|
|
32
|
+
const m = Math.cbrt(0.2119034708 * R + 0.6806995451 * G + 0.1073969566 * B);
|
|
33
|
+
const s = Math.cbrt(0.0883024619 * R + 0.281718565 * G + 0.6299787005 * B);
|
|
34
|
+
return { kind: "rgb", l: 0.2104542553*l + 0.793617785*m - 0.0040720468*s, a: 1.9779984951*l - 2.428592205*m + 0.4505937099*s, b: 0.0259040371*l + 0.7827717662*m - 0.808675766*s };
|
|
35
|
+
}
|
|
36
|
+
function swatch(theme: Theme, token: string): Swatch {
|
|
37
|
+
const ansi = ansiFor(theme, token);
|
|
38
|
+
const rgb = /38;2;(\d+);(\d+);(\d+)/.exec(ansi);
|
|
39
|
+
if (rgb) return oklab(Number(rgb[1]), Number(rgb[2]), Number(rgb[3]));
|
|
40
|
+
const index = /38;5;(\d+)/.exec(ansi);
|
|
41
|
+
if (index) {
|
|
42
|
+
const n = Number(index[1]);
|
|
43
|
+
if (n >= 232) { const v = 8 + 10 * (n - 232); return oklab(v, v, v); }
|
|
44
|
+
if (n >= 16) { const i = n - 16, step = [0, 95, 135, 175, 215, 255]; return oklab(step[Math.floor(i / 36)]!, step[Math.floor(i / 6) % 6]!, step[i % 6]!); }
|
|
45
|
+
return { kind: "key", key: `p${n}` };
|
|
46
|
+
}
|
|
47
|
+
const basic = /(?:^|\[|;)(3[0-7]|9[0-7])m/.exec(ansi);
|
|
48
|
+
return { kind: "key", key: basic?.[1] ?? ansi };
|
|
49
|
+
}
|
|
50
|
+
function clashes(a: Swatch, b: Swatch): boolean {
|
|
51
|
+
if (a.kind === "key" && b.kind === "key") return a.key === b.key;
|
|
52
|
+
if (a.kind === "key" || b.kind === "key") return false;
|
|
53
|
+
return Math.hypot(a.l - b.l, a.a - b.a, a.b - b.b) < 0.025;
|
|
54
|
+
}
|
|
55
|
+
function typeColor(theme: Theme, mode: string, used: Swatch[]): string {
|
|
56
|
+
const modeIndex = ["background", "sync", "delayed"].indexOf(mode);
|
|
57
|
+
const preferred = TYPE_TOKENS[modeIndex < 0 ? 0 : modeIndex]!;
|
|
58
|
+
for (const token of [preferred, ...TYPE_TOKENS]) {
|
|
59
|
+
const color = swatch(theme, token);
|
|
60
|
+
if (!used.some((previous) => clashes(color, previous))) { used.push(color); return token; }
|
|
61
|
+
}
|
|
62
|
+
return preferred;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const statePriority: Record<TaskSnapshot["status"], number> = { running: 0, pending: 1, completed: 2, failed: 3, killed: 4 };
|
|
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)));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Pure, unstyled rail rows; active tasks first and a hard ten-row limit. */
|
|
75
|
+
export function renderRailRows(snapshots: TaskSnapshot[], width: number, now = Date.now()): string[] {
|
|
76
|
+
const rows = snapshots
|
|
77
|
+
.slice().sort((a, b) => statePriority[a.status] - statePriority[b.status] || (b.startedAt ?? b.createdAt) - (a.startedAt ?? a.createdAt))
|
|
78
|
+
.slice(0, 10)
|
|
79
|
+
.map((task) => `▶ ${visibleText(task.name)} · ${task.status} · ${formatDuration(task, now)}`);
|
|
80
|
+
const limit = Math.max(0, Math.floor(width));
|
|
81
|
+
return rows.map((row) => truncateToWidth(row, limit));
|
|
82
|
+
}
|
|
83
|
+
|
|
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 });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const RAIL_PAGE = 5;
|
|
90
|
+
|
|
91
|
+
export function createRailFactory(pi: PiCtx, registry: TaskRegistry, opts: { onOpenTask?: (taskId: string) => void } = {}): (tui: TUI, theme: Theme) => RailWidget {
|
|
92
|
+
// Factory signature includes the extension context for future host-specific rail integrations.
|
|
93
|
+
void pi;
|
|
94
|
+
return (tui, theme) => {
|
|
95
|
+
let scroll = 0;
|
|
96
|
+
let hitRows: string[] = [];
|
|
97
|
+
let latchedId: string | undefined;
|
|
98
|
+
// Terminal tasks linger briefly so the user sees the outcome, then drop off
|
|
99
|
+
// 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)
|
|
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
|
+
};
|
|
119
|
+
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;
|
|
124
|
+
clampScroll(tasks.length, taskSlots);
|
|
125
|
+
const visible = tasks.slice(scroll, scroll + taskSlots);
|
|
126
|
+
hitRows = visible.map((task) => task.taskId);
|
|
127
|
+
const used = [swatch(theme, "error"), swatch(theme, "dim")];
|
|
128
|
+
const rows = renderRailRows(visible, width).map((row, index) => {
|
|
129
|
+
const task = visible[index];
|
|
130
|
+
if (!task) return row;
|
|
131
|
+
let token = "dim";
|
|
132
|
+
if (task.status === "running") token = typeColor(theme, task.mode, used);
|
|
133
|
+
else if (task.status === "completed") token = "success";
|
|
134
|
+
else if (task.status === "failed") token = "error";
|
|
135
|
+
try {
|
|
136
|
+
return theme.fg(token, row) ?? row;
|
|
137
|
+
} catch {
|
|
138
|
+
return row;
|
|
139
|
+
}
|
|
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;
|
|
159
|
+
};
|
|
160
|
+
return {
|
|
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
|
+
},
|
|
172
|
+
handleMouse(event: TuiMouseEvent): { handled: boolean } | undefined {
|
|
173
|
+
const mouse = event as TuiMouseEvent & { type: string; y: number; button?: string; wheelDelta?: number };
|
|
174
|
+
if (mouse.type === "wheel") {
|
|
175
|
+
const count = ordered().length;
|
|
176
|
+
if (count <= 10) return undefined;
|
|
177
|
+
const taskSlots = 9;
|
|
178
|
+
const next = Math.max(0, Math.min(count - taskSlots, scroll + Math.trunc(mouse.wheelDelta ?? 0)));
|
|
179
|
+
if (next === scroll) return undefined;
|
|
180
|
+
scroll = next; latchedId = undefined; tui.requestRender(); return { handled: true };
|
|
181
|
+
}
|
|
182
|
+
if (mouse.type === "press") {
|
|
183
|
+
latchedId = undefined;
|
|
184
|
+
if (mouse.button !== "left") return undefined;
|
|
185
|
+
const id = hitRows[mouse.y];
|
|
186
|
+
if (!id) return undefined;
|
|
187
|
+
latchedId = id; return { handled: true };
|
|
188
|
+
}
|
|
189
|
+
if (mouse.type !== "click" || mouse.button !== "left") return undefined;
|
|
190
|
+
const id = hitRows[mouse.y];
|
|
191
|
+
if (!latchedId || id !== latchedId) { latchedId = undefined; return undefined; }
|
|
192
|
+
const clicked = latchedId; latchedId = undefined; opts.onOpenTask?.(clicked); return { handled: true };
|
|
193
|
+
},
|
|
194
|
+
dispose(): void { latchedId = undefined; },
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The sole rail widget registration point. */
|
|
200
|
+
export function registerRail(pi: PiCtx, registry: TaskRegistry): void {
|
|
201
|
+
// TUI surfaces bind from event contexts; the factory API has no mode/ui.
|
|
202
|
+
const bound = tuiHost();
|
|
203
|
+
if (!bound) return;
|
|
204
|
+
const setWidget = bound.ui["setWidget"] as ((key: string, factory: ReturnType<typeof createRailFactory> | undefined, options?: { placement: "aboveEditor" }) => void) | undefined;
|
|
205
|
+
setWidget?.("pi-async-rail", createRailFactory(pi, registry, { onOpenTask: (id) => openPanel(pi, registry, id) }), { placement: "aboveEditor" });
|
|
206
|
+
}
|