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 +15 -15
- 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 +9 -2
- package/dist/cli/ui/views/OmList.js +32 -4
- 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 +62 -0
- package/dist/client/discovery.js +6 -3
- package/dist/client/outline.d.ts +8 -1
- package/dist/client/outline.js +44 -0
- package/dist/client/registry.d.ts +10 -0
- package/dist/core/types.d.ts +7 -0
- package/dist/output/RunModel.d.ts +8 -0
- package/dist/output/RunModel.js +39 -6
- package/dist/output/writers/report/report.css +1795 -361
- package/dist/output/writers/report/report.js +26 -1
- package/dist/output/writers/views.d.ts +2 -1
- package/package.json +1 -1
|
@@ -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,9 +34,34 @@ 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 })] }));
|
|
38
|
+
}
|
|
39
|
+
/** Steps listed before the rest collapse into `+N more`, so the box keeps a steady height. */
|
|
40
|
+
const MAX_STEPS = 10;
|
|
41
|
+
/** A description key as a label: `docsUrl` / `docs-url` / `docs_url` → `Docs url`. */
|
|
42
|
+
export function labelOf(key) {
|
|
43
|
+
const words = key
|
|
44
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
45
|
+
.replace(/[-_]+/g, " ")
|
|
46
|
+
.trim()
|
|
47
|
+
.toLowerCase();
|
|
48
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
49
|
+
}
|
|
50
|
+
/** One label/value row; the value wraps under itself, not under the label. */
|
|
51
|
+
function Field({ label, width, children, }) {
|
|
52
|
+
return (_jsxs(Box, { children: [_jsx(Box, { width: width + 2, flexShrink: 0, children: _jsx(Text, { dimColor: true, children: label }) }), _jsx(Box, { flexGrow: 1, children: children })] }));
|
|
35
53
|
}
|
|
36
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* The highlighted om in full: its name, then a field per thing it says about itself — the
|
|
56
|
+
* summary, any further `.describe(…)` props, where it is defined — then its steps, numbered.
|
|
57
|
+
* The steps are the static outline, an approximation (see `outlineBody`), and say so.
|
|
58
|
+
*/
|
|
37
59
|
function OmDetail({ om, root }) {
|
|
38
|
-
|
|
60
|
+
const details = (om.details ?? []).map((d) => ({ label: labelOf(d.key), value: d.value }));
|
|
61
|
+
const width = Math.max("Summary".length, "Defined".length, ...details.map((d) => d.label.length));
|
|
62
|
+
const steps = om.calls ?? [];
|
|
63
|
+
const shown = steps.slice(0, MAX_STEPS);
|
|
64
|
+
const stepWidth = Math.min(24, Math.max(0, ...shown.map((c) => c.name.length)));
|
|
65
|
+
const numWidth = String(shown.length).length;
|
|
66
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: om.name }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Field, { label: "Summary", width: width, children: om.summary ? (_jsx(Text, { wrap: "wrap", children: om.summary })) : (_jsxs(Text, { dimColor: true, italic: true, children: ["none \u00B7 add .describe(", "{ summary }", ") to the om"] })) }), details.map((d) => (_jsx(Field, { label: d.label, width: width, children: _jsx(Text, { wrap: "wrap", children: d.value }) }, d.label))), _jsx(Field, { label: "Defined", width: width, children: _jsx(Text, { dimColor: true, wrap: "truncate-start", children: `${displayPath(om.file, root)}:${om.line}` }) })] }), shown.length ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "Steps" }), _jsx(Text, { dimColor: true, italic: true, children: " read from source · approximate" })] }), shown.map((c, i) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { dimColor: true, children: ` ${String(i + 1).padStart(numWidth)} ` }), c.tag ? c.name.padEnd(stepWidth) : c.name, c.tag ? _jsx(Text, { color: "cyan", children: ` ${c.tag}` }) : null] }, `${c.name}\0${c.tag ?? ""}`))), steps.length > shown.length ? (_jsx(Text, { dimColor: true, children: ` ${" ".repeat(numWidth)} +${steps.length - shown.length} more` })) : null] })) : null] }));
|
|
39
67
|
}
|
|
@@ -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: `main.log` and the report, and the assert
|
|
17
|
+
* tally when the run asserted anything. The full block (every sibling log) is in the run folder
|
|
18
|
+
* and at the foot 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,62 @@
|
|
|
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: `main.log` and the report, and the assert
|
|
38
|
+
* tally when the run asserted anything. The full block (every sibling log) is in the run folder
|
|
39
|
+
* and at the foot 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("main log ") || trimmed.startsWith("report ")) {
|
|
50
|
+
lines.push({ text: ` ${trimmed}`, dim: true });
|
|
51
|
+
}
|
|
52
|
+
const tally = /⊨ (\d+) passed · ⊭ (\d+) failed/.exec(trimmed);
|
|
53
|
+
if (tally && Number(tally[1]) + Number(tally[2]) > 0) {
|
|
54
|
+
lines.push({
|
|
55
|
+
text: ` asserts ${tally[0]}`,
|
|
56
|
+
color: Number(tally[2]) ? "red" : undefined,
|
|
57
|
+
dim: !Number(tally[2]),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return lines;
|
|
62
|
+
}
|
package/dist/client/discovery.js
CHANGED
|
@@ -6,7 +6,7 @@ import { omHash } from "../foundation/ids.js";
|
|
|
6
6
|
import { fsSafe } from "../foundation/fsSafe.js";
|
|
7
7
|
import { canonicalPath } from "../foundation/canonicalPath.js";
|
|
8
8
|
import { tsxLoader } from "./runner.js";
|
|
9
|
-
import { chainRoot, importedNames, outlineBody, summaryOf } from "./outline.js";
|
|
9
|
+
import { chainRoot, detailsOf, importedNames, outlineBody, summaryOf } from "./outline.js";
|
|
10
10
|
import { checkedProgram } from "./tsCache.js";
|
|
11
11
|
/**
|
|
12
12
|
* Statically scan the project described by `tsconfigPath` for runnable oms and inspectable
|
|
@@ -43,12 +43,13 @@ export function discover(tsconfigPath) {
|
|
|
43
43
|
node.expression.text === names.om) {
|
|
44
44
|
const arg = node.arguments[0];
|
|
45
45
|
if (arg && ts.isStringLiteralLike(arg)) {
|
|
46
|
-
const { calls, summary } = outlines.get(arg.text) ?? {};
|
|
46
|
+
const { calls, summary, details } = outlines.get(arg.text) ?? {};
|
|
47
47
|
oms.push({
|
|
48
48
|
name: arg.text,
|
|
49
49
|
file,
|
|
50
50
|
line: lineOf(sf, node),
|
|
51
51
|
...(summary !== undefined ? { summary } : {}),
|
|
52
|
+
...(details?.length ? { details } : {}),
|
|
52
53
|
...(calls?.length ? { calls } : {}),
|
|
53
54
|
});
|
|
54
55
|
}
|
|
@@ -80,7 +81,7 @@ export function discover(tsconfigPath) {
|
|
|
80
81
|
}
|
|
81
82
|
/**
|
|
82
83
|
* Outline every `om("name")….run(body)` chain in a file, keyed by om name, along with the
|
|
83
|
-
* chain's literal `.describe({ summary })`.
|
|
84
|
+
* chain's literal `.describe({ summary, … })`.
|
|
84
85
|
*
|
|
85
86
|
* Collected in its own pass rather than alongside the `om(...)` match, because a body is only
|
|
86
87
|
* reachable from the `.run` end of the chain and the om name only from the other — and the
|
|
@@ -99,9 +100,11 @@ function outlinesIn(sf, omName, imported) {
|
|
|
99
100
|
const name = root?.call.arguments[0];
|
|
100
101
|
if (root?.name === omName && name && ts.isStringLiteralLike(name)) {
|
|
101
102
|
const summary = summaryOf(root);
|
|
103
|
+
const details = detailsOf(root);
|
|
102
104
|
outlines.set(name.text, {
|
|
103
105
|
...(body && ts.isFunctionLike(body) ? { calls: outlineBody(body, imported) } : {}),
|
|
104
106
|
...(summary !== undefined ? { summary } : {}),
|
|
107
|
+
...(details.length ? { details } : {}),
|
|
105
108
|
});
|
|
106
109
|
}
|
|
107
110
|
}
|
package/dist/client/outline.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import ts from "typescript";
|
|
2
|
-
import type { OmCall } from "./registry.ts";
|
|
2
|
+
import type { DescriptionDetail, OmCall } from "./registry.ts";
|
|
3
3
|
/**
|
|
4
4
|
* Every identifier a file binds through an import — the whole allow-list for the outline.
|
|
5
5
|
* An action always arrives by import (`omkit/actions`, or a local actions module), so
|
|
@@ -40,3 +40,10 @@ export declare function outlineBody(body: ts.Node, imported: ReadonlySet<string>
|
|
|
40
40
|
*/
|
|
41
41
|
/** The `summary` of a `.describe({ summary: "…" })` in the chain, when it is a string literal. */
|
|
42
42
|
export declare function summaryOf(chain: ChainRoot): string | undefined;
|
|
43
|
+
/**
|
|
44
|
+
* The props of a `.describe({ … })` in the chain besides `summary`, in source order, with each
|
|
45
|
+
* value rendered as text. Like {@link summaryOf}, only what the AST can read without evaluating:
|
|
46
|
+
* a string, number or boolean literal, or an array of those (joined with commas). A prop with
|
|
47
|
+
* any other value is left out rather than guessed at.
|
|
48
|
+
*/
|
|
49
|
+
export declare function detailsOf(chain: ChainRoot): DescriptionDetail[];
|
package/dist/client/outline.js
CHANGED
|
@@ -106,6 +106,50 @@ export function summaryOf(chain) {
|
|
|
106
106
|
}
|
|
107
107
|
return undefined;
|
|
108
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* The props of a `.describe({ … })` in the chain besides `summary`, in source order, with each
|
|
111
|
+
* value rendered as text. Like {@link summaryOf}, only what the AST can read without evaluating:
|
|
112
|
+
* a string, number or boolean literal, or an array of those (joined with commas). A prop with
|
|
113
|
+
* any other value is left out rather than guessed at.
|
|
114
|
+
*/
|
|
115
|
+
export function detailsOf(chain) {
|
|
116
|
+
const details = [];
|
|
117
|
+
for (const call of chain.calls) {
|
|
118
|
+
const callee = call.expression;
|
|
119
|
+
if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== "describe")
|
|
120
|
+
continue;
|
|
121
|
+
const arg = call.arguments[0];
|
|
122
|
+
if (!arg || !ts.isObjectLiteralExpression(arg))
|
|
123
|
+
continue;
|
|
124
|
+
for (const prop of arg.properties) {
|
|
125
|
+
if (!ts.isPropertyAssignment(prop))
|
|
126
|
+
continue;
|
|
127
|
+
const key = ts.isIdentifier(prop.name) || ts.isStringLiteralLike(prop.name)
|
|
128
|
+
? prop.name.text
|
|
129
|
+
: undefined;
|
|
130
|
+
if (key === undefined || key === "summary")
|
|
131
|
+
continue;
|
|
132
|
+
const value = literalText(prop.initializer);
|
|
133
|
+
if (value !== undefined)
|
|
134
|
+
details.push({ key, value });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return details;
|
|
138
|
+
}
|
|
139
|
+
/** A literal's value as text, or `undefined` when it needs evaluating. Arrays join with commas. */
|
|
140
|
+
function literalText(node) {
|
|
141
|
+
if (ts.isStringLiteralLike(node) || ts.isNumericLiteral(node))
|
|
142
|
+
return node.text;
|
|
143
|
+
if (node.kind === ts.SyntaxKind.TrueKeyword)
|
|
144
|
+
return "true";
|
|
145
|
+
if (node.kind === ts.SyntaxKind.FalseKeyword)
|
|
146
|
+
return "false";
|
|
147
|
+
if (ts.isArrayLiteralExpression(node)) {
|
|
148
|
+
const items = node.elements.map((e) => literalText(e));
|
|
149
|
+
return items.every((i) => i !== undefined) ? items.join(", ") : undefined;
|
|
150
|
+
}
|
|
151
|
+
return undefined;
|
|
152
|
+
}
|
|
109
153
|
function tagOf(chain) {
|
|
110
154
|
let tag;
|
|
111
155
|
for (const call of chain.calls) {
|
|
@@ -5,6 +5,11 @@ export interface OmCall {
|
|
|
5
5
|
/** The `.tag("…")` literal from the same fluent chain, when the author wrote one. */
|
|
6
6
|
readonly tag?: string;
|
|
7
7
|
}
|
|
8
|
+
/** One `.describe(…)` prop besides `summary`: its key, and its literal value as text. */
|
|
9
|
+
export interface DescriptionDetail {
|
|
10
|
+
readonly key: string;
|
|
11
|
+
readonly value: string;
|
|
12
|
+
}
|
|
8
13
|
/** A runnable om discovered from a top-level `om("name", …)` call. */
|
|
9
14
|
export interface DiscoveredOm {
|
|
10
15
|
/** The om's name — its first string argument. */
|
|
@@ -19,6 +24,11 @@ export interface DiscoveredOm {
|
|
|
19
24
|
* the import-based pass, `discoverRegistrations`.
|
|
20
25
|
*/
|
|
21
26
|
readonly summary?: string;
|
|
27
|
+
/**
|
|
28
|
+
* The `.describe(…)` props besides `summary`, in source order, each value rendered as text
|
|
29
|
+
* (a list joined with commas). Read statically like `summary`, so only literal values appear.
|
|
30
|
+
*/
|
|
31
|
+
readonly details?: readonly DescriptionDetail[];
|
|
22
32
|
/**
|
|
23
33
|
* A static approximation of what the body calls, in source order. Absent when the walk
|
|
24
34
|
* found nothing recognisable. Never a contract — see {@link import("./outline.ts").outlineBody}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -63,7 +63,14 @@ export interface ActionContext<Events extends object = NoEvents, Handle = void>
|
|
|
63
63
|
export interface OmDescription {
|
|
64
64
|
/** One line saying what this does. */
|
|
65
65
|
summary: string;
|
|
66
|
+
/**
|
|
67
|
+
* Anything else worth knowing before running it — an owner, a docs link, what it needs.
|
|
68
|
+
* Listed under the summary, keyed by name, in the interactive picker and the run report.
|
|
69
|
+
*/
|
|
70
|
+
readonly [prop: string]: DescriptionValue;
|
|
66
71
|
}
|
|
72
|
+
/** What a description prop can hold: values that read plainly in a terminal and a report. */
|
|
73
|
+
export type DescriptionValue = string | number | boolean | readonly string[] | undefined;
|
|
67
74
|
/** How an om or action behaves when a client runs it as an MCP tool. */
|
|
68
75
|
export type McpMode = "settling" | "long-lived";
|
|
69
76
|
/**
|
|
@@ -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
|
}
|