omkit 0.8.23 → 0.8.25

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/README.md CHANGED
@@ -83,7 +83,7 @@ om("dev")
83
83
  Run it:
84
84
 
85
85
  ```sh
86
- npx omkit # interactive picker: browse, run, watch milestones live
86
+ npx omkit # interactive shell: pick, run, watch, stop, rerun — until you quit
87
87
  npx omkit run dev # or run one om directly
88
88
  ```
89
89
 
@@ -116,12 +116,12 @@ om(name: string): OmBuilder
116
116
 
117
117
  Returns a builder. Chain configuration, then finish with `.run(body)`, which launches the run.
118
118
 
119
- | Method | Purpose |
120
- | ------------------------ | ------------------------------------------------------------------------------------------------------------- |
121
- | `.describe({ summary })` | A one-line summary, shown by `omkit ls --describe`, the skill file, MCP and the report. |
122
- | `.args(schema)` | A zod schema for the run's inputs. **Resolved at runtime**; see below. |
123
- | `.mcp({ mode? })` | Expose this om over MCP. `mode` is `"settling"` (default) or `"long-lived"`. |
124
- | `.run(body)` | Launch the run. `body(ctx, args?)` receives an [`OmContext`](#contexts) and, after `.args()`, the typed args. |
119
+ | Method | Purpose |
120
+ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
121
+ | `.describe({ summary, … })` | A one-line summary, shown by `omkit ls --describe`, the skill file, MCP and the report. Any further props (`owner`, `docsUrl`, `needs: [...]`) are listed under it in the interactive picker and the report. |
122
+ | `.args(schema)` | A zod schema for the run's inputs. **Resolved at runtime**; see below. |
123
+ | `.mcp({ mode? })` | Expose this om over MCP. `mode` is `"settling"` (default) or `"long-lived"`. |
124
+ | `.run(body)` | Launch the run. `body(ctx, args?)` receives an [`OmContext`](#contexts) and, after `.args()`, the typed args. |
125
125
 
126
126
  `.run()` returns a promise that resolves once the run has torn down and written its record. It never rejects: failures are recorded in the run and set the process exit code. Whatever the body returns is recorded as the run's `value` in `result.json`.
127
127
 
@@ -156,14 +156,14 @@ import { action } from "omkit";
156
156
  action(name: string): ActionBuilder
157
157
  ```
158
158
 
159
- | Method | Purpose |
160
- | ------------------------ | ------------------------------------------------------------------------------------- |
161
- | `.describe({ summary })` | A one-line summary, carried onto every run of this action. |
162
- | `.emits<Events>()` | Declare the events this action emits, as a type: `{ ready: number; error: string }`. |
163
- | `.ref<Handle>()` | Declare the type of the value this action publishes with `attach`. |
164
- | `.args(schema)` | Pin `.run`'s argument to a zod schema's type. **Type-level only** in code; see below. |
165
- | `.mcp({ mode? })` | Expose this action over MCP as a runnable tool. |
166
- | `.run(exec)` | Bind the implementation. Returns an **Action**: a function you call to launch it. |
159
+ | Method | Purpose |
160
+ | --------------------------- | ----------------------------------------------------------------------------------------------------------- |
161
+ | `.describe({ summary, … })` | A one-line summary, plus any further props, carried onto every run of this action and listed in the report. |
162
+ | `.emits<Events>()` | Declare the events this action emits, as a type: `{ ready: number; error: string }`. |
163
+ | `.ref<Handle>()` | Declare the type of the value this action publishes with `attach`. |
164
+ | `.args(schema)` | Pin `.run`'s argument to a zod schema's type. **Type-level only** in code; see below. |
165
+ | `.mcp({ mode? })` | Expose this action over MCP as a runnable tool. |
166
+ | `.run(exec)` | Bind the implementation. Returns an **Action**: a function you call to launch it. |
167
167
 
168
168
  `exec(ctx, ...args)` receives an [`ActionContext`](#contexts) and the arguments the action was called with. Its return value is the activity's result.
169
169
 
@@ -1,8 +1,9 @@
1
1
  import type { OmkitClient } from "../../client/index.ts";
2
2
  /**
3
- * Render the interactive Ink app. Ink's own Ctrl+C handling is disabled so the app can tear the
4
- * run down gracefully first; the final verdict + run-folder path are printed here, after Ink
5
- * unmounts (it erases its own frame), so the pointer to the on-disk record survives on screen.
3
+ * Render the interactive shell, which stays up across runs until the user quits. Ink's own Ctrl+C
4
+ * handling is disabled so the app can tear a live run down gracefully first. Each run's verdict
5
+ * and recap block are already in the scrollback by then (the app writes them there as each run
6
+ * settles), so nothing is reprinted here; the exit code is the last run's verdict.
6
7
  *
7
8
  * Discovery runs first, before Ink takes the terminal: a fatal config problem throws here and the
8
9
  * bin's error boundary turns it into a clean crash rather than an empty list. With no oms to show,
@@ -4,9 +4,10 @@ import { App } from "../ui/app.js";
4
4
  import { renderDiagnostics, withSpinner } from "../ui/Report.js";
5
5
  import { reportNoOms } from "./run.js";
6
6
  /**
7
- * Render the interactive Ink app. Ink's own Ctrl+C handling is disabled so the app can tear the
8
- * run down gracefully first; the final verdict + run-folder path are printed here, after Ink
9
- * unmounts (it erases its own frame), so the pointer to the on-disk record survives on screen.
7
+ * Render the interactive shell, which stays up across runs until the user quits. Ink's own Ctrl+C
8
+ * handling is disabled so the app can tear a live run down gracefully first. Each run's verdict
9
+ * and recap block are already in the scrollback by then (the app writes them there as each run
10
+ * settles), so nothing is reprinted here; the exit code is the last run's verdict.
10
11
  *
11
12
  * Discovery runs first, before Ink takes the terminal: a fatal config problem throws here and the
12
13
  * bin's error boundary turns it into a clean crash rather than an empty list. With no oms to show,
@@ -27,19 +28,12 @@ export async function launchUi(client) {
27
28
  });
28
29
  }
29
30
  let finalVerdict;
30
- const app = render(_jsx(App, { client: client, oms: registry.oms, onExit: (v) => (finalVerdict = v) }), {
31
+ const app = render(_jsx(App, { client: client, oms: registry.oms, warnings: registry.warnings, onExit: (v) => (finalVerdict = v) }), {
31
32
  exitOnCtrlC: false,
32
33
  });
33
34
  void app.waitUntilExit().then(() => {
34
- if (finalVerdict) {
35
- // Ink erased its frame on unmount; reprint the verdict marker plus the run's recap block
36
- // (the same summary a bare run prints) so the pointers to the on-disk record survive.
37
- console.log("");
38
- console.log(finalVerdict.ok ? "✓ ok" : "✗ failed");
39
- for (const line of finalVerdict.summary)
40
- console.log(line);
35
+ if (finalVerdict)
41
36
  process.exitCode = finalVerdict.ok ? 0 : 1;
42
- }
43
37
  // Backstop: the runner disconnects the supervised child so both processes can exit, but if
44
38
  // anything (a wedged pipe, a lingering handle) keeps the event loop alive past the grace,
45
39
  // force the exit. Unref'd, so it only fires when the process is genuinely stuck — a clean
@@ -1,12 +1,27 @@
1
1
  import { type ReactElement } from "react";
2
2
  import type { OmkitClient, Verdict } from "../../client/types.ts";
3
3
  import type { DiscoveredOm } from "../../client/registry.ts";
4
- /** The interactive app: discover → list/search → run → live milestones + prompts. */
5
- export declare function App({ client, onExit, oms: initialOms, }: {
4
+ /**
5
+ * The interactive shell: pick an om, watch it run, stop it, and come back to the picker for the
6
+ * next — the process stays up until you quit. Every run is a fresh supervised child, so a rerun
7
+ * always executes the current code of the om and its actions; only the picker's list comes from
8
+ * discovery, which is redone in the background each time a run ends.
9
+ *
10
+ * The screen is two parts. Above, the scrollback ({@link Scrollback}): each run's header, its
11
+ * milestones, and its verdict with the recap block a bare run prints — written once, kept by the
12
+ * terminal. Below, the live part, the only thing redrawn: the picker, or the run box
13
+ * ({@link RunView}) with the running nodes, a prompt, and the key line.
14
+ *
15
+ * Stopping is always a graceful teardown — the child reaps its process tree and reports a verdict
16
+ * — and nothing here exits or moves on before that verdict. `esc` stops and returns to the
17
+ * picker; `ctrl+c` stops and then quits, and quits straight away from the picker.
18
+ */
19
+ export declare function App({ client, onExit, oms: initialOms, warnings: initialWarnings, }: {
6
20
  client: OmkitClient;
7
- /** Called with the run's verdict (if any) just before Ink unmounts, so the caller can print
8
- * a durable summary — Ink erases its own frame on exit. */
21
+ /** Called with the last run's verdict (if any) just before Ink unmounts. */
9
22
  onExit?: (verdict: Verdict | undefined) => void;
10
- /** Oms discovered by the caller (pre-flight). When given, the app skips its own discovery. */
23
+ /** Oms discovered by the caller (pre-flight). When given, the app skips its own first scan. */
11
24
  oms?: DiscoveredOm[];
25
+ /** Warnings the caller already printed for that scan, so a rediscovery repeats only new ones. */
26
+ warnings?: readonly string[];
12
27
  }): ReactElement;
@@ -1,143 +1,308 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
2
4
  import { useEffect, useRef, useState } from "react";
3
- import { useApp, useInput } from "ink";
5
+ import { Box, Text, useApp, useInput } from "ink";
4
6
  import { RunModel } from "../../output/RunModel.js";
5
7
  import { OmList } from "./views/OmList.js";
6
- import { RunView } from "./views/RunView.js";
8
+ import { RunView, formatDuration } from "./views/RunView.js";
9
+ import { recapLines, shellLine } from "./views/shellLines.js";
10
+ import { Scrollback } from "./views/Scrollback.js";
7
11
  import { Spinner } from "./Spinner.js";
8
12
  import { redrawLoop } from "./redrawLoop.js";
13
+ import { openFile } from "./openFile.js";
9
14
  /**
10
15
  * How often the run screen redraws while the log streams in.
11
16
  *
12
- * An Ink frame costs ~25ms at minimum and grows with the milestone tail, so redrawing per log
13
- * entry caps the app at roughly 40 entries/second — far under what a build, a test suite, or a
14
- * dev server emits. The app then falls permanently behind: `settled` is the *last* message on
15
- * the channel, so it only arrives once every backlogged entry has been drawn, and the run looks
16
- * hung long after the child has finished. Folding into the model is cheap; drawing is not, so
17
- * the two run at different rates — and the gap is kept *after* each frame (see {@link redrawLoop}),
17
+ * An Ink frame costs ~25ms at minimum and grows with what it draws, so redrawing per log entry
18
+ * caps the app at roughly 40 entries/second — far under what a build, a test suite, or a dev
19
+ * server emits. The app then falls permanently behind: the verdict is the *last* message on the
20
+ * channel, so it only arrives once every backlogged entry has been drawn, and the run looks hung
21
+ * long after the child has finished. Folding into the model is cheap; drawing is not, so the two
22
+ * run at different rates — and the gap is kept *after* each frame (see {@link redrawLoop}),
18
23
  * since a frame slower than this would otherwise leave no time between frames for the log at all.
19
24
  */
20
25
  const REDRAW_MS = 80;
21
- /** The interactive app: discover → list/search → run → live milestones + prompts. */
22
- export function App({ client, onExit, oms: initialOms, }) {
26
+ const sameOm = (a, b) => a.file === b.file && a.name === b.name;
27
+ const clock = (at) => new Date(at).toTimeString().slice(0, 8);
28
+ /**
29
+ * The interactive shell: pick an om, watch it run, stop it, and come back to the picker for the
30
+ * next — the process stays up until you quit. Every run is a fresh supervised child, so a rerun
31
+ * always executes the current code of the om and its actions; only the picker's list comes from
32
+ * discovery, which is redone in the background each time a run ends.
33
+ *
34
+ * The screen is two parts. Above, the scrollback ({@link Scrollback}): each run's header, its
35
+ * milestones, and its verdict with the recap block a bare run prints — written once, kept by the
36
+ * terminal. Below, the live part, the only thing redrawn: the picker, or the run box
37
+ * ({@link RunView}) with the running nodes, a prompt, and the key line.
38
+ *
39
+ * Stopping is always a graceful teardown — the child reaps its process tree and reports a verdict
40
+ * — and nothing here exits or moves on before that verdict. `esc` stops and returns to the
41
+ * picker; `ctrl+c` stops and then quits, and quits straight away from the picker.
42
+ */
43
+ export function App({ client, onExit, oms: initialOms, warnings: initialWarnings = [], }) {
23
44
  const { exit } = useApp();
24
45
  const [oms, setOms] = useState(initialOms ?? []);
25
- const [session, setSession] = useState(null);
26
- // A per-run RunModel folds the log stream into a capped milestone tail plus a live node summary;
27
- // these snapshots are what the view renders. The cap is what keeps a long run from growing forever.
28
- const [lines, setLines] = useState([]);
29
- const [status, setStatus] = useState();
46
+ const [discovering, setDiscovering] = useState(false);
47
+ const [chunks, setChunks] = useState([]);
48
+ const [current, setCurrent] = useState();
49
+ const [phase, setPhase] = useState("running");
50
+ const [upAt, setUpAt] = useState();
51
+ const [nodes, setNodes] = useState([]);
52
+ const [summary, setSummary] = useState();
30
53
  const [prompt, setPrompt] = useState();
31
- const [verdict, setVerdict] = useState();
32
- const [tearing, setTearing] = useState(false);
33
- // Refs so the Ctrl+C handler always sees the live session/verdict, not a stale render closure.
34
- const sessionRef = useRef(null);
35
- const verdictRef = useRef(undefined);
36
- const tearingRef = useRef(false);
54
+ const [last, setLast] = useState();
55
+ const [quitting, setQuitting] = useState(false);
56
+ // Refs so key handlers and session callbacks see live values, not a stale render closure.
57
+ const currentRef = useRef(undefined);
58
+ const phaseRef = useRef("running");
59
+ const quitRef = useRef(false);
60
+ const lastRef = useRef(undefined);
61
+ const omsRef = useRef(initialOms ?? []);
62
+ const warningsRef = useRef(initialWarnings.join("\n"));
63
+ const chunkId = useRef(0);
64
+ const mounted = useRef(true);
37
65
  const redrawTimer = useRef(undefined);
66
+ const say = (lines) => {
67
+ if (lines.length === 0)
68
+ return;
69
+ const chunk = { id: chunkId.current++, lines };
70
+ setChunks((c) => [...c, chunk]);
71
+ };
72
+ const enterPhase = (p) => {
73
+ phaseRef.current = p;
74
+ setPhase(p);
75
+ };
38
76
  const stopRedraw = () => {
39
77
  redrawTimer.current?.stop();
40
78
  redrawTimer.current = undefined;
41
79
  };
42
80
  // The redraw timer holds the event loop open, so it must not outlive the app — an unmount
43
81
  // for any reason other than the run settling would otherwise keep the CLI from exiting.
44
- useEffect(() => stopRedraw, []);
45
- useEffect(() => {
46
- if (initialOms)
47
- return; // provided by the caller's pre-flight discovery
48
- let live = true;
49
- void client.discover().then((r) => {
50
- if (live)
51
- setOms(r.oms);
82
+ useEffect(() => () => {
83
+ mounted.current = false;
84
+ stopRedraw();
85
+ }, []);
86
+ /**
87
+ * Scan for oms again — after every run, since the om files may have changed while it ran. The
88
+ * old list stays usable meanwhile, and stays put if the scan fails: a broken edit is reported,
89
+ * not a reason to empty the picker.
90
+ */
91
+ const rediscover = () => {
92
+ setDiscovering(true);
93
+ void client
94
+ .discover()
95
+ .then((registry) => {
96
+ if (!mounted.current)
97
+ return;
98
+ omsRef.current = registry.oms;
99
+ setOms(registry.oms);
100
+ const key = registry.warnings.join("\n");
101
+ if (key !== warningsRef.current) {
102
+ warningsRef.current = key;
103
+ say(registry.warnings.map((w) => ({ text: `⚠ ${w}`, color: "yellow" })));
104
+ }
105
+ })
106
+ .catch((err) => {
107
+ if (!mounted.current)
108
+ return;
109
+ const message = err instanceof Error ? err.message : String(err);
110
+ say([{ text: `✗ discovery failed — keeping the previous list: ${message}`, color: "red" }]);
111
+ })
112
+ .finally(() => {
113
+ if (mounted.current)
114
+ setDiscovering(false);
52
115
  });
53
- return () => {
54
- live = false;
55
- };
56
- }, [client, initialOms]);
116
+ };
117
+ useEffect(() => {
118
+ if (!initialOms)
119
+ rediscover();
120
+ // first scan only; later ones follow runs
121
+ // eslint-disable-next-line react-hooks/exhaustive-deps
122
+ }, []);
123
+ const quit = () => {
124
+ onExit?.(lastRef.current?.verdict);
125
+ exit();
126
+ };
127
+ /** The run settled: its verdict and recap go to the scrollback, then the picker comes back. */
128
+ const finish = (cur, v) => {
129
+ const stopped = phaseRef.current === "stopping";
130
+ const took = formatDuration(Date.now() - cur.startedAt);
131
+ const [icon, word, color] = !v.ok
132
+ ? ["✗", "failed", "red"]
133
+ : stopped
134
+ ? ["⊘", "stopped", "yellow"]
135
+ : ["✓", "ok", "green"];
136
+ say([
137
+ { text: `${icon} ${cur.om.name} · ${word} · ${took}`, color, bold: true },
138
+ ...recapLines(v.summary),
139
+ { text: " " }, // a blank line before the next run's rule; Ink drops an empty one
140
+ ]);
141
+ const done = { om: cur.om, verdict: v };
142
+ lastRef.current = done;
143
+ setLast(done);
144
+ currentRef.current = undefined;
145
+ setCurrent(undefined);
146
+ setPrompt(undefined);
147
+ setNodes([]);
148
+ setSummary(undefined);
149
+ setUpAt(undefined);
150
+ enterPhase("running");
151
+ if (quitRef.current) {
152
+ quit();
153
+ return;
154
+ }
155
+ rediscover();
156
+ };
57
157
  const run = (om) => {
158
+ if (currentRef.current)
159
+ return;
58
160
  const model = new RunModel();
59
- const s = client.run(om.file);
60
- sessionRef.current = s;
161
+ const startedAt = Date.now();
162
+ say([{ text: om.name, bold: true, rule: { right: clock(startedAt) } }]);
163
+ const session = client.run(om.file);
164
+ const cur = { om, session, startedAt };
165
+ currentRef.current = cur;
166
+ enterPhase("running");
61
167
  // Every entry lands in the model immediately; the screen catches up on its own cadence.
62
168
  // `dirty` keeps an idle run from redrawing over nothing — see REDRAW_MS for why the two
63
- // are separated at all.
169
+ // are separated at all. Scrollback lines (see shellLine for which entries earn one) wait in
170
+ // `pending` and reach the scrollback as one chunk per tick.
64
171
  let dirty = false;
172
+ let tearingDown = false;
173
+ const pending = [];
65
174
  const redraw = () => {
66
175
  if (!dirty)
67
176
  return;
68
177
  dirty = false;
69
- setLines(model.milestones());
70
- setStatus(model.summary());
178
+ setNodes(model.nodes());
179
+ setSummary(model.summary());
180
+ say(pending.splice(0));
71
181
  };
72
182
  redrawTimer.current = redrawLoop(redraw, REDRAW_MS);
73
- s.on("log", (entry) => {
183
+ session.on("log", (entry) => {
74
184
  model.apply(entry);
185
+ // the run narrates its own teardown — a stop, or the end of a body that left daemons up
186
+ if (entry.level === "run" && entry.message.startsWith("tearing down"))
187
+ tearingDown = true;
188
+ const line = shellLine(entry, model.node(entry.nodeId), tearingDown);
189
+ if (line)
190
+ pending.push(line);
75
191
  dirty = true;
76
192
  });
77
193
  // A prompt blocks the child until it's answered, so it must not wait for the next tick —
78
194
  // and the milestones that led to it are the context the question is read in.
79
- s.on("prompt", (req) => {
195
+ session.on("prompt", (req) => {
80
196
  redraw();
81
197
  setPrompt(req);
82
198
  });
83
199
  // The child gave up on the prompt (timed out or torn down). Withdraw its box so a stale,
84
200
  // unanswerable prompt doesn't linger — but only if it's still the one on screen, so a late
85
201
  // withdrawal can't clobber a fresh prompt.
86
- s.on("promptDone", (id) => setPrompt((p) => (p?.id === id ? undefined : p)));
87
- s.on("settled", (v) => {
88
- redraw(); // the run's closing milestones, before its verdict
89
- setVerdict(v);
90
- setPrompt(undefined);
202
+ session.on("promptDone", (id) => setPrompt((p) => (p?.id === id ? undefined : p)));
203
+ // The body returned with something still running: a stack that stays up until stopped.
204
+ // A body cut short by a stop returns too, and reports `up` on its way down — not a stack up.
205
+ session.on("up", () => {
206
+ if (phaseRef.current === "stopping")
207
+ return;
208
+ const at = Date.now();
209
+ pending.push({ text: ` ● up · ${formatDuration(at - startedAt)}`, color: "green" });
210
+ dirty = true;
211
+ setUpAt(at);
212
+ enterPhase("up");
91
213
  });
92
- // The single exit point. `result` resolves when the run ends — a natural completion (a build
93
- // that settles on its own), a teardown after Ctrl+C, or a crash that closes the channel — and
94
- // stays pending for a live daemon stack until Ctrl+C. Record the verdict, hand it to the caller
95
- // so it can print the durable summary block, and unmount. Without this a self-completing run
96
- // would show its verdict line but never exit or render the summary.
97
- void s.result.then((v) => {
214
+ // The single end of a run: a natural completion, a teardown after a stop, or a crash that
215
+ // closes the channel. A live stack never gets here on its own — only after a stop.
216
+ void session.result.then((v) => {
98
217
  stopRedraw();
218
+ dirty = true;
99
219
  redraw(); // last pass: whatever arrived since the final tick
100
- verdictRef.current = v;
101
- setVerdict(v);
102
- onExit?.(v);
103
- exit();
220
+ if (mounted.current)
221
+ finish(cur, v);
104
222
  });
105
- setSession(s);
223
+ setCurrent(cur);
106
224
  };
107
- const answer = (value) => {
108
- if (session && prompt)
109
- session.answer(prompt.id, value);
110
- setPrompt(undefined);
225
+ /** Ask the run to tear down. Idempotent: the child owns the teardown and reports when done. */
226
+ const stop = (thenQuit) => {
227
+ const cur = currentRef.current;
228
+ if (!cur)
229
+ return;
230
+ if (thenQuit && !quitRef.current) {
231
+ quitRef.current = true;
232
+ setQuitting(true);
233
+ }
234
+ if (phaseRef.current === "stopping")
235
+ return;
236
+ enterPhase("stopping");
237
+ cur.session.cancel();
111
238
  };
112
- // Ctrl+C: in raw mode this is a keystroke, not a signal. Cancel the run and wait for the child
113
- // to tear down and report its verdict, then quit — leaving the durable summary to `onExit`.
114
- //
115
- // Repeated presses are a deliberate no-op (see the `tearing` guard below): the child owns the
116
- // teardown and is guaranteed to report `settled` within its grace window, so we let it finish
117
- // rather than force-exiting. This mirrors the normal om run, where SIGINT is caught with
118
- // `process.on` and smashing Ctrl+C just re-triggers an idempotent teardown — the process never
119
- // exits before the tree is reaped. Force-exiting here (the old second-press escape hatch) killed
120
- // the parent mid-teardown and orphaned the child's process tree (vite, watchers, Chrome).
121
- useInput((input, key) => {
122
- if (!(key.ctrl && input === "c"))
239
+ const rerun = () => {
240
+ const prev = lastRef.current;
241
+ if (!prev)
123
242
  return;
124
- const s = sessionRef.current;
125
- // Nothing running or already settled → quit now.
126
- if (!s || verdictRef.current) {
127
- onExit?.(verdictRef.current);
128
- exit();
243
+ // Rerun what discovery still finds; the run itself reads the current code either way.
244
+ const om = omsRef.current.find((o) => sameOm(o, prev.om));
245
+ if (!om) {
246
+ say([{ text: `⚠ ${prev.om.name} is no longer discovered — not rerunning`, color: "yellow" }]);
129
247
  return;
130
248
  }
131
- // Already tearing down → ignore. The child is reaping its tree and will report `settled` (or
132
- // the channel closes and resolves a failed verdict); either way `result` resolves and the
133
- // handler wired in `run()` records the verdict and unmounts. Just request the teardown here.
134
- if (tearingRef.current)
249
+ run(om);
250
+ };
251
+ const openReport = () => {
252
+ const folder = lastRef.current?.verdict.folder;
253
+ const file = folder ? path.join(folder, "report.html") : undefined;
254
+ if (!file || !fs.existsSync(file)) {
255
+ say([{ text: "⚠ the last run wrote no report.html", color: "yellow" }]);
256
+ return;
257
+ }
258
+ openFile(file).then(() => say([{ text: `↗ ${file}`, dim: true }]), (err) => say([
259
+ {
260
+ text: `✗ could not open ${file}: ${err instanceof Error ? err.message : String(err)}`,
261
+ color: "red",
262
+ },
263
+ ]));
264
+ };
265
+ // In raw mode Ctrl+C is a keystroke, not a signal. During a run every stop key only *requests*
266
+ // the teardown: repeated presses are a deliberate no-op, and nothing exits before the child has
267
+ // reaped its tree and reported — force-exiting mid-teardown would orphan its process tree
268
+ // (vite, watchers, Chrome). This mirrors a bare om run, where SIGINT re-triggers an idempotent
269
+ // teardown and the process never exits before the tree is reaped.
270
+ useInput((input, key) => {
271
+ const running = currentRef.current !== undefined;
272
+ if (key.ctrl && input === "c") {
273
+ if (running)
274
+ stop(true);
275
+ else
276
+ quit();
277
+ return;
278
+ }
279
+ if (running) {
280
+ // with a prompt up, esc belongs to the prompt (it clears a choice search)
281
+ if (key.escape && !prompt)
282
+ stop(false);
135
283
  return;
136
- tearingRef.current = true;
137
- setTearing(true);
138
- s.cancel();
284
+ }
285
+ if (key.ctrl && input === "r")
286
+ rerun();
287
+ else if (key.ctrl && input === "o")
288
+ openReport();
139
289
  });
140
- if (!session)
141
- return _jsx(OmList, { oms: oms, onSelect: run });
142
- return (_jsxs(_Fragment, { children: [_jsx(RunView, { lines: lines, status: status, prompt: prompt, verdict: verdict, onAnswer: answer }), !verdict && tearing ? _jsx(Spinner, { label: "tearing down\u2026 (stopping the run cleanly)" }) : null, !verdict && !tearing && !prompt ? _jsx(Spinner, { label: "running\u2026" }) : null] }));
290
+ const hint = current
291
+ ? phase === "stopping"
292
+ ? `stopping cleanly — waiting for the run to tear down${quitting ? ", then quitting" : ""}`
293
+ : prompt
294
+ ? "enter answer · ctrl+c stop and quit"
295
+ : "esc stop · ctrl+c stop and quit"
296
+ : [
297
+ "↑↓ move · enter run",
298
+ last ? `ctrl+r rerun ${last.om.name} · ctrl+o report` : "",
299
+ "ctrl+c quit",
300
+ ]
301
+ .filter(Boolean)
302
+ .join(" · ");
303
+ return (_jsxs(_Fragment, { children: [_jsx(Scrollback, { chunks: chunks }), current ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(RunView, { name: current.om.name, phase: phase, startedAt: current.startedAt, upAt: upAt, nodes: nodes, summary: summary, prompt: prompt, onAnswer: (value) => {
304
+ if (prompt)
305
+ current.session.answer(prompt.id, value);
306
+ setPrompt(undefined);
307
+ } }), _jsx(Text, { dimColor: true, children: hint })] })) : (_jsx(OmList, { oms: oms, onSelect: run, hint: hint, status: discovering ? _jsx(Spinner, { label: "rediscovering\u2026" }) : undefined }))] }));
143
308
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Open `file` with the OS's default handler (a browser, for `report.html`) without waiting on it.
3
+ * Resolves once the opener has launched, or rejects with why it couldn't — a Linux box without
4
+ * `xdg-open`, say — so the caller can say so instead of the key press doing nothing.
5
+ */
6
+ export declare function openFile(file: string): Promise<void>;
@@ -0,0 +1,21 @@
1
+ import { spawn } from "node:child_process";
2
+ /**
3
+ * Open `file` with the OS's default handler (a browser, for `report.html`) without waiting on it.
4
+ * Resolves once the opener has launched, or rejects with why it couldn't — a Linux box without
5
+ * `xdg-open`, say — so the caller can say so instead of the key press doing nothing.
6
+ */
7
+ export function openFile(file) {
8
+ const [cmd, args] = process.platform === "win32"
9
+ ? ["cmd", ["/c", "start", "", file]]
10
+ : process.platform === "darwin"
11
+ ? ["open", [file]]
12
+ : ["xdg-open", [file]];
13
+ return new Promise((resolve, reject) => {
14
+ const child = spawn(cmd, args, { detached: true, stdio: "ignore", windowsHide: true });
15
+ child.once("error", reject);
16
+ child.once("spawn", () => {
17
+ child.unref();
18
+ resolve();
19
+ });
20
+ });
21
+ }
@@ -1,4 +1,4 @@
1
- import { type ReactElement } from "react";
1
+ import { type ReactElement, type ReactNode } from "react";
2
2
  import type { DiscoveredOm } from "../../../client/registry.ts";
3
3
  /**
4
4
  * The picker: a searchable, arrow-navigable list of oms; Enter runs the highlighted one.
@@ -10,8 +10,15 @@ import type { DiscoveredOm } from "../../../client/registry.ts";
10
10
  * Rows are ordered by {@link byNesting} from the working directory — the same root the paths
11
11
  * are rendered from, so the oms nearest the top of the project come first, deeper ones after,
12
12
  * and any shown as an absolute path last.
13
+ *
14
+ * `status` goes on the header line (the shell's "rediscovering…"), and `hint` replaces the key
15
+ * line under the list, for a caller that binds more keys than the list does.
13
16
  */
14
- export declare function OmList({ oms, onSelect, }: {
17
+ export declare function OmList({ oms, onSelect, status, hint, }: {
15
18
  oms: DiscoveredOm[];
16
19
  onSelect: (om: DiscoveredOm) => void;
20
+ status?: ReactNode;
21
+ hint?: string;
17
22
  }): ReactElement;
23
+ /** A description key as a label: `docsUrl` / `docs-url` / `docs_url` → `Docs url`. */
24
+ export declare function labelOf(key: string): string;