omkit 0.8.23 → 0.8.24
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 +1 -1
- package/dist/cli/commands/ui.d.ts +4 -3
- package/dist/cli/commands/ui.js +6 -12
- package/dist/cli/ui/app.d.ts +20 -5
- package/dist/cli/ui/app.js +252 -87
- package/dist/cli/ui/openFile.d.ts +6 -0
- package/dist/cli/ui/openFile.js +21 -0
- package/dist/cli/ui/views/OmList.d.ts +7 -2
- package/dist/cli/ui/views/OmList.js +5 -2
- package/dist/cli/ui/views/RunView.d.ts +31 -7
- package/dist/cli/ui/views/RunView.js +87 -8
- package/dist/cli/ui/views/Scrollback.d.ts +30 -0
- package/dist/cli/ui/views/Scrollback.js +21 -0
- package/dist/cli/ui/views/shellLines.d.ts +21 -0
- package/dist/cli/ui/views/shellLines.js +61 -0
- package/dist/output/RunModel.d.ts +8 -0
- package/dist/output/RunModel.js +39 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { OmkitClient } from "../../client/index.ts";
|
|
2
2
|
/**
|
|
3
|
-
* Render the interactive
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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,
|
package/dist/cli/commands/ui.js
CHANGED
|
@@ -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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
package/dist/cli/ui/app.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
5
|
-
|
|
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
|
|
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
|
|
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;
|
package/dist/cli/ui/app.js
CHANGED
|
@@ -1,143 +1,308 @@
|
|
|
1
|
-
import { jsx as _jsx,
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
|
|
22
|
-
|
|
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 [
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const [
|
|
29
|
-
const [
|
|
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 [
|
|
32
|
-
const [
|
|
33
|
-
// Refs so
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const
|
|
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(() =>
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
60
|
-
|
|
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
|
-
|
|
70
|
-
|
|
178
|
+
setNodes(model.nodes());
|
|
179
|
+
setSummary(model.summary());
|
|
180
|
+
say(pending.splice(0));
|
|
71
181
|
};
|
|
72
182
|
redrawTimer.current = redrawLoop(redraw, REDRAW_MS);
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
93
|
-
//
|
|
94
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
onExit?.(v);
|
|
103
|
-
exit();
|
|
220
|
+
if (mounted.current)
|
|
221
|
+
finish(cur, v);
|
|
104
222
|
});
|
|
105
|
-
|
|
223
|
+
setCurrent(cur);
|
|
106
224
|
};
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
if (!
|
|
127
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
284
|
+
}
|
|
285
|
+
if (key.ctrl && input === "r")
|
|
286
|
+
rerun();
|
|
287
|
+
else if (key.ctrl && input === "o")
|
|
288
|
+
openReport();
|
|
139
289
|
});
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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,13 @@ 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;
|
|
@@ -22,8 +22,11 @@ function truncate(text, max) {
|
|
|
22
22
|
* Rows are ordered by {@link byNesting} from the working directory — the same root the paths
|
|
23
23
|
* are rendered from, so the oms nearest the top of the project come first, deeper ones after,
|
|
24
24
|
* and any shown as an absolute path last.
|
|
25
|
+
*
|
|
26
|
+
* `status` goes on the header line (the shell's "rediscovering…"), and `hint` replaces the key
|
|
27
|
+
* line under the list, for a caller that binds more keys than the list does.
|
|
25
28
|
*/
|
|
26
|
-
export function OmList({ oms, onSelect, }) {
|
|
29
|
+
export function OmList({ oms, onSelect, status, hint = "↑↓ move · enter run · esc clears search · ctrl+c quit", }) {
|
|
27
30
|
const root = process.cwd();
|
|
28
31
|
const sorted = useMemo(() => [...oms].sort(byNesting(root)), [oms, root]);
|
|
29
32
|
const { stdout } = useStdout();
|
|
@@ -31,7 +34,7 @@ export function OmList({ oms, onSelect, }) {
|
|
|
31
34
|
// Column width so summaries line up; capped so one long name can't push them off-screen.
|
|
32
35
|
const nameWidth = Math.min(30, oms.reduce((w, o) => Math.max(w, o.name.length), 0));
|
|
33
36
|
const restWidth = Math.max(20, columns - nameWidth - 6);
|
|
34
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "omkit" }), _jsx(Text, { dimColor: true, children: ` · ${oms.length} om${oms.length === 1 ? "" : "s"}` })] }), _jsx(SearchableList, { items: sorted, getKey: (o) => `${o.file}\0${o.name}`, getSearchText: (o) => `${o.name} ${o.summary ?? ""}`, onSelect: onSelect, alwaysShowSearch: true, searchLabel: "search \u203A ", searchPlaceholder: "type to filter by name or summary", renderRow: (o, selected) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: selected ? "cyan" : undefined, children: selected ? "❯ " : " " }), _jsx(Text, { bold: selected, color: selected ? "cyan" : undefined, children: o.name.padEnd(nameWidth) }), " ", _jsx(Text, { dimColor: true, children: truncate(o.summary ?? displayPath(o.file, root), restWidth) })] })), renderDetail: (o) => (o ? _jsx(OmDetail, { om: o, root: root }) : null) }), _jsx(Text, { dimColor: true, children:
|
|
37
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "omkit" }), _jsx(Text, { dimColor: true, children: ` · ${oms.length} om${oms.length === 1 ? "" : "s"}` }), status ? _jsx(Text, { dimColor: true, children: " " }) : null, status ?? null] }), _jsx(SearchableList, { items: sorted, getKey: (o) => `${o.file}\0${o.name}`, getSearchText: (o) => `${o.name} ${o.summary ?? ""}`, onSelect: onSelect, alwaysShowSearch: true, searchLabel: "search \u203A ", searchPlaceholder: "type to filter by name or summary", renderRow: (o, selected) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: selected ? "cyan" : undefined, children: selected ? "❯ " : " " }), _jsx(Text, { bold: selected, color: selected ? "cyan" : undefined, children: o.name.padEnd(nameWidth) }), " ", _jsx(Text, { dimColor: true, children: truncate(o.summary ?? displayPath(o.file, root), restWidth) })] })), renderDetail: (o) => (o ? _jsx(OmDetail, { om: o, root: root }) : null) }), _jsx(Text, { dimColor: true, children: hint })] }));
|
|
35
38
|
}
|
|
36
39
|
/** The highlighted om in full. */
|
|
37
40
|
function OmDetail({ om, root }) {
|
|
@@ -1,16 +1,40 @@
|
|
|
1
1
|
import { type ReactElement } from "react";
|
|
2
|
-
import type { RunSummary } from "../../../output/RunModel.ts";
|
|
3
|
-
import type { PromptRequest
|
|
2
|
+
import type { RunNode, RunSummary } from "../../../output/RunModel.ts";
|
|
3
|
+
import type { PromptRequest } from "../../../client/types.ts";
|
|
4
4
|
/** Answer one pending prompt — free-text input, or searchable choices. */
|
|
5
5
|
export declare function PromptView({ request, onAnswer, }: {
|
|
6
6
|
request: PromptRequest;
|
|
7
7
|
onAnswer: (value: string) => void;
|
|
8
8
|
}): ReactElement;
|
|
9
|
-
/**
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
/** Where a run is: its body still going, its body returned with daemons left up, or tearing down. */
|
|
10
|
+
export type RunPhase = "running" | "up" | "stopping";
|
|
11
|
+
/** Rows of the live tree before the rest collapse into `+N running` — so the box never jumps. */
|
|
12
|
+
export declare const MAX_TREE_ROWS = 6;
|
|
13
|
+
/** `850ms`, `8.3s`, `3m12s`, `1h04m`. */
|
|
14
|
+
export declare function formatDuration(ms: number): string;
|
|
15
|
+
/**
|
|
16
|
+
* A node's name in the tree, with its tags — `vite [ready]`. The log stream carries only the
|
|
17
|
+
* node's path segment, the action name made file-safe plus the node's id (`tailLog_bbe803dc`),
|
|
18
|
+
* so the id is dropped and an over-long name (a `command`'s whole command line) keeps its two
|
|
19
|
+
* ends — the tag beside it is usually what tells the rows apart.
|
|
20
|
+
*/
|
|
21
|
+
export declare function nodeLabel(node: Pick<RunNode, "path" | "tags">): string;
|
|
22
|
+
/** One-line roll-up of the live tree: what's still running and the finished tally. Empty until
|
|
23
|
+
* something is happening, so it renders nothing at the very start of a run. */
|
|
24
|
+
export declare function statusStrip(s: RunSummary): string;
|
|
25
|
+
/**
|
|
26
|
+
* The live run box — the only part of the screen redrawn while a run streams (its milestones go
|
|
27
|
+
* to the scrollback above it): a header with the phase and elapsed time, the running nodes with
|
|
28
|
+
* their latest line, the finished tally, and a pending prompt, which takes the box over.
|
|
29
|
+
*/
|
|
30
|
+
export declare function RunView({ name, phase, startedAt, upAt, nodes, summary, prompt, onAnswer, }: {
|
|
31
|
+
name: string;
|
|
32
|
+
phase: RunPhase;
|
|
33
|
+
startedAt: number;
|
|
34
|
+
/** When the run's body returned (the `up` event), for the "up for" clock. */
|
|
35
|
+
upAt?: number;
|
|
36
|
+
nodes: readonly RunNode[];
|
|
37
|
+
summary?: RunSummary;
|
|
13
38
|
prompt?: PromptRequest;
|
|
14
|
-
verdict?: Verdict;
|
|
15
39
|
onAnswer: (value: string) => void;
|
|
16
40
|
}): ReactElement;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useRef, useState } from "react";
|
|
3
|
-
import { Box, Text, useInput } from "ink";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { Box, Text, useInput, useStdout } from "ink";
|
|
4
4
|
import { SearchableList } from "./SearchableList.js";
|
|
5
|
+
import { Spinner } from "../Spinner.js";
|
|
5
6
|
/** A choice prompt: searchable, arrow-navigable options, Enter picks the highlighted one. */
|
|
6
7
|
function ChoicePrompt({ request, onAnswer, }) {
|
|
7
8
|
const { spec } = request;
|
|
@@ -35,9 +36,53 @@ function InputPrompt({ request, onAnswer, }) {
|
|
|
35
36
|
export function PromptView({ request, onAnswer, }) {
|
|
36
37
|
return request.spec.kind === "choice" ? (_jsx(ChoicePrompt, { request: request, onAnswer: onAnswer })) : (_jsx(InputPrompt, { request: request, onAnswer: onAnswer }));
|
|
37
38
|
}
|
|
39
|
+
/** Rows of the live tree before the rest collapse into `+N running` — so the box never jumps. */
|
|
40
|
+
export const MAX_TREE_ROWS = 6;
|
|
41
|
+
/** `850ms`, `8.3s`, `3m12s`, `1h04m`. */
|
|
42
|
+
export function formatDuration(ms) {
|
|
43
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
44
|
+
return "";
|
|
45
|
+
if (ms < 1000)
|
|
46
|
+
return `${Math.round(ms)}ms`;
|
|
47
|
+
if (ms < 60_000)
|
|
48
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
49
|
+
const s = Math.floor(ms / 1000);
|
|
50
|
+
if (s < 3600)
|
|
51
|
+
return `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
|
|
52
|
+
return `${Math.floor(s / 3600)}h${String(Math.floor(s / 60) % 60).padStart(2, "0")}m`;
|
|
53
|
+
}
|
|
54
|
+
/** Longest node name the tree shows before cutting out its middle. */
|
|
55
|
+
const NAME_MAX = 40;
|
|
56
|
+
/**
|
|
57
|
+
* A node's name in the tree, with its tags — `vite [ready]`. The log stream carries only the
|
|
58
|
+
* node's path segment, the action name made file-safe plus the node's id (`tailLog_bbe803dc`),
|
|
59
|
+
* so the id is dropped and an over-long name (a `command`'s whole command line) keeps its two
|
|
60
|
+
* ends — the tag beside it is usually what tells the rows apart.
|
|
61
|
+
*/
|
|
62
|
+
export function nodeLabel(node) {
|
|
63
|
+
const segment = node.path.slice(node.path.lastIndexOf("/") + 1).replace(/_[0-9a-f]{8}$/, "");
|
|
64
|
+
const name = segment.length > NAME_MAX
|
|
65
|
+
? `${segment.slice(0, NAME_MAX / 2 - 1)}…${segment.slice(-(NAME_MAX / 2))}`
|
|
66
|
+
: segment;
|
|
67
|
+
return node.tags.length ? `${name} [${node.tags.join(", ")}]` : name;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The time since `since`. Read at render, so every redraw shows it current; the tick only forces
|
|
71
|
+
* one when nothing else does (an idle stack that is up). Unref'd, like {@link Spinner}'s
|
|
72
|
+
* interval, so it never holds the process open.
|
|
73
|
+
*/
|
|
74
|
+
function Elapsed({ since }) {
|
|
75
|
+
const [, setTick] = useState(0);
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const id = setInterval(() => setTick((t) => t + 1), 1000);
|
|
78
|
+
id.unref?.();
|
|
79
|
+
return () => clearInterval(id);
|
|
80
|
+
}, []);
|
|
81
|
+
return _jsx(_Fragment, { children: formatDuration(Date.now() - since) });
|
|
82
|
+
}
|
|
38
83
|
/** One-line roll-up of the live tree: what's still running and the finished tally. Empty until
|
|
39
84
|
* something is happening, so it renders nothing at the very start of a run. */
|
|
40
|
-
function statusStrip(s) {
|
|
85
|
+
export function statusStrip(s) {
|
|
41
86
|
const parts = [];
|
|
42
87
|
if (s.running.length) {
|
|
43
88
|
const shown = s.running.slice(0, 3).join(", ");
|
|
@@ -55,8 +100,42 @@ function statusStrip(s) {
|
|
|
55
100
|
parts.push(tally.join(" · "));
|
|
56
101
|
return parts.join(" · ");
|
|
57
102
|
}
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
103
|
+
/**
|
|
104
|
+
* The running nodes under the root, most recently active first — the order {@link RunSummary}
|
|
105
|
+
* keeps — so whatever is talking right now stays on screen when the rest collapse.
|
|
106
|
+
*/
|
|
107
|
+
function liveNodes(nodes, summary) {
|
|
108
|
+
const byPath = new Map(nodes.map((n) => [n.path, n]));
|
|
109
|
+
const order = summary?.running ?? nodes.filter((n) => n.status === "running").map((n) => n.path);
|
|
110
|
+
return order
|
|
111
|
+
.map((p) => byPath.get(p))
|
|
112
|
+
.filter((n) => n !== undefined && n.path.includes("/"));
|
|
113
|
+
}
|
|
114
|
+
function Header({ name, phase, startedAt, upAt, }) {
|
|
115
|
+
return (_jsxs(Text, { children: [_jsx(Text, { bold: true, children: name }), _jsx(Text, { dimColor: true, children: " · " }), phase === "stopping" ? (_jsx(Text, { color: "yellow", children: "stopping\u2026" })) : phase === "up" ? (_jsxs(Text, { color: "green", children: ["\u25CF up ", _jsx(Elapsed, { since: upAt ?? startedAt })] })) : (_jsxs(Text, { color: "cyan", children: ["running ", _jsx(Elapsed, { since: startedAt })] }))] }));
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The live run box — the only part of the screen redrawn while a run streams (its milestones go
|
|
119
|
+
* to the scrollback above it): a header with the phase and elapsed time, the running nodes with
|
|
120
|
+
* their latest line, the finished tally, and a pending prompt, which takes the box over.
|
|
121
|
+
*/
|
|
122
|
+
export function RunView({ name, phase, startedAt, upAt, nodes, summary, prompt, onAnswer, }) {
|
|
123
|
+
const { stdout } = useStdout();
|
|
124
|
+
const width = Math.max(30, (stdout?.columns || 100) - 4);
|
|
125
|
+
const live = liveNodes(nodes, summary);
|
|
126
|
+
const shown = live.slice(0, MAX_TREE_ROWS);
|
|
127
|
+
const root = nodes.find((n) => !n.path.includes("/"));
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
const stopping = phase === "stopping";
|
|
130
|
+
const strip = summary ? statusStrip(summary) : "";
|
|
131
|
+
const tally = summary
|
|
132
|
+
? [
|
|
133
|
+
summary.ok ? `✓ ${summary.ok} ok` : "",
|
|
134
|
+
summary.failed ? `✗ ${summary.failed} failed` : "",
|
|
135
|
+
summary.cancelled ? `⊘ ${summary.cancelled} cancelled` : "",
|
|
136
|
+
].filter(Boolean)
|
|
137
|
+
: [];
|
|
138
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: stopping ? "yellow" : phase === "up" ? "green" : "gray", paddingX: 1, children: [_jsx(Header, { name: name, phase: phase, startedAt: startedAt, upAt: upAt }), prompt ? (_jsxs(_Fragment, { children: [strip ? _jsx(Text, { dimColor: true, children: strip }) : null, _jsx(PromptView, { request: prompt, onAnswer: onAnswer })] })) : (_jsxs(_Fragment, { children: [shown.map((n) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { wrap: "truncate-end", children: [stopping ? _jsx(Text, { color: "yellow", children: "\u25CC " }) : _jsx(Text, { color: "cyan", children: "\u25CF " }), nodeLabel(n), _jsx(Text, { dimColor: true, children: ` ${formatDuration(now - n.startedAt)}` })] }), n.last ? (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: ` └ ${n.last.slice(0, width)}` })) : null] }, n.path))), live.length > shown.length ? (_jsx(Text, { dimColor: true, children: ` +${live.length - shown.length} more running` })) : null, live.length === 0 && !stopping ? (
|
|
139
|
+
// nothing launched under the root (yet): what the om's own body last said, if anything
|
|
140
|
+
root?.last ? (_jsx(Text, { dimColor: true, wrap: "truncate-end", children: `└ ${root.last.slice(0, width)}` })) : (_jsx(Spinner, { label: phase === "up" ? "idle" : "starting…" }))) : null, tally.length ? _jsx(Text, { dimColor: true, children: tally.join(" · ") }) : null] }))] }));
|
|
62
141
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** One line of scrollback, with an optional accent. */
|
|
2
|
+
export interface ScrollLine {
|
|
3
|
+
readonly text: string;
|
|
4
|
+
readonly color?: string;
|
|
5
|
+
readonly dim?: boolean;
|
|
6
|
+
readonly bold?: boolean;
|
|
7
|
+
/**
|
|
8
|
+
* Draw the line as a rule across the terminal, `text` set into it and `right` (if any) at its
|
|
9
|
+
* end — `── tskb:build ──────── 14:21:17`. What opens each run, so runs read as sections.
|
|
10
|
+
*/
|
|
11
|
+
readonly rule?: {
|
|
12
|
+
readonly right?: string;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A batch of lines appended to the scrollback at once — one per redraw tick, not one per line,
|
|
17
|
+
* so the item array {@link Scrollback} hands `<Static>` grows with ticks rather than with output.
|
|
18
|
+
*/
|
|
19
|
+
export interface ScrollChunk {
|
|
20
|
+
readonly id: number;
|
|
21
|
+
readonly lines: readonly ScrollLine[];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Everything the session has said, written once into the terminal's own scrollback by Ink's
|
|
25
|
+
* `<Static>` and never redrawn: run headers, outcomes, verdicts and their recap. That is what
|
|
26
|
+
* keeps the live frame below it small — a frame costs in proportion to what it draws.
|
|
27
|
+
*/
|
|
28
|
+
export declare const Scrollback: import("react").NamedExoticComponent<{
|
|
29
|
+
chunks: readonly ScrollChunk[];
|
|
30
|
+
}>;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { memo } from "react";
|
|
3
|
+
import { Box, Static, Text, useStdout } from "ink";
|
|
4
|
+
/** Widest a rule runs, so it stays a separator on a very wide terminal rather than a horizon. */
|
|
5
|
+
const RULE_MAX = 100;
|
|
6
|
+
function Rule({ line, columns }) {
|
|
7
|
+
const right = line.rule?.right ? ` ${line.rule.right}` : "";
|
|
8
|
+
const width = Math.min(columns, RULE_MAX);
|
|
9
|
+
const fill = Math.max(3, width - 3 - line.text.length - 1 - right.length);
|
|
10
|
+
return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "── " }), _jsx(Text, { bold: line.bold, color: line.color, children: line.text }), _jsx(Text, { dimColor: true, children: ` ${"─".repeat(fill)}${right}` })] }));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Everything the session has said, written once into the terminal's own scrollback by Ink's
|
|
14
|
+
* `<Static>` and never redrawn: run headers, outcomes, verdicts and their recap. That is what
|
|
15
|
+
* keeps the live frame below it small — a frame costs in proportion to what it draws.
|
|
16
|
+
*/
|
|
17
|
+
export const Scrollback = memo(function Scrollback({ chunks, }) {
|
|
18
|
+
const { stdout } = useStdout();
|
|
19
|
+
const columns = stdout?.columns || 80;
|
|
20
|
+
return (_jsx(Static, { items: chunks, children: (chunk) => (_jsx(Box, { flexDirection: "column", children: chunk.lines.map((l, i) => l.rule ? (_jsx(Rule, { line: l, columns: columns }, i)) : (_jsx(Text, { color: l.color, dimColor: l.dim, bold: l.bold, children: l.text }, i))) }, chunk.id)) }));
|
|
21
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { LogEntry } from "../../../foundation/LogEntry.ts";
|
|
2
|
+
import type { RunNode } from "../../../output/RunModel.ts";
|
|
3
|
+
import type { ScrollLine } from "./Scrollback.tsx";
|
|
4
|
+
/**
|
|
5
|
+
* The shell's scrollback line for one log entry, or `null` to keep it out.
|
|
6
|
+
*
|
|
7
|
+
* The scrollback is a record of outcomes, not a transcript: a node that finished (with how long
|
|
8
|
+
* it took), a failure, an error, a failed assert. What is merely happening — launches, events,
|
|
9
|
+
* output, snapshots, the run's own narration — shows in the live box while it happens and is in
|
|
10
|
+
* the run's logs and report afterwards. The root's own end is left to the verdict line, and
|
|
11
|
+
* once the run is tearing down the cancellations that teardown causes are expected, so they are
|
|
12
|
+
* left to the verdict too.
|
|
13
|
+
*/
|
|
14
|
+
export declare function shellLine(e: LogEntry, node: RunNode | undefined, tearingDown: boolean): ScrollLine | null;
|
|
15
|
+
/**
|
|
16
|
+
* The verdict's recap, cut to what you reach for next: the report, and the assert tally when the
|
|
17
|
+
* run asserted anything. The full block (every sibling log) is in the run folder and at the foot
|
|
18
|
+
* of `main.log`. A summary of any other shape — the output of a child that died before it could
|
|
19
|
+
* report — is the only account of what went wrong, so it is kept whole.
|
|
20
|
+
*/
|
|
21
|
+
export declare function recapLines(summary: readonly string[]): ScrollLine[];
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { formatDuration, nodeLabel } from "./RunView.js";
|
|
2
|
+
const firstLine = (message) => message.split("\n", 1)[0] ?? message;
|
|
3
|
+
/**
|
|
4
|
+
* The shell's scrollback line for one log entry, or `null` to keep it out.
|
|
5
|
+
*
|
|
6
|
+
* The scrollback is a record of outcomes, not a transcript: a node that finished (with how long
|
|
7
|
+
* it took), a failure, an error, a failed assert. What is merely happening — launches, events,
|
|
8
|
+
* output, snapshots, the run's own narration — shows in the live box while it happens and is in
|
|
9
|
+
* the run's logs and report afterwards. The root's own end is left to the verdict line, and
|
|
10
|
+
* once the run is tearing down the cancellations that teardown causes are expected, so they are
|
|
11
|
+
* left to the verdict too.
|
|
12
|
+
*/
|
|
13
|
+
export function shellLine(e, node, tearingDown) {
|
|
14
|
+
if (!node)
|
|
15
|
+
return null;
|
|
16
|
+
const label = nodeLabel(node);
|
|
17
|
+
const isRoot = !node.path.includes("/");
|
|
18
|
+
if (e.level === "event" && e.source === "lifecycle" && e.message.startsWith("done · ")) {
|
|
19
|
+
if (isRoot)
|
|
20
|
+
return null;
|
|
21
|
+
const took = node.endedAt === undefined ? "" : ` · ${formatDuration(node.endedAt - node.startedAt)}`;
|
|
22
|
+
if (node.status === "ok")
|
|
23
|
+
return { text: ` ✓ ${label}${took}`, color: "green" };
|
|
24
|
+
if (node.status === "failed")
|
|
25
|
+
return { text: ` ✗ ${label} · failed${took}`, color: "red" };
|
|
26
|
+
return tearingDown ? null : { text: ` ⊘ ${label} · cancelled`, dim: true };
|
|
27
|
+
}
|
|
28
|
+
if (e.level === "error" && e.source === "error") {
|
|
29
|
+
return { text: ` ✗ ${isRoot ? "om" : label} · ${firstLine(e.message)}`, color: "red" };
|
|
30
|
+
}
|
|
31
|
+
if (e.level === "assert" && e.message.includes("⊭")) {
|
|
32
|
+
return { text: ` ✗ ${isRoot ? "om" : label} · ${e.message}`, color: "red" };
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The verdict's recap, cut to what you reach for next: the report, and the assert tally when the
|
|
38
|
+
* run asserted anything. The full block (every sibling log) is in the run folder and at the foot
|
|
39
|
+
* of `main.log`. A summary of any other shape — the output of a child that died before it could
|
|
40
|
+
* report — is the only account of what went wrong, so it is kept whole.
|
|
41
|
+
*/
|
|
42
|
+
export function recapLines(summary) {
|
|
43
|
+
if (!summary[0]?.startsWith("om → ")) {
|
|
44
|
+
return summary.map((line) => ({ text: ` ${line}`, dim: true }));
|
|
45
|
+
}
|
|
46
|
+
const lines = [];
|
|
47
|
+
for (const line of summary) {
|
|
48
|
+
const trimmed = line.trim();
|
|
49
|
+
if (trimmed.startsWith("report "))
|
|
50
|
+
lines.push({ text: ` ${trimmed}`, dim: true });
|
|
51
|
+
const tally = /⊨ (\d+) passed · ⊭ (\d+) failed/.exec(trimmed);
|
|
52
|
+
if (tally && Number(tally[1]) + Number(tally[2]) > 0) {
|
|
53
|
+
lines.push({
|
|
54
|
+
text: ` asserts ${tally[0]}`,
|
|
55
|
+
color: Number(tally[2]) ? "red" : undefined,
|
|
56
|
+
dim: !Number(tally[2]),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return lines;
|
|
61
|
+
}
|
|
@@ -7,6 +7,12 @@ export interface RunNode {
|
|
|
7
7
|
readonly path: string;
|
|
8
8
|
readonly status: RunNodeStatus;
|
|
9
9
|
readonly tags: readonly string[];
|
|
10
|
+
/** Epoch millis of the node's first entry. */
|
|
11
|
+
readonly startedAt: number;
|
|
12
|
+
/** Epoch millis it settled; absent while it runs. */
|
|
13
|
+
readonly endedAt?: number;
|
|
14
|
+
/** First line of the node's latest output (console or proc), for a live "what is it doing" row. */
|
|
15
|
+
readonly last?: string;
|
|
10
16
|
}
|
|
11
17
|
/** A compact roll-up of the live tree, for a one-line status strip. */
|
|
12
18
|
export interface RunSummary {
|
|
@@ -31,6 +37,8 @@ export declare class RunModel {
|
|
|
31
37
|
apply(entry: LogEntry): Rendered | null;
|
|
32
38
|
/** The live tree: every node touched so far, with its current status and tags. */
|
|
33
39
|
nodes(): readonly RunNode[];
|
|
40
|
+
/** One node of the live tree by id, as it stands now — for a caller rendering one entry. */
|
|
41
|
+
node(nodeId: string): RunNode | undefined;
|
|
34
42
|
/** The capped tail of milestone lines, oldest first. */
|
|
35
43
|
milestones(): readonly string[];
|
|
36
44
|
/** A compact roll-up for a status strip: who's still running, and how many finished each way. */
|
package/dist/output/RunModel.js
CHANGED
|
@@ -18,6 +18,11 @@ export class RunModel {
|
|
|
18
18
|
return null; // tags decorate a node's lines; they aren't a line of their own
|
|
19
19
|
}
|
|
20
20
|
this.transition(node, entry);
|
|
21
|
+
if (isActivity(entry)) {
|
|
22
|
+
const line = entry.message.split("\n", 1)[0].trim();
|
|
23
|
+
if (line)
|
|
24
|
+
node.last = line;
|
|
25
|
+
}
|
|
21
26
|
const rendered = format(entry, node.tags);
|
|
22
27
|
if (rendered)
|
|
23
28
|
this.push(rendered.text);
|
|
@@ -25,11 +30,12 @@ export class RunModel {
|
|
|
25
30
|
}
|
|
26
31
|
/** The live tree: every node touched so far, with its current status and tags. */
|
|
27
32
|
nodes() {
|
|
28
|
-
return [...this._nodes.values()].map(
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
return [...this._nodes.values()].map(view);
|
|
34
|
+
}
|
|
35
|
+
/** One node of the live tree by id, as it stands now — for a caller rendering one entry. */
|
|
36
|
+
node(nodeId) {
|
|
37
|
+
const n = this._nodes.get(nodeId);
|
|
38
|
+
return n && view(n);
|
|
33
39
|
}
|
|
34
40
|
/** The capped tail of milestone lines, oldest first. */
|
|
35
41
|
milestones() {
|
|
@@ -60,7 +66,13 @@ export class RunModel {
|
|
|
60
66
|
existing.order = this.seq++; // most-recent activity wins the ordering
|
|
61
67
|
return existing;
|
|
62
68
|
}
|
|
63
|
-
const node = {
|
|
69
|
+
const node = {
|
|
70
|
+
path: entry.path,
|
|
71
|
+
status: "running",
|
|
72
|
+
tags: [],
|
|
73
|
+
order: this.seq++,
|
|
74
|
+
startedAt: entry.ts,
|
|
75
|
+
};
|
|
64
76
|
this._nodes.set(entry.nodeId, node);
|
|
65
77
|
return node;
|
|
66
78
|
}
|
|
@@ -69,10 +81,12 @@ export class RunModel {
|
|
|
69
81
|
// A node reports `done · <status>` when it settles; "launched" leaves it running.
|
|
70
82
|
if (entry.message.startsWith("done · ")) {
|
|
71
83
|
node.status = statusOf(entry.message.slice("done · ".length));
|
|
84
|
+
node.endedAt = entry.ts;
|
|
72
85
|
}
|
|
73
86
|
}
|
|
74
87
|
else if (entry.level === "event" && entry.source === "cancel") {
|
|
75
88
|
node.status = "cancelled";
|
|
89
|
+
node.endedAt ??= entry.ts;
|
|
76
90
|
}
|
|
77
91
|
}
|
|
78
92
|
push(text) {
|
|
@@ -81,6 +95,25 @@ export class RunModel {
|
|
|
81
95
|
this._milestones.shift();
|
|
82
96
|
}
|
|
83
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* What a node is doing, for its "latest line": its output and its own events (a watcher's
|
|
100
|
+
* `update · …`), not its lifecycle or a cancellation, which its status already says.
|
|
101
|
+
*/
|
|
102
|
+
function isActivity(e) {
|
|
103
|
+
if (e.level === "info" || e.level === "error")
|
|
104
|
+
return true;
|
|
105
|
+
return e.level === "event" && e.source !== "lifecycle" && e.source !== "cancel";
|
|
106
|
+
}
|
|
107
|
+
function view(n) {
|
|
108
|
+
return {
|
|
109
|
+
path: n.path,
|
|
110
|
+
status: n.status,
|
|
111
|
+
tags: [...n.tags],
|
|
112
|
+
startedAt: n.startedAt,
|
|
113
|
+
...(n.endedAt === undefined ? {} : { endedAt: n.endedAt }),
|
|
114
|
+
...(n.last === undefined ? {} : { last: n.last }),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
84
117
|
function statusOf(s) {
|
|
85
118
|
return s === "ok" ? "ok" : s === "cancelled" ? "cancelled" : "failed";
|
|
86
119
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "omkit",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.24",
|
|
4
4
|
"description": "A tiny runtime for developer workflows: typed actions that pass runtime capabilities and emit typed events, under one run and one shared log. The operational/runtime layer to tskb's knowledge layer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|