tickmarkr 1.81.0 → 1.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/brand.d.ts +4 -1
- package/dist/brand.js +46 -7
- package/dist/cli/commands/approve.js +53 -8
- package/dist/cli/commands/plan.js +17 -2
- package/dist/cli/commands/report.js +15 -6
- package/dist/cli/commands/ui.js +13 -1
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/compile/collateral.d.ts +19 -0
- package/dist/compile/collateral.js +90 -0
- package/dist/compile/index.js +18 -4
- package/dist/drivers/herdr.d.ts +1 -0
- package/dist/drivers/herdr.js +19 -0
- package/dist/drivers/types.d.ts +1 -0
- package/dist/gates/acceptance.js +50 -8
- package/dist/gates/llm.js +34 -19
- package/dist/gates/review.d.ts +9 -1
- package/dist/gates/review.js +173 -7
- package/dist/gates/run-gates.d.ts +1 -0
- package/dist/gates/run-gates.js +18 -1
- package/dist/report/bundle.d.ts +6 -1
- package/dist/report/bundle.js +13 -3
- package/dist/run/daemon.d.ts +5 -0
- package/dist/run/daemon.js +188 -27
- package/dist/run/journal.d.ts +5 -0
- package/dist/run/journal.js +71 -1
- package/dist/tui/cockpit/capture.d.ts +107 -1
- package/dist/tui/cockpit/capture.js +287 -10
- package/dist/tui/cockpit/components.d.ts +40 -47
- package/dist/tui/cockpit/components.js +66 -50
- package/dist/tui/cockpit/derive.d.ts +73 -1
- package/dist/tui/cockpit/derive.js +211 -15
- package/dist/tui/cockpit/keys.d.ts +114 -19
- package/dist/tui/cockpit/keys.js +413 -44
- package/dist/tui/cockpit/layout.d.ts +231 -0
- package/dist/tui/cockpit/layout.js +307 -0
- package/dist/tui/cockpit/live.d.ts +65 -3
- package/dist/tui/cockpit/live.js +418 -48
- package/dist/tui/cockpit/pointer.d.ts +261 -0
- package/dist/tui/cockpit/pointer.js +610 -0
- package/dist/tui/cockpit/run-cockpit.d.ts +93 -6
- package/dist/tui/cockpit/run-cockpit.js +805 -164
- package/dist/tui/cockpit/setup-cockpit.d.ts +146 -1
- package/dist/tui/cockpit/setup-cockpit.js +337 -5
- package/dist/tui/cockpit/views.d.ts +72 -0
- package/dist/tui/cockpit/views.js +56 -0
- package/dist/tui/cockpit/width.d.ts +105 -0
- package/dist/tui/cockpit/width.js +276 -0
- package/fixtures/speckit-sample/tasks.md +2 -2
- package/package.json +3 -1
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { Box } from "ink";
|
|
3
|
-
import { cloneElement, } from "react";
|
|
2
|
+
import { Box, useApp, useStdout } from "ink";
|
|
3
|
+
import { cloneElement, createContext, useContext, useEffect, useInsertionEffect, useMemo, useRef, useSyncExternalStore, } from "react";
|
|
4
4
|
import { GLYPHS, PLAIN_COMPACT_LOCKUP, } from "../../brand.js";
|
|
5
|
-
import { allocateBandColumns, BandLines, BodyText, CockpitGrid, composeBandLine, JournalRowPanel,
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import { allocateBandColumns, BandLines, BodyText, CockpitGrid, composeBandLine, JournalRowPanel, keyRosterLines, Panel, PANEL_CHROME_ROWS, ProgressMeter, Sparkline, StatTile, StatusStrip, } from "./components.js";
|
|
6
|
+
import { fieldReading } from "./derive.js";
|
|
7
|
+
import { initialRunInteractionState, projectRunKeyEntries, reconcileRunInteraction, RUN_SIDE_RAIL_COLUMN_FLOOR, runPanelFocusOrder, runSideRailVisible, selectableRunViewRowIds, } from "./keys.js";
|
|
8
|
+
import { DECISIONS_TAB_TITLE, RUN_VIEWS, runViewById, setupSectionAt, SETUP_SECTIONS, } from "./views.js";
|
|
9
|
+
import { FRAME_HEADER_GAP, FRAME_NESTED_ROW_BANDS, FULL_JOURNAL_ROWS, planFrame, RUN_PANEL_HORIZONTAL_CHROME, SIDEBAR_COLUMN_FLOOR, } from "./layout.js";
|
|
10
|
+
import { pointerHoverRow, pointerRestingCell, sessionRailOverride, watchPointerRest, watchSessionRailOverride, } from "./pointer.js";
|
|
11
|
+
import { cellWidth, fitCells, wrapCells } from "./width.js";
|
|
8
12
|
export { deriveRunCockpitData } from "./derive.js";
|
|
9
13
|
export { PANEL_CHROME_ROWS } from "./components.js";
|
|
10
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The width focus and key routing resolve at, from a measured width — the form
|
|
16
|
+
* used before a plan exists. Once one does, `plannedKeyColumns` (pointer.ts)
|
|
17
|
+
* reads the same answer off the bands the plan actually drew.
|
|
18
|
+
*/
|
|
19
|
+
export function runKeyColumns(columns) {
|
|
20
|
+
return columns >= SIDEBAR_COLUMN_FLOOR
|
|
21
|
+
? Math.max(columns, RUN_SIDE_RAIL_COLUMN_FLOOR)
|
|
22
|
+
: columns;
|
|
23
|
+
}
|
|
11
24
|
const NARROW_HEADER_ALLOCATION = {
|
|
12
25
|
chromeColumns: 0,
|
|
13
26
|
minimumReadableColumns: 1,
|
|
@@ -19,52 +32,15 @@ const NARROW_HEADER_ALLOCATION = {
|
|
|
19
32
|
* rows it will really occupy instead of a hand-tuned constant.
|
|
20
33
|
*/
|
|
21
34
|
export function wrappedRows(text, columns) {
|
|
22
|
-
|
|
23
|
-
return 1;
|
|
24
|
-
let rows = 1;
|
|
25
|
-
let used = 0;
|
|
26
|
-
for (const [index, word] of text.split(" ").entries()) {
|
|
27
|
-
// ponytail: code points, not display columns — every glyph the cockpit
|
|
28
|
-
// draws is one column wide. Reach for a width table only if it stops being.
|
|
29
|
-
const length = [...word].length;
|
|
30
|
-
if (index !== 0) {
|
|
31
|
-
if (used >= columns) {
|
|
32
|
-
rows += 1;
|
|
33
|
-
used = 0;
|
|
34
|
-
}
|
|
35
|
-
used += 1;
|
|
36
|
-
}
|
|
37
|
-
if (length > columns) {
|
|
38
|
-
const startsThisLine = 1 + Math.floor((length - (columns - used) - 1) / columns);
|
|
39
|
-
if (Math.floor((length - 1) / columns) < startsThisLine) {
|
|
40
|
-
rows += 1;
|
|
41
|
-
used = 0;
|
|
42
|
-
}
|
|
43
|
-
for (let taken = 0; taken < length; taken += 1) {
|
|
44
|
-
if (used < columns)
|
|
45
|
-
used += 1;
|
|
46
|
-
else {
|
|
47
|
-
rows += 1;
|
|
48
|
-
used = 1;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
if (used > 0 && used + length > columns) {
|
|
54
|
-
rows += 1;
|
|
55
|
-
used = 0;
|
|
56
|
-
}
|
|
57
|
-
used += length;
|
|
58
|
-
}
|
|
59
|
-
return rows;
|
|
35
|
+
return wrapCells(text, columns).length;
|
|
60
36
|
}
|
|
61
37
|
/** The rows a bordered panel occupies when its body wraps into `columns`. */
|
|
62
38
|
export function panelRows(lines, columns) {
|
|
63
|
-
return lines.reduce((rows, line) => rows +
|
|
39
|
+
return lines.reduce((rows, line) => rows + wrapCells(line, columns, { continuationPrefix: "↳ " }).length, PANEL_CHROME_ROWS);
|
|
64
40
|
}
|
|
65
41
|
function IdentityHeader({ data, columns, secondary = true, }) {
|
|
66
42
|
const lockup = PLAIN_COMPACT_LOCKUP.split("\n");
|
|
67
|
-
if (columns
|
|
43
|
+
if (!runSideRailVisible(columns)) {
|
|
68
44
|
const primary = [
|
|
69
45
|
{ title: "", lines: [lockup[0] ?? "tickmarkr"] },
|
|
70
46
|
{
|
|
@@ -89,160 +65,825 @@ function NarrowHeaderBand({ columns, content, rightStrong = false, }) {
|
|
|
89
65
|
const widths = allocateBandColumns(columns, content, NARROW_HEADER_ALLOCATION);
|
|
90
66
|
return (_jsxs(CockpitGrid, { columns: columns, columnContents: content, allocation: NARROW_HEADER_ALLOCATION, children: [_jsx(BandLines, { columns: Math.max(1, widths[0]), lines: content[0].lines, emphasis: "strong" }), _jsx(Box, { alignItems: "flex-end", flexDirection: "column", children: _jsx(BandLines, { columns: Math.max(1, widths[1]), lines: content[1].lines, emphasis: rightStrong ? "strong" : "normal" }) })] }));
|
|
91
67
|
}
|
|
92
|
-
|
|
93
|
-
|
|
68
|
+
const RUN_SIDE_RAIL_WIDTH = 16;
|
|
69
|
+
/** The header caption: the run's identity line, planned as the header's refinable sub-region. */
|
|
70
|
+
function frameCaption(data) {
|
|
71
|
+
return `${data.runId} · ${data.branch} · ${data.status} · ${data.elapsed}`;
|
|
94
72
|
}
|
|
95
|
-
|
|
96
|
-
|
|
73
|
+
/** The header's fixed left part: brand, version and the two tab labels. */
|
|
74
|
+
function headerLead(data, plan) {
|
|
75
|
+
const tabs = plan.tab === "watch"
|
|
76
|
+
? `[ WATCH ]${FRAME_HEADER_GAP}${DECISIONS_TAB_TITLE}`
|
|
77
|
+
: `WATCH${FRAME_HEADER_GAP}[ ${DECISIONS_TAB_TITLE} ]`;
|
|
78
|
+
return `tickmarkr ${data.binaryVersion}${FRAME_HEADER_GAP}${tabs}`;
|
|
97
79
|
}
|
|
98
|
-
/** The
|
|
99
|
-
function
|
|
100
|
-
|
|
101
|
-
if (query === "")
|
|
102
|
-
return rows;
|
|
103
|
-
return rows.filter((row) => row.text.toLowerCase().includes(query));
|
|
80
|
+
/** The one plain header line: brand, version and the two tab labels, the active one marked. */
|
|
81
|
+
function PlannedHeader({ data, plan, columns, }) {
|
|
82
|
+
return (_jsx(BodyText, { emphasis: "strong", children: fitCells(headerLead(data, plan), Math.max(1, columns)) }));
|
|
104
83
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
const
|
|
84
|
+
/** The header's caption sub-region: the run identity, right-aligned in it. */
|
|
85
|
+
function PlannedCaption({ data, columns, }) {
|
|
86
|
+
const caption = frameCaption(data);
|
|
87
|
+
const pad = Math.max(0, columns - cellWidth(caption));
|
|
88
|
+
return (_jsx(BodyText, { children: fitCells(`${" ".repeat(pad)}${caption}`, Math.max(1, columns)) }));
|
|
89
|
+
}
|
|
90
|
+
function runViewBodyColumns(columns, navigationRail, keysRail) {
|
|
91
|
+
const railColumns = (navigationRail ? RUN_SIDE_RAIL_WIDTH + 1 : 0)
|
|
92
|
+
+ (keysRail ? RUN_SIDE_RAIL_WIDTH + 1 : 0);
|
|
93
|
+
return Math.max(1, columns - railColumns - RUN_PANEL_HORIZONTAL_CHROME);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* One row of a side rail. A rail row is exactly one row: where the layout
|
|
97
|
+
* squeezes the rail narrower than a label, the label is clipped rather than
|
|
98
|
+
* wrapped onto a second row nothing charges to the height budget — the rail's
|
|
99
|
+
* height is then a constant, and a rail can never be what overruns the terminal.
|
|
100
|
+
*/
|
|
101
|
+
function RailRow({ label, selected, }) {
|
|
102
|
+
return (_jsx(Box, { height: 1, overflow: "hidden", children: _jsxs(BodyText, { emphasis: selected ? "strong" : "normal", children: [selected ? GLYPHS.pointer : " ", " ", label] }) }));
|
|
103
|
+
}
|
|
104
|
+
function NavigationPanel({ focused = false, selection = 0, }) {
|
|
105
|
+
return (_jsx(Panel, { title: "VIEWS", width: RUN_SIDE_RAIL_WIDTH, focused: focused, children: RUN_VIEWS.map((view, index) => (_jsx(RailRow, { label: view.label, selected: index === selection }, view.id))) }));
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The setup tab's rail. It draws in the same column as VIEWS and holds fewer
|
|
109
|
+
* rows than it, so the rail can never be what decides the frame's height — the
|
|
110
|
+
* body's budget stays the one budget, and the labels fit their column unwrapped.
|
|
111
|
+
*/
|
|
112
|
+
/** The lines the selected setup section states about itself. */
|
|
113
|
+
function setupSectionLines(section, data) {
|
|
114
|
+
if (section.readOnly) {
|
|
115
|
+
return [`${section.title} is read-only on this surface`];
|
|
116
|
+
}
|
|
117
|
+
const parked = data.taskRows.filter((row) => row.state === "human");
|
|
118
|
+
if (parked.length === 0)
|
|
119
|
+
return ["No parked decisions in this engagement"];
|
|
120
|
+
return parked.map((row) => `${row.taskId} · parked · attempt ${row.attempts ?? 0}`);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The plan's item rows, and the ref the frame registers their painted node
|
|
124
|
+
* through. Every body panel draws its list into this one box, so the rectangle
|
|
125
|
+
* the conformance oracle measures off the paint is the rectangle planFrame
|
|
126
|
+
* planned — which is exactly the pin a field standing beside the plan could
|
|
127
|
+
* never carry. Absent on the unsized legacy surface, which has no plan.
|
|
128
|
+
*/
|
|
129
|
+
const ItemRowsBand = createContext(undefined);
|
|
130
|
+
/**
|
|
131
|
+
* The identity of the row a live pointer is resting on, or none. It is drawn
|
|
132
|
+
* state and only drawn state: nothing reads it back, no interaction state
|
|
133
|
+
* carries it, and the one provider is fed by a `hover` prop the frame's own
|
|
134
|
+
* callers supply. A capture supplies none — `capture.ts` builds this frame from
|
|
135
|
+
* data, size and interaction alone — so every golden, anchor and colour capture
|
|
136
|
+
* records the pointerless appearance by construction rather than by a guard
|
|
137
|
+
* stripping a highlight back out afterwards.
|
|
138
|
+
*/
|
|
139
|
+
const HoveredRow = createContext(null);
|
|
140
|
+
/**
|
|
141
|
+
* A body panel's list, drawn into the plan's own item rows and nowhere else.
|
|
142
|
+
* The band is consumed by the outermost panel that asks for it — a panel nested
|
|
143
|
+
* inside that one (the RUN view's own journal) is part of the list, not a second
|
|
144
|
+
* claim on the region, so the band is cleared for everything below.
|
|
145
|
+
*/
|
|
146
|
+
function ItemRows({ children }) {
|
|
147
|
+
const band = useContext(ItemRowsBand);
|
|
148
|
+
if (band === undefined)
|
|
149
|
+
return _jsx(_Fragment, { children: children });
|
|
150
|
+
return (_jsx(Box, { ref: band.regionRef, flexDirection: "column", height: band.region.rows, width: band.region.columns, flexShrink: 0, children: _jsx(ItemRowsBand.Provider, { value: undefined, children: children }) }));
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The setup tab's body, drawn inside the same row budget the watch tab's views
|
|
154
|
+
* are drawn inside — the tab an operator switches to can never be taller than
|
|
155
|
+
* the one they switched away from.
|
|
156
|
+
*/
|
|
157
|
+
function SetupTabPanel({ data, selection, focused, keyEntries, help, bodyRows, bodyColumns, }) {
|
|
158
|
+
const section = setupSectionAt(selection);
|
|
159
|
+
return (_jsx(SizedLinesPanel, { title: help ? "HELP" : section.title, lines: help ? helpLines(keyEntries) : setupSectionLines(section, data), bodyRows: bodyRows, wrapColumns: bodyColumns, focused: focused, flexGrow: 1 }));
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The one keybar, drawn once. While the help overlay is up it is not drawn at
|
|
163
|
+
* all: the overlay is this same projected roster, one entry per line, so
|
|
164
|
+
* drawing both states every key twice — and costs the frame the very rows the
|
|
165
|
+
* shortest supported terminal has none of.
|
|
166
|
+
*/
|
|
167
|
+
function RunKeybar({ entries, width, help, }) {
|
|
168
|
+
if (help)
|
|
169
|
+
return _jsx(_Fragment, {});
|
|
170
|
+
// The plan grants this band one row: the first lossless roster slice is drawn explicitly.
|
|
171
|
+
return (_jsx(Box, { height: 1, flexShrink: 0, overflow: "hidden", children: _jsx(BodyText, { emphasis: "dim", children: keyRosterLines(entries, width)[0] ?? "" }) }));
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The selectable rows owned by one view. The overview's journal is a passive
|
|
175
|
+
* tail, not its item collection; tasks, gates and fleet remain honestly empty
|
|
176
|
+
* until their dedicated derivations land. Journal rows keep the source-record
|
|
177
|
+
* identities assigned by the production journal derivation.
|
|
178
|
+
*/
|
|
179
|
+
export function deriveRunViewRows(data, viewId, filterQuery = "") {
|
|
180
|
+
const query = filterQuery.trim().toLowerCase();
|
|
181
|
+
const rows = promotedViewRows(data, viewId);
|
|
182
|
+
return query === ""
|
|
183
|
+
? rows
|
|
184
|
+
: rows.filter((row) => row.text.toLowerCase().includes(query));
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The rows a view owns, in the identities `runViewRowIdentities` assigns them —
|
|
188
|
+
* the two must agree, or the surface's keys would stand on rows the frame never
|
|
189
|
+
* drew. The overview owns no list of its own, and the journal is a tail.
|
|
190
|
+
*/
|
|
191
|
+
function promotedViewRows(data, viewId) {
|
|
192
|
+
if (viewId === "journal")
|
|
193
|
+
return data.journalRows;
|
|
194
|
+
if (viewId === "tasks") {
|
|
195
|
+
return data.taskRows.map((row) => ({
|
|
196
|
+
id: row.id,
|
|
197
|
+
time: fieldReading(row.lastEventTime),
|
|
198
|
+
state: taskRowState(row.state),
|
|
199
|
+
text: `${row.taskId} · ${fieldReading(row.state)} · attempt ${fieldReading(row.attempts)} · ${fieldReading(row.actor)}${row.title === undefined ? "" : ` · ${row.title}`}`,
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
if (viewId === "gates") {
|
|
203
|
+
return data.gateRows.map((row) => ({
|
|
204
|
+
id: row.id,
|
|
205
|
+
time: row.time,
|
|
206
|
+
state: row.state,
|
|
207
|
+
text: `${fieldReading(row.taskId)} · ${fieldReading(row.gate)} · ${row.pass === undefined ? fieldReading(undefined) : row.pass ? "pass" : "fail"}${row.details === undefined ? "" : ` · ${row.details}`}`,
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
if (viewId === "fleet") {
|
|
211
|
+
return data.fleetRows.map((row) => ({
|
|
212
|
+
id: row.id,
|
|
213
|
+
time: fieldReading(row.lastEventTime),
|
|
214
|
+
state: "neutral",
|
|
215
|
+
text: `${row.adapter} · ${row.model} · dispatches ${fieldReading(row.dispatches)}`,
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
/** A task's recorded state read as the component vocabulary the rows draw in. */
|
|
221
|
+
function taskRowState(state) {
|
|
222
|
+
if (state === "done")
|
|
223
|
+
return "pass";
|
|
224
|
+
if (state === "failed")
|
|
225
|
+
return "fail";
|
|
226
|
+
if (state === "running" || state === "human")
|
|
227
|
+
return "active";
|
|
228
|
+
return "neutral";
|
|
229
|
+
}
|
|
230
|
+
function windowedRows(rows, bodyRows, selection) {
|
|
231
|
+
const limit = Math.max(0, Math.floor(bodyRows));
|
|
232
|
+
const selectedIndex = selection === null
|
|
233
|
+
? -1
|
|
234
|
+
: rows.findIndex((row) => row.id === selection);
|
|
235
|
+
const offset = selectedIndex < limit
|
|
236
|
+
? 0
|
|
237
|
+
: selectedIndex - limit + 1;
|
|
238
|
+
return {
|
|
239
|
+
rows: rows.slice(offset, offset + limit),
|
|
240
|
+
...(selectedIndex < 0 ? {} : { selection: selectedIndex - offset }),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* The identities the body's rows carry in the order the body draws them — the
|
|
245
|
+
* same window `SizedJournalPanel` paints, so a hit on a body row resolves to
|
|
246
|
+
* the row an operator is looking at rather than to a position in a collection
|
|
247
|
+
* the frame scrolled past. A body that draws no selectable list (the overview,
|
|
248
|
+
* the journal tail, an open detail, the help overlay, the decisions tab) owns
|
|
249
|
+
* no rows here.
|
|
250
|
+
*/
|
|
251
|
+
export function drawnRunViewRowIds(data, interaction, bodyRows) {
|
|
252
|
+
if (interaction.tab === "setup"
|
|
253
|
+
|| interaction.activeView === "run"
|
|
254
|
+
|| interaction.opened !== null
|
|
255
|
+
|| interaction.help)
|
|
256
|
+
return [];
|
|
257
|
+
const rows = deriveRunViewRows(data, interaction.activeView, interaction.filterQuery);
|
|
258
|
+
return selectableRunViewRowIds(interaction.activeView, windowedRows(rows, bodyRows, interaction.selection).rows.map((row) => row.id));
|
|
259
|
+
}
|
|
260
|
+
function SizedJournalPanel({ rows, bodyRows, title, selection, focused, }) {
|
|
261
|
+
const window = windowedRows(rows, bodyRows, selection);
|
|
262
|
+
// The hovered row is carried as its identity, never as a screen row: it was
|
|
263
|
+
// resolved through the plan once, and this panel finds it in the very window
|
|
264
|
+
// it is about to paint or does not draw it at all. A list this pointer cell
|
|
265
|
+
// acts on nothing in — the passive overview tail, the journal's unselectable
|
|
266
|
+
// rows — finds nothing here, so no row is decorated for being passed over.
|
|
267
|
+
const hovered = useContext(HoveredRow);
|
|
268
|
+
const hover = hovered === null
|
|
269
|
+
? -1
|
|
270
|
+
: window.rows.findIndex((row) => row.id === hovered);
|
|
271
|
+
const emptyRows = Math.max(0, bodyRows - window.rows.length);
|
|
272
|
+
const journal = JournalRowPanel({
|
|
273
|
+
rows: window.rows,
|
|
274
|
+
title,
|
|
275
|
+
selection: window.selection,
|
|
276
|
+
focused,
|
|
277
|
+
...(hover < 0 ? {} : { hover }),
|
|
278
|
+
});
|
|
109
279
|
const journalProps = journal.props;
|
|
110
|
-
return cloneElement(journal, {}, journalProps.children, emptyRows > 0 ? _jsx(Box, { height: emptyRows }, "journal-space") : null);
|
|
280
|
+
return cloneElement(journal, {}, _jsxs(ItemRows, { children: [journalProps.children, emptyRows > 0 ? _jsx(Box, { height: emptyRows }, "journal-space") : null] }, "items"));
|
|
111
281
|
}
|
|
112
282
|
/** A body panel (help overlay, opened event) padded to the journal's rows. */
|
|
113
|
-
function SizedLinesPanel({ title, lines, bodyRows, }) {
|
|
114
|
-
const
|
|
283
|
+
function SizedLinesPanel({ title, lines, bodyRows, wrapColumns, focused, flexGrow, }) {
|
|
284
|
+
const drawnLines = wrapColumns === undefined
|
|
285
|
+
? lines
|
|
286
|
+
: lines.flatMap((line) => composeBandLine(line, wrapColumns));
|
|
287
|
+
const visible = drawnLines.slice(0, bodyRows);
|
|
115
288
|
const emptyRows = Math.max(0, bodyRows - visible.length);
|
|
116
|
-
return (
|
|
289
|
+
return (_jsx(Panel, { title: title, focused: focused, flexGrow: flexGrow, children: _jsxs(ItemRows, { children: [visible.map((line, index) => (_jsx(Box, { height: 1, overflow: "hidden", children: _jsx(BodyText, { children: line }) }, `${index}:${line}`))), emptyRows > 0 && _jsx(Box, { height: emptyRows })] }) }));
|
|
117
290
|
}
|
|
118
291
|
/** The lines the help overlay draws: every advertised key and its promise. */
|
|
119
|
-
function helpLines() {
|
|
120
|
-
return
|
|
292
|
+
function helpLines(entries) {
|
|
293
|
+
return entries.map(({ key, label }) => `${key} ${label}`);
|
|
121
294
|
}
|
|
122
295
|
/** The lines the opened-event view draws for one journal row. */
|
|
123
296
|
function eventLines(row) {
|
|
124
297
|
return [`${row.time} · ${row.state}`, row.text];
|
|
125
298
|
}
|
|
299
|
+
/**
|
|
300
|
+
* A view drawn from the rows its own derivation produces: its detail while the
|
|
301
|
+
* opened row stands, its genuinely empty state when the derivation yields
|
|
302
|
+
* nothing, otherwise a window over the rows marked at the selection. The state
|
|
303
|
+
* arrives already repaired by the frame, which owns both it and the data.
|
|
304
|
+
*/
|
|
305
|
+
function RunViewPanel({ data, viewId, interaction, focused, keyEntries, bodyRows, bodyColumns, }) {
|
|
306
|
+
const view = runViewById(viewId);
|
|
307
|
+
const rows = deriveRunViewRows(data, viewId, interaction.filterQuery);
|
|
308
|
+
if (interaction.help) {
|
|
309
|
+
return (_jsx(Panel, { title: "HELP", focused: focused, flexGrow: 1, children: _jsx(ItemRows, { children: helpLines(keyEntries).map((line) => _jsx(BodyText, { children: line }, line)) }) }));
|
|
310
|
+
}
|
|
311
|
+
const opened = interaction.opened === null
|
|
312
|
+
? undefined
|
|
313
|
+
: rows.find((row) => row.id === interaction.opened);
|
|
314
|
+
if (opened !== undefined) {
|
|
315
|
+
return (_jsx(SizedLinesPanel, { title: view.detailTitle, lines: eventLines(opened), bodyRows: bodyRows, wrapColumns: bodyColumns, focused: focused, flexGrow: 1 }));
|
|
316
|
+
}
|
|
317
|
+
if (rows.length === 0) {
|
|
318
|
+
return (_jsx(Panel, { title: view.title, focused: focused, flexGrow: 1, children: _jsx(ItemRows, { children: _jsxs(BodyText, { emphasis: "dim", children: ["No ", view.id, " in this engagement"] }) }) }));
|
|
319
|
+
}
|
|
320
|
+
return (_jsx(SizedJournalPanel, { rows: rows, bodyRows: bodyRows, title: interaction.filterPrompt
|
|
321
|
+
? `${view.title} /${interaction.filterQuery}`
|
|
322
|
+
: view.title, selection: interaction.selection, focused: focused }));
|
|
323
|
+
}
|
|
126
324
|
/**
|
|
127
325
|
* What the journal's slot of the RUN panel draws under an interaction state:
|
|
128
|
-
* the help overlay while ? is up, the
|
|
129
|
-
*
|
|
130
|
-
* selection, titled with the open / prompt.
|
|
326
|
+
* the help overlay while ? is up, otherwise the passive overview tail. Row
|
|
327
|
+
* selection, filtering and detail live only on the Journal view above.
|
|
131
328
|
*/
|
|
132
|
-
function JournalBody({ data, interaction, bodyRows, }) {
|
|
133
|
-
const rows =
|
|
329
|
+
function JournalBody({ data, interaction, bodyRows, keyEntries, }) {
|
|
330
|
+
const rows = deriveRunViewRows(data, "journal");
|
|
134
331
|
if (interaction?.help === true) {
|
|
135
|
-
return _jsx(SizedLinesPanel, { title: "HELP", lines: helpLines(), bodyRows: bodyRows });
|
|
136
|
-
}
|
|
137
|
-
const opened = interaction?.opened ?? null;
|
|
138
|
-
if (opened !== null && opened < rows.length) {
|
|
139
|
-
return (_jsx(SizedLinesPanel, { title: "EVENT", lines: eventLines(rows[opened]), bodyRows: bodyRows }));
|
|
332
|
+
return (_jsx(SizedLinesPanel, { title: "HELP", lines: helpLines(keyEntries), bodyRows: bodyRows }));
|
|
140
333
|
}
|
|
141
|
-
return (_jsx(SizedJournalPanel, { rows: rows, bodyRows: bodyRows,
|
|
142
|
-
? `JOURNAL /${interaction.filterQuery}`
|
|
143
|
-
: undefined, selection: interaction === undefined || interaction.selection < 0
|
|
144
|
-
? undefined
|
|
145
|
-
: interaction.selection }));
|
|
334
|
+
return (_jsx(SizedJournalPanel, { rows: rows, bodyRows: bodyRows, selection: null }));
|
|
146
335
|
}
|
|
147
336
|
function RunStats({ data, mode, }) {
|
|
337
|
+
if (mode === "none")
|
|
338
|
+
return _jsx(_Fragment, {});
|
|
148
339
|
if (mode === "summary") {
|
|
149
340
|
return (_jsx(BodyText, { children: `tasks ${data.tasks.done}/${data.tasks.total} · gates ${data.gates.passed}/${data.gates.total} · pass ${data.passRate.value}%` }));
|
|
150
341
|
}
|
|
151
342
|
return (_jsxs(CockpitGrid, { children: [_jsx(StatTile, { label: "TASKS", value: `${data.tasks.done}/${data.tasks.total}`, samples: data.tasks.samples }), _jsx(StatTile, { label: "GATES", value: `${data.gates.passed}/${data.gates.total}`, samples: data.gates.samples }), _jsx(StatTile, { label: "PASS RATE", value: `${data.passRate.value}%`, samples: data.passRate.samples })] }));
|
|
152
343
|
}
|
|
344
|
+
/** The sparkline's block ramp, one glyph per trailing sample, newest last. */
|
|
345
|
+
const COMPACT_SPARK_BLOCKS = "▁▂▃▄▅▆▇█";
|
|
346
|
+
function compactSparkline(samples, cells) {
|
|
347
|
+
const buckets = samples.slice(-Math.max(0, cells));
|
|
348
|
+
const populated = buckets.filter((sample) => typeof sample === "number" && Number.isFinite(sample));
|
|
349
|
+
const minimum = populated.length > 0 ? Math.min(...populated) : 0;
|
|
350
|
+
const maximum = populated.length > 0 ? Math.max(...populated) : 0;
|
|
351
|
+
let glyphs = "";
|
|
352
|
+
for (const sample of buckets) {
|
|
353
|
+
if (sample === null) {
|
|
354
|
+
glyphs += " ";
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const level = maximum === minimum
|
|
358
|
+
? COMPACT_SPARK_BLOCKS.length - 1
|
|
359
|
+
: Math.round(((sample - minimum) / (maximum - minimum))
|
|
360
|
+
* (COMPACT_SPARK_BLOCKS.length - 1));
|
|
361
|
+
glyphs += COMPACT_SPARK_BLOCKS[level];
|
|
362
|
+
}
|
|
363
|
+
return glyphs;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* One vitals element of the sidebar's block, drawn whole: the plan has already
|
|
367
|
+
* decided which elements draw and in which mode, so nothing here measures or
|
|
368
|
+
* surrenders. The full form is a count row over a sparkline row (the meter: its
|
|
369
|
+
* label over its bar); the compact form is one inline row per element.
|
|
370
|
+
*/
|
|
371
|
+
function RailVitals({ data, columns, mode, elements, }) {
|
|
372
|
+
const figures = {
|
|
373
|
+
tasks: {
|
|
374
|
+
value: `${data.tasks.done}/${data.tasks.total}`,
|
|
375
|
+
samples: data.tasks.samples,
|
|
376
|
+
},
|
|
377
|
+
gates: {
|
|
378
|
+
value: `${data.gates.passed}/${data.gates.total}`,
|
|
379
|
+
samples: data.gates.samples,
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
const figure = (element) => {
|
|
383
|
+
const reading = figures[element];
|
|
384
|
+
if (mode === "compact") {
|
|
385
|
+
const lead = `${element} ${reading.value} `;
|
|
386
|
+
return (_jsx(Box, { height: 1, flexShrink: 0, overflow: "hidden", children: _jsx(BodyText, { emphasis: "strong", children: fitCells(`${lead}${compactSparkline(reading.samples, columns - cellWidth(lead))}`, columns) }) }, element));
|
|
387
|
+
}
|
|
388
|
+
return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(Box, { height: 1, flexShrink: 0, overflow: "hidden", children: _jsx(BodyText, { emphasis: "strong", children: fitCells(`${element} ${reading.value}`, columns) }) }), _jsx(Sparkline, { samples: reading.samples })] }, element));
|
|
389
|
+
};
|
|
390
|
+
const meter = mode === "compact"
|
|
391
|
+
? (_jsxs(Box, { height: 1, flexDirection: "row", flexWrap: "nowrap", flexShrink: 0, overflow: "hidden", children: [_jsx(BodyText, { emphasis: "strong", children: "pass " }), _jsx(ProgressMeter, { value: data.passRate.value, width: Math.max(1, columns - 9) })] }, "meter"))
|
|
392
|
+
: (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(Box, { height: 1, flexShrink: 0, overflow: "hidden", children: _jsx(BodyText, { emphasis: "strong", children: fitCells("pass", columns) }) }), _jsx(Box, { height: 1, flexShrink: 0, overflow: "hidden", children: _jsx(ProgressMeter, { value: data.passRate.value, width: Math.max(1, columns - 6) }) })] }, "meter"));
|
|
393
|
+
return (_jsx(Box, { flexDirection: "column", width: columns, flexShrink: 0, overflow: "hidden", children: elements.map((element) => (element === "meter" ? meter : figure(element))) }));
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* The navigation rail, unboxed, drawn at the composition the plan computed:
|
|
397
|
+
* the menu at the top, the vitals anchored to the bottom, and the planned gap
|
|
398
|
+
* of blank rows between them — the gap is planFrame's, never a spacing hack
|
|
399
|
+
* applied here.
|
|
400
|
+
*/
|
|
401
|
+
function RailBand({ data, focused, selection, columns, rows, sidebar, }) {
|
|
402
|
+
return (_jsxs(Box, { flexDirection: "column", width: columns, height: rows, overflow: "hidden", children: [sidebar.menuRows > RUN_VIEWS.length && (_jsx(Box, { height: 1, flexShrink: 0, overflow: "hidden", children: _jsx(BodyText, { emphasis: "strong", children: fitCells(focused ? `${GLYPHS.pointer} VIEWS` : "VIEWS", columns) }) })), RUN_VIEWS.map((view, index) => (_jsx(Box, { height: 1, flexShrink: 0, overflow: "hidden", children: _jsx(BodyText, { emphasis: index === selection ? "strong" : "normal", children: fitCells(`${index === selection ? GLYPHS.pointer : " "} ${view.label}`, columns) }) }, view.id))), sidebar.gapRows > 0 && _jsx(Box, { height: sidebar.gapRows, flexShrink: 0 }), _jsx(RailVitals, { data: data, columns: columns, mode: sidebar.vitalsMode, elements: sidebar.vitals })] }));
|
|
403
|
+
}
|
|
404
|
+
/** The setup tab's rail, drawn unboxed in the same planned columns. */
|
|
405
|
+
function SectionsBand({ focused, selection, columns, }) {
|
|
406
|
+
return (_jsxs(Box, { flexDirection: "column", width: columns, overflow: "hidden", children: [_jsx(Box, { height: 1, overflow: "hidden", children: _jsx(BodyText, { emphasis: "strong", children: fitCells(focused ? `${GLYPHS.pointer} SECTIONS` : "SECTIONS", columns) }) }), SETUP_SECTIONS.map((section, index) => (_jsx(Box, { height: 1, overflow: "hidden", children: _jsx(BodyText, { emphasis: index === selection ? "strong" : "normal", children: fitCells(`${index === selection ? GLYPHS.pointer : " "} ${section.label}`, columns) }) }, section.id)))] }));
|
|
407
|
+
}
|
|
408
|
+
/** A planned rule band: one row of the region edge glyph, exactly `columns` wide. */
|
|
409
|
+
function RuleBand({ columns }) {
|
|
410
|
+
return (_jsx(Box, { height: 1, overflow: "hidden", children: _jsx(BodyText, { emphasis: "dim", children: "─".repeat(Math.max(0, columns)) }) }));
|
|
411
|
+
}
|
|
412
|
+
/** The strip the plan draws instead of a rail between 40 and 63 columns: the five views on one row. */
|
|
413
|
+
function ViewStrip({ selection, columns, }) {
|
|
414
|
+
return (_jsx(Box, { width: columns, height: 1, flexDirection: "row", flexWrap: "nowrap", overflow: "hidden", children: RUN_VIEWS.map((view, index) => (_jsx(Box, { flexShrink: 0, children: _jsxs(BodyText, { children: [index === selection ? `${GLYPHS.pointer}${view.label}` : ` ${view.label}`, index === RUN_VIEWS.length - 1 ? "" : " "] }) }, view.id))) }));
|
|
415
|
+
}
|
|
416
|
+
/** The journal tail band: it draws exactly the rows the plan left it, surrendered whole by tier. */
|
|
417
|
+
function TailBand({ data, rows, columns, pendingWrites, }) {
|
|
418
|
+
const lines = pendingWrites
|
|
419
|
+
? ["PENDING WRITES · none"]
|
|
420
|
+
: deriveRunViewRows(data, "journal")
|
|
421
|
+
.slice(-rows)
|
|
422
|
+
.map((row) => `${row.time} ${row.text}`);
|
|
423
|
+
// A styled blank cell differs with colour on and off; these rows carry no dim style.
|
|
424
|
+
return (_jsx(Box, { flexDirection: "column", height: rows, overflow: "hidden", children: Array.from({ length: rows }, (_, index) => (_jsx(Box, { height: 1, overflow: "hidden", children: _jsx(BodyText, { children: fitCells(lines[index] ?? "", columns) }) }, index))) }));
|
|
425
|
+
}
|
|
153
426
|
function ApprovedProgress({ data, caption, }) {
|
|
154
427
|
return (_jsxs(Panel, { title: "PROGRESS", children: [_jsx(ProgressMeter, { value: data.progress, width: 28 }), caption && _jsx(BodyText, { emphasis: "dim", children: data.progressCaption })] }));
|
|
155
428
|
}
|
|
156
429
|
function CompactProgress({ data, caption, meterWidth = 28, }) {
|
|
157
430
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", flexWrap: "nowrap", children: [_jsx(BodyText, { emphasis: "strong", children: "PROGRESS " }), _jsx(ProgressMeter, { value: data.progress, width: meterWidth })] }), caption && _jsx(BodyText, { emphasis: "dim", children: data.progressCaption })] }));
|
|
158
431
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
432
|
+
/** The RUN view's interior, allocated out of the planned body span, each element granted whole or not at all. */
|
|
433
|
+
function resolveRunViewContent(bodySpan, bodyColumns, data) {
|
|
434
|
+
const interior = Math.max(0, bodySpan - PANEL_CHROME_ROWS);
|
|
435
|
+
let available = interior;
|
|
436
|
+
const progressBar = available >= 1;
|
|
437
|
+
if (progressBar)
|
|
438
|
+
available -= 1;
|
|
439
|
+
// Every element is granted whole or not at all, and the summary reports the fit decision it computed.
|
|
440
|
+
let journal = 0;
|
|
441
|
+
if (available >= PANEL_CHROME_ROWS + 1) {
|
|
442
|
+
journal = 1;
|
|
443
|
+
available -= PANEL_CHROME_ROWS + 1;
|
|
444
|
+
}
|
|
445
|
+
const summary = available >= 1;
|
|
446
|
+
if (summary)
|
|
447
|
+
available -= 1;
|
|
448
|
+
const tail = journal > 0
|
|
449
|
+
? Math.min(available, Math.max(0, FULL_JOURNAL_ROWS - journal))
|
|
450
|
+
: 0;
|
|
451
|
+
journal += tail;
|
|
452
|
+
available -= tail;
|
|
453
|
+
const captionRows = progressBar
|
|
454
|
+
? wrapCells(data.progressCaption, Math.max(1, bodyColumns)).length
|
|
455
|
+
: 0;
|
|
456
|
+
const progressCaption = captionRows > 0 && available >= captionRows;
|
|
457
|
+
if (progressCaption)
|
|
458
|
+
available -= captionRows;
|
|
459
|
+
// The tiles are retired into the rail's unboxed vitals; the summary line is the only stats form drawn.
|
|
460
|
+
if (journal > 0)
|
|
461
|
+
journal += available;
|
|
462
|
+
// Restored only once every element is full and the journal can pay the chrome rows.
|
|
463
|
+
const approvedProgress = progressBar && progressCaption
|
|
464
|
+
&& journal > PANEL_CHROME_ROWS;
|
|
465
|
+
if (approvedProgress)
|
|
466
|
+
journal -= PANEL_CHROME_ROWS;
|
|
467
|
+
return {
|
|
468
|
+
journal,
|
|
469
|
+
stats: summary ? "summary" : "none",
|
|
470
|
+
progressBar,
|
|
471
|
+
progressCaption,
|
|
472
|
+
approvedProgress,
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
/** What fills the regions this paint draws, resolved from the plan's own spans. */
|
|
476
|
+
function resolveRegionContent(plan, data, interaction) {
|
|
477
|
+
const bodySpan = requiredRegion(plan, "body").rows;
|
|
478
|
+
const sideRails = plan.band === "sidebar";
|
|
479
|
+
// The plan's own item rows, consumed unmodified: the paint draws its list at
|
|
480
|
+
// this region and the pointer resolves a hit through it — one geometry.
|
|
481
|
+
const items = requiredRegion(plan, "items");
|
|
482
|
+
const bodyColumns = items.columns;
|
|
483
|
+
const common = { items, sideRails };
|
|
484
|
+
if (interaction.tab !== "setup" && interaction.activeView === "run") {
|
|
485
|
+
return {
|
|
486
|
+
...common,
|
|
487
|
+
// The help overlay stands in for the journal tail, drawing into its whole interior.
|
|
488
|
+
...(interaction.help
|
|
489
|
+
? {
|
|
490
|
+
journal: Math.max(1, bodySpan - PANEL_CHROME_ROWS * 2),
|
|
491
|
+
stats: "summary",
|
|
492
|
+
progressBar: false,
|
|
493
|
+
progressCaption: false,
|
|
494
|
+
approvedProgress: false,
|
|
495
|
+
}
|
|
496
|
+
: resolveRunViewContent(bodySpan, bodyColumns, data)),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
return {
|
|
500
|
+
...common,
|
|
501
|
+
journal: 0,
|
|
502
|
+
stats: "summary",
|
|
503
|
+
progressBar: false,
|
|
504
|
+
progressCaption: false,
|
|
505
|
+
approvedProgress: false,
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
/** The RUN view drawn at its planned spans: every row budget arrives resolved. */
|
|
509
|
+
function LadderRunPanel({ data, content, compactMeterWidth, interaction, keyEntries, focused = true, }) {
|
|
510
|
+
if (interaction?.help === true) {
|
|
511
|
+
return (_jsx(SizedLinesPanel, { title: "HELP", lines: helpLines(keyEntries), bodyRows: content.items.rows, wrapColumns: content.items.columns, focused: focused, flexGrow: 1 }));
|
|
512
|
+
}
|
|
513
|
+
return (_jsx(Panel, { title: "RUN", focused: focused, flexGrow: 1, children: _jsxs(ItemRows, { children: [_jsx(RunStats, { data: data, mode: content.stats }), content.progressBar && (content.approvedProgress
|
|
514
|
+
? _jsx(ApprovedProgress, { data: data, caption: content.progressCaption })
|
|
515
|
+
: (_jsx(CompactProgress, { data: data, caption: content.progressCaption, meterWidth: compactMeterWidth }))), content.journal > 0 && (_jsx(JournalBody, { data: data, interaction: interaction, bodyRows: content.journal, keyEntries: keyEntries }))] }) }));
|
|
516
|
+
}
|
|
517
|
+
function LegacyRunPanel({ data, interaction, keyEntries, focused = true, }) {
|
|
518
|
+
const rows = deriveRunViewRows(data, "journal");
|
|
519
|
+
return (_jsxs(Panel, { title: "RUN", focused: focused, flexGrow: 1, children: [_jsx(RunStats, { data: data, mode: "tiles" }), _jsxs(Panel, { title: "PROGRESS", children: [_jsx(ProgressMeter, { value: data.progress, width: 28 }), _jsx(BodyText, { emphasis: "dim", children: data.progressCaption })] }), _jsx(JournalBody, { data: data, interaction: interaction, bodyRows: rows.length, keyEntries: keyEntries })] }));
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* One planned band, drawn at exactly the span the plan gives it. Unsized (the
|
|
523
|
+
* legacy surface) it is whatever it contains.
|
|
524
|
+
*/
|
|
525
|
+
function FrameBand({ rows, row = false, gap, regionRef, children, }) {
|
|
526
|
+
return (_jsx(Box, { ref: regionRef, flexDirection: row ? "row" : "column", ...(gap === undefined ? {} : { gap }), ...(rows === undefined ? {} : { height: rows, flexShrink: 0 }), overflow: "hidden", children: children }));
|
|
527
|
+
}
|
|
528
|
+
/** A sized sub-floor plan draws the plain fallback, clipped to the measured size. */
|
|
529
|
+
function PlainFallbackPaint({ data, size, }) {
|
|
530
|
+
const columns = Math.max(1, size.columns);
|
|
531
|
+
const lines = [
|
|
532
|
+
`tickmarkr ${data.binaryVersion}`,
|
|
533
|
+
`${data.runId} ${data.status} ${data.elapsed}`,
|
|
534
|
+
...data.statusItems.map((item) => item.text),
|
|
173
535
|
];
|
|
174
|
-
|
|
175
|
-
return Math.max(...content.map((column, index) => composeBandLine(column.lines[0], Math.max(1, widths[index])).length));
|
|
536
|
+
return (_jsx(Box, { flexDirection: "column", width: size.columns, height: Math.max(0, size.rows), overflow: "hidden", children: lines.map((line, index) => (_jsx(Box, { height: 1, flexShrink: 0, overflow: "hidden", children: _jsx(BodyText, { children: fitCells(line, columns) }) }, `${index}:${line}`))) }));
|
|
176
537
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
:
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
+ statRows
|
|
196
|
-
+ progressRows
|
|
197
|
-
+ 3; // journal border and title
|
|
198
|
-
return Math.max(1, Math.min(layout.journalRows, Math.floor(rows) - fixedRows));
|
|
199
|
-
}
|
|
200
|
-
function LadderRunPanel({ data, layout, columns, rows, compactMeterWidth, interaction, focused = true, }) {
|
|
201
|
-
const compactBodyRows = compactJournalRows(rows, layout, data, columns);
|
|
202
|
-
// The approved bordered progress panel has the same meter/caption as the
|
|
203
|
-
// compact form plus its border, title row and closing border. It is restored
|
|
204
|
-
// only after the resolved ladder has retained every full-arrangement element
|
|
205
|
-
// and the journal can pay those three chrome rows without disappearing.
|
|
206
|
-
const approvedProgressChromeRows = 3;
|
|
207
|
-
const approvedArrangement = layout.elements.secondaryHeader
|
|
208
|
-
&& layout.elements.progressBar
|
|
209
|
-
&& layout.elements.progressCaption
|
|
210
|
-
&& layout.stats.mode === "tiles"
|
|
211
|
-
&& compactBodyRows > approvedProgressChromeRows;
|
|
212
|
-
const journalBodyRows = approvedArrangement
|
|
213
|
-
? compactBodyRows - approvedProgressChromeRows
|
|
214
|
-
: compactBodyRows;
|
|
215
|
-
return (_jsxs(Panel, { title: "RUN", focused: focused, flexGrow: 1, children: [_jsx(RunStats, { data: data, mode: layout.stats.mode }), layout.elements.progressBar && (approvedArrangement
|
|
216
|
-
? (_jsx(ApprovedProgress, { data: data, caption: layout.elements.progressCaption }))
|
|
217
|
-
: (_jsx(CompactProgress, { data: data, caption: layout.elements.progressCaption, meterWidth: compactMeterWidth }))), _jsx(JournalBody, { data: data, interaction: interaction, bodyRows: journalBodyRows })] }));
|
|
218
|
-
}
|
|
219
|
-
function LegacyRunPanel({ data, interaction, focused = true, }) {
|
|
220
|
-
const rows = visibleJournalRows(data.journalRows, interaction);
|
|
221
|
-
return (_jsxs(Panel, { title: "RUN", focused: focused, flexGrow: 1, children: [_jsx(RunStats, { data: data, mode: "tiles" }), _jsxs(Panel, { title: "PROGRESS", children: [_jsx(ProgressMeter, { value: data.progress, width: 28 }), _jsx(BodyText, { emphasis: "dim", children: data.progressCaption })] }), _jsx(JournalBody, { data: data, interaction: interaction, bodyRows: rows.length })] }));
|
|
222
|
-
}
|
|
223
|
-
export function RunCockpitFrame({ data, columns, rows, interaction, }) {
|
|
224
|
-
const focus = interaction === undefined
|
|
225
|
-
? 0
|
|
226
|
-
: interaction.panel % RUN_PANEL_FOCUS_ORDER.length;
|
|
227
|
-
const focusedPanel = RUN_PANEL_FOCUS_ORDER[focus] ?? "RUN";
|
|
538
|
+
/** The paint: every span is read off the plan's regions and every row budget off the resolved content. */
|
|
539
|
+
function RunCockpitPaint({ data, columns, plan, content, interaction, keyProjection = projectRunKeyEntries, onRegion, }) {
|
|
540
|
+
const suppliedInteraction = interaction ?? initialRunInteractionState();
|
|
541
|
+
const suppliedRows = deriveRunViewRows(data, suppliedInteraction.activeView, suppliedInteraction.filterQuery);
|
|
542
|
+
const currentInteraction = reconcileRunInteraction(suppliedInteraction, suppliedRows.map((row) => row.id));
|
|
543
|
+
const focusOrder = runPanelFocusOrder(runKeyColumns(columns));
|
|
544
|
+
const focus = currentInteraction.panel % focusOrder.length;
|
|
545
|
+
const focusedPanel = focusOrder[focus] ?? "CONTENT";
|
|
546
|
+
const contentFocused = focusedPanel === "CONTENT";
|
|
547
|
+
const activeView = currentInteraction.activeView;
|
|
548
|
+
const railSelection = currentInteraction.railSelection;
|
|
549
|
+
const keyEntries = keyProjection({
|
|
550
|
+
interaction: currentInteraction,
|
|
551
|
+
columns: runKeyColumns(columns),
|
|
552
|
+
// What the keybar promises is what a key can actually stand on: the journal
|
|
553
|
+
// tail owns no selectable row, so it advertises no row key.
|
|
554
|
+
rowIds: selectableRunViewRowIds(currentInteraction.activeView, suppliedRows.map((row) => row.id)),
|
|
555
|
+
});
|
|
228
556
|
// Follow mode reports its on state as a status-strip item carrying the
|
|
229
557
|
// brand's reserved active-toggle glyph; off it draws nothing, so the pinned
|
|
230
558
|
// frame is untouched.
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
const
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
: {
|
|
244
|
-
|
|
245
|
-
|
|
559
|
+
const interactionStatusItems = [
|
|
560
|
+
...(currentInteraction.quit
|
|
561
|
+
? [{ state: "active", text: "Quit requested" }]
|
|
562
|
+
: []),
|
|
563
|
+
...(currentInteraction.follow
|
|
564
|
+
? [{ state: "active", text: "Follow" }]
|
|
565
|
+
: []),
|
|
566
|
+
];
|
|
567
|
+
const reportedStatusItems = [...interactionStatusItems, ...data.statusItems];
|
|
568
|
+
const regionRef = (id) => onRegion?.(id);
|
|
569
|
+
const railFocused = focusedPanel === "VIEWS";
|
|
570
|
+
if (plan === undefined || content === undefined) {
|
|
571
|
+
return (_jsxs(Box, { flexDirection: "column", width: columns, overflow: "hidden", children: [_jsx(IdentityHeader, { data: data, columns: columns }), _jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(NavigationPanel, { focused: railFocused, selection: railSelection }), activeView === "run"
|
|
572
|
+
? (_jsx(LegacyRunPanel, { data: data, interaction: currentInteraction, keyEntries: keyEntries, focused: contentFocused }))
|
|
573
|
+
: (_jsx(RunViewPanel, { data: data, viewId: activeView, interaction: currentInteraction, focused: contentFocused, keyEntries: keyEntries, bodyRows: Math.max(1, currentInteraction.help
|
|
574
|
+
? keyEntries.length
|
|
575
|
+
: suppliedRows.length), bodyColumns: runViewBodyColumns(columns, true, false) }))] }), _jsx(StatusStrip, { items: reportedStatusItems, width: columns }), _jsx(RunKeybar, { entries: keyEntries, width: columns, help: currentInteraction.help })] }));
|
|
576
|
+
}
|
|
577
|
+
// Every region the plan places, drawn at the plan's own offset and span.
|
|
578
|
+
const region = (id) => plan.regions.find((candidate) => candidate.id === id);
|
|
579
|
+
const header = requiredRegion(plan, "header");
|
|
580
|
+
const body = requiredRegion(plan, "body");
|
|
581
|
+
const caption = region("caption");
|
|
582
|
+
const strip = region("strip");
|
|
583
|
+
const rail = region("rail");
|
|
584
|
+
const tail = region("tail");
|
|
585
|
+
const setup = currentInteraction.tab === "setup";
|
|
586
|
+
return (_jsxs(Box, { flexDirection: "column", width: plan.size.columns, height: plan.size.rows, overflow: "hidden", children: [_jsxs(FrameBand, { rows: header.rows, row: true, regionRef: regionRef("header"), children: [_jsx(Box, { flexGrow: 1, flexShrink: 1, height: header.rows, overflow: "hidden", children: _jsx(PlannedHeader, { data: data, plan: plan, columns: plan.size.columns - (caption?.columns ?? 0) }) }), caption !== undefined && (_jsx(Box, { ref: regionRef("caption"), width: caption.columns, height: caption.rows, flexShrink: 0, overflow: "hidden", children: _jsx(PlannedCaption, { data: data, columns: caption.columns }) }))] }), _jsx(FrameBand, { rows: requiredRegion(plan, "rule").rows, regionRef: regionRef("rule"), children: _jsx(RuleBand, { columns: plan.size.columns }) }), strip !== undefined && (_jsx(FrameBand, { rows: strip.rows, regionRef: regionRef("strip"), children: _jsx(ViewStrip, { selection: railSelection, columns: strip.columns }) })), _jsxs(FrameBand, { rows: body.rows, row: true, children: [rail !== undefined && (_jsx(Box, { ref: regionRef("rail"), width: rail.columns, height: rail.rows, flexShrink: 0, overflow: "hidden", children: setup
|
|
587
|
+
? (_jsx(SectionsBand, { focused: railFocused, selection: railSelection, columns: rail.columns }))
|
|
588
|
+
: (_jsx(RailBand, { data: data, focused: railFocused, selection: railSelection, columns: rail.columns, rows: rail.rows, sidebar: plan.sidebar })) })), _jsx(Box, { ref: regionRef("body"), width: body.columns, height: body.rows, flexShrink: 0, flexDirection: "column", overflow: "hidden", children: _jsx(ItemRowsBand.Provider, { value: { region: content.items, regionRef: regionRef("items") }, children: setup
|
|
589
|
+
? (_jsx(SetupTabPanel, { data: data, selection: railSelection, focused: contentFocused, keyEntries: keyEntries, help: currentInteraction.help, bodyRows: content.items.rows, bodyColumns: content.items.columns }))
|
|
590
|
+
: activeView === "run"
|
|
591
|
+
? (_jsx(LadderRunPanel, { data: data, content: content, compactMeterWidth: content.sideRails ? undefined : 24, interaction: currentInteraction, keyEntries: keyEntries, focused: contentFocused }))
|
|
592
|
+
: (_jsx(RunViewPanel, { data: data, viewId: activeView, interaction: currentInteraction, focused: contentFocused, keyEntries: keyEntries, bodyRows: content.items.rows, bodyColumns: content.items.columns })) }) })] }), _jsx(FrameBand, { rows: requiredRegion(plan, "rule2").rows, regionRef: regionRef("rule2"), children: _jsx(RuleBand, { columns: plan.size.columns }) }), tail !== undefined && (_jsx(FrameBand, { rows: tail.rows, regionRef: regionRef("tail"), children: _jsx(TailBand, { data: data, rows: tail.rows, columns: tail.columns, pendingWrites: plan.content.tail === "pending-writes" }) })), _jsx(FrameBand, { rows: requiredRegion(plan, "status").rows, regionRef: regionRef("status"), children: _jsx(StatusStrip, { items: reportedStatusItems, width: plan.size.columns }) }), _jsx(FrameBand, { rows: requiredRegion(plan, "keybar").rows, regionRef: regionRef("keybar"), children: _jsx(RunKeybar, { entries: keyEntries, width: plan.size.columns, help: currentInteraction.help }) })] }));
|
|
593
|
+
}
|
|
594
|
+
/** Resolve every draw-time input; the plan is planFrame's own return, consumed unmodified. */
|
|
595
|
+
export function planRunCockpitFrame({ data, columns, rows, interaction, keyProjection = projectRunKeyEntries, railColumns, }) {
|
|
596
|
+
const suppliedInteraction = interaction ?? initialRunInteractionState();
|
|
597
|
+
const suppliedRows = deriveRunViewRows(data, suppliedInteraction.activeView, suppliedInteraction.filterQuery);
|
|
598
|
+
const rowIds = suppliedRows.map((row) => row.id);
|
|
599
|
+
const repaired = reconcileRunInteraction(suppliedInteraction, rowIds);
|
|
600
|
+
const keyEntries = keyProjection({
|
|
601
|
+
interaction: repaired,
|
|
602
|
+
columns: runKeyColumns(columns),
|
|
603
|
+
rowIds: selectableRunViewRowIds(repaired.activeView, rowIds),
|
|
604
|
+
});
|
|
605
|
+
const size = { columns, rows: rows ?? 1 };
|
|
606
|
+
const view = repaired.activeView;
|
|
607
|
+
const state = {
|
|
608
|
+
tab: repaired.tab,
|
|
609
|
+
detail: repaired.opened !== null,
|
|
610
|
+
captionCells: cellWidth(frameCaption(data)),
|
|
611
|
+
...(railColumns === undefined ? {} : { railColumns }),
|
|
612
|
+
};
|
|
613
|
+
const plan = planFrame(size, view, state);
|
|
614
|
+
const content = rows === undefined || plan.kind !== "frame"
|
|
615
|
+
? undefined
|
|
616
|
+
: resolveRegionContent(plan, data, repaired);
|
|
617
|
+
return {
|
|
618
|
+
plan,
|
|
619
|
+
columns,
|
|
620
|
+
rows,
|
|
621
|
+
content,
|
|
622
|
+
planning: rows === undefined || plan.kind !== "frame"
|
|
623
|
+
? undefined
|
|
624
|
+
: { size, view, state },
|
|
625
|
+
interaction: repaired,
|
|
626
|
+
keyEntries,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
/** The public surface always enters paint through one measured plan. */
|
|
630
|
+
export function RunCockpitFrame({ data, columns, rows, interaction, keyProjection = projectRunKeyEntries, onCommittedFrame, }) {
|
|
631
|
+
// The session's panel resize override, read from the pointer layer the live
|
|
632
|
+
// drag feeds and redrawn when the boundary moves. It enters the frame only
|
|
633
|
+
// as an input to this planning call — the drawn frame is the recomputed
|
|
634
|
+
// plan, so a resize can never paint beside the plan. Nothing persists it: a
|
|
635
|
+
// relaunch plans the default layout.
|
|
636
|
+
const railOverride = useSyncExternalStore(watchSessionRailOverride, sessionRailOverride, sessionRailOverride);
|
|
637
|
+
const plannedFrame = useMemo(() => planRunCockpitFrame({
|
|
638
|
+
data,
|
|
639
|
+
columns,
|
|
640
|
+
rows,
|
|
641
|
+
interaction,
|
|
642
|
+
keyProjection,
|
|
643
|
+
railColumns: railOverride ?? undefined,
|
|
644
|
+
}), [data, columns, rows, interaction, keyProjection, railOverride]);
|
|
645
|
+
useEffect(() => {
|
|
646
|
+
onCommittedFrame?.(plannedFrame, data);
|
|
647
|
+
}, [plannedFrame, data, onCommittedFrame]);
|
|
648
|
+
// Where the pointer is resting, read from the pointer layer that the live
|
|
649
|
+
// input path feeds and redrawn when it moves. There is no prop beside it: a
|
|
650
|
+
// hover cannot be handed to this frame at all, only reported to the terminal.
|
|
651
|
+
const resting = useSyncExternalStore(watchPointerRest, pointerRestingCell, pointerRestingCell);
|
|
652
|
+
// And only a frame that publishes its geometry for hit testing can be hovered
|
|
653
|
+
// — publishing it is what makes a pointer report resolve against this frame
|
|
654
|
+
// at all. A capture publishes none and resolves none, so no golden, anchor or
|
|
655
|
+
// colour capture can carry a highlight however the pointer came to rest: the
|
|
656
|
+
// pointerless appearance is what that path draws by construction, not what a
|
|
657
|
+
// guard strips back out of it.
|
|
658
|
+
const hover = onCommittedFrame === undefined ? null : resting;
|
|
659
|
+
const hoveredRow = useMemo(() => {
|
|
660
|
+
const plan = plannedFrame.plan;
|
|
661
|
+
if (hover === null || plan.kind !== "frame"
|
|
662
|
+
|| plannedFrame.content === undefined)
|
|
663
|
+
return null;
|
|
664
|
+
const interaction = plannedFrame.interaction;
|
|
665
|
+
// The whole surface a click resolves through, not only the geometry: what a
|
|
666
|
+
// click at this cell ACTS ON is the highlight's answer, and that route
|
|
667
|
+
// passes through the focus the frame is drawn with and the rows a key may
|
|
668
|
+
// stand on. Deriving the highlight from the row lookup alone would advertise
|
|
669
|
+
// a row of the drawn view while the rail marker stands on another — where
|
|
670
|
+
// the click opens the marked view and selects nothing.
|
|
671
|
+
return pointerHoverRow({
|
|
672
|
+
plan,
|
|
673
|
+
interaction,
|
|
674
|
+
drawnRowIds: drawnRunViewRowIds(data, interaction, plannedFrame.content.items.rows),
|
|
675
|
+
rowIds: selectableRunViewRowIds(interaction.activeView, deriveRunViewRows(data, interaction.activeView, interaction.filterQuery)
|
|
676
|
+
.map((row) => row.id)),
|
|
677
|
+
}, hover) ?? null;
|
|
678
|
+
}, [plannedFrame, data, hover]);
|
|
679
|
+
return (_jsx(HoveredRow.Provider, { value: hoveredRow, children: _jsx(RunCockpitFrameFromPlan, { data: data, plannedFrame: plannedFrame }) }));
|
|
680
|
+
}
|
|
681
|
+
function requiredRegion(plan, id) {
|
|
682
|
+
const region = plan.regions.find((candidate) => candidate.id === id);
|
|
683
|
+
if (region === undefined)
|
|
684
|
+
throw new Error(`planned region ${id} is missing`);
|
|
685
|
+
return region;
|
|
686
|
+
}
|
|
687
|
+
function optionalRegion(plan, id) {
|
|
688
|
+
return plan.regions.find((candidate) => candidate.id === id);
|
|
689
|
+
}
|
|
690
|
+
function assertPlanSpans(plan) {
|
|
691
|
+
let nextRow = 0;
|
|
692
|
+
for (const [id, span] of Object.entries(plan.rowSpans)) {
|
|
693
|
+
const region = requiredRegion(plan, id);
|
|
694
|
+
const host = FRAME_NESTED_ROW_BANDS[id];
|
|
695
|
+
if (host !== undefined) {
|
|
696
|
+
// A nested band refines its host's rows rather than tiling beside them,
|
|
697
|
+
// so it advances no row cursor — and is held to its span and to staying
|
|
698
|
+
// inside the band that hosts it.
|
|
699
|
+
const within = requiredRegion(plan, host);
|
|
700
|
+
if (region.rows !== span ||
|
|
701
|
+
region.row < within.row ||
|
|
702
|
+
region.row + region.rows > within.row + within.rows ||
|
|
703
|
+
region.column < within.column ||
|
|
704
|
+
region.column + region.columns > within.column + within.columns) {
|
|
705
|
+
throw new Error(`planned row region ${id} has offset/span ${region.row}/${region.rows} column ${region.column}/${region.columns}, expected span ${span} inside ${host} at ${within.row}/${within.rows} column ${within.column}/${within.columns}`);
|
|
706
|
+
}
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
const fullWidth = id !== "body";
|
|
710
|
+
if (region.row !== nextRow ||
|
|
711
|
+
region.rows !== span ||
|
|
712
|
+
(fullWidth && (region.column !== 0 || region.columns !== plan.size.columns))) {
|
|
713
|
+
throw new Error(`planned row region ${id} has offset/span ${region.row}/${region.rows}, expected ${nextRow}/${span}`);
|
|
714
|
+
}
|
|
715
|
+
nextRow += span;
|
|
716
|
+
}
|
|
717
|
+
if (nextRow !== plan.size.rows) {
|
|
718
|
+
throw new Error(`planned row spans total ${nextRow}, expected ${plan.size.rows}`);
|
|
719
|
+
}
|
|
720
|
+
let nextColumn = 0;
|
|
721
|
+
for (const [id, span] of Object.entries(plan.columnSpans)) {
|
|
722
|
+
const region = requiredRegion(plan, id);
|
|
723
|
+
if (region.column !== nextColumn || region.columns !== span) {
|
|
724
|
+
throw new Error(`planned column region ${id} has offset/span ${region.column}/${region.columns}, expected ${nextColumn}/${span}`);
|
|
725
|
+
}
|
|
726
|
+
nextColumn += span;
|
|
727
|
+
}
|
|
728
|
+
if (nextColumn !== plan.size.columns) {
|
|
729
|
+
throw new Error(`planned column spans total ${nextColumn}, expected ${plan.size.columns}`);
|
|
730
|
+
}
|
|
731
|
+
const body = requiredRegion(plan, "body");
|
|
732
|
+
const rail = optionalRegion(plan, "rail");
|
|
733
|
+
if (rail !== undefined && (rail.row !== body.row || rail.rows !== body.rows)) {
|
|
734
|
+
throw new Error("planned rail region does not share the body row span");
|
|
735
|
+
}
|
|
736
|
+
const caption = optionalRegion(plan, "caption");
|
|
737
|
+
if (caption !== undefined) {
|
|
738
|
+
const header = requiredRegion(plan, "header");
|
|
739
|
+
if (caption.row !== header.row ||
|
|
740
|
+
caption.rows !== header.rows ||
|
|
741
|
+
caption.column + caption.columns !== header.column + header.columns) {
|
|
742
|
+
throw new Error("planned caption region does not refine the header span");
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
const ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/gu;
|
|
747
|
+
/** The rows the renderer drew, Ink's transport framing removed: the shipped path appends a newline of its own. */
|
|
748
|
+
function drawnRows(drawnFrame, plannedRows) {
|
|
749
|
+
const rows = drawnFrame.replace(ANSI, "").split("\n");
|
|
750
|
+
return rows.length === plannedRows + 1 && rows.at(-1) === ""
|
|
751
|
+
? rows.slice(0, -1)
|
|
752
|
+
: rows;
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Assert the plan's tiled extents, the bytes production paints, and — given
|
|
756
|
+
* the offsets read off the painted nodes — that every planned region was
|
|
757
|
+
* drawn where the plan puts it.
|
|
758
|
+
*/
|
|
759
|
+
export function assertFrameConformance(plan, drawnFrame, drawnRegions) {
|
|
760
|
+
assertPlanSpans(plan);
|
|
761
|
+
const lines = drawnRows(drawnFrame, plan.size.rows);
|
|
762
|
+
if (lines.length !== plan.size.rows) {
|
|
763
|
+
throw new Error(`drawn frame has ${lines.length} rows, planned ${plan.size.rows} for ${plan.tab}/${plan.view} at ${plan.size.columns} columns`);
|
|
764
|
+
}
|
|
765
|
+
for (const [index, line] of lines.entries()) {
|
|
766
|
+
if (cellWidth(line) > plan.size.columns) {
|
|
767
|
+
throw new Error(`drawn frame row ${index} has ${cellWidth(line)} cells, exceeding planned span ${plan.size.columns} for ${plan.tab}/${plan.view}: ${JSON.stringify(line)}`);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
if (drawnRegions === undefined)
|
|
771
|
+
return;
|
|
772
|
+
// Every planned region is located in the drawn frame, so a redistribution
|
|
773
|
+
// that keeps the plan tiled at unchanged height still fails here.
|
|
774
|
+
for (const region of plan.regions) {
|
|
775
|
+
const drawn = drawnRegions.find((candidate) => candidate.id === region.id);
|
|
776
|
+
if (drawn === undefined) {
|
|
777
|
+
throw new Error(`drawn frame has no region ${region.id}; planned row ${region.row} span ${region.rows} column ${region.column} span ${region.columns} for ${plan.tab}/${plan.view}`);
|
|
778
|
+
}
|
|
779
|
+
if (drawn.row !== region.row ||
|
|
780
|
+
drawn.rows !== region.rows ||
|
|
781
|
+
drawn.column !== region.column ||
|
|
782
|
+
drawn.columns !== region.columns) {
|
|
783
|
+
throw new Error(`drawn region ${region.id} is at row ${drawn.row} span ${drawn.rows} column ${drawn.column} span ${drawn.columns}, planned row ${region.row} span ${region.rows} column ${region.column} span ${region.columns} for ${plan.tab}/${plan.view} at ${plan.size.columns} columns`);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
/** The offsets and spans the renderer actually drew, read off the painted nodes' computed layout. */
|
|
788
|
+
function measuredDrawnRegions(nodes) {
|
|
789
|
+
return [...nodes.entries()].map(([id, node]) => {
|
|
790
|
+
let row = 0;
|
|
791
|
+
let column = 0;
|
|
792
|
+
for (let current = node; current?.parentNode !== undefined; current = current.parentNode) {
|
|
793
|
+
row += current.yogaNode?.getComputedTop() ?? 0;
|
|
794
|
+
column += current.yogaNode?.getComputedLeft() ?? 0;
|
|
795
|
+
}
|
|
796
|
+
return {
|
|
797
|
+
id,
|
|
798
|
+
row: Math.round(row),
|
|
799
|
+
rows: Math.round(node.yogaNode?.getComputedHeight() ?? 0),
|
|
800
|
+
column: Math.round(column),
|
|
801
|
+
columns: Math.round(node.yogaNode?.getComputedWidth() ?? 0),
|
|
246
802
|
};
|
|
247
|
-
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
/** The plan's regions measured off the painted nodes; a planned region with no painted node is a refusal. */
|
|
806
|
+
function drawnPlannedRegions(plan, nodes) {
|
|
807
|
+
const missing = plan.regions
|
|
808
|
+
.filter((region) => !nodes.has(region.id))
|
|
809
|
+
.map((region) => region.id);
|
|
810
|
+
if (missing.length > 0) {
|
|
811
|
+
throw new Error(`drawn frame registered no node for planned region(s) ${String(missing)} for ${plan.tab}/${plan.view} at ${plan.size.columns} columns`);
|
|
812
|
+
}
|
|
813
|
+
return measuredDrawnRegions(nodes);
|
|
814
|
+
}
|
|
815
|
+
/** planFrame's own regions for the inputs the plan was planned from, so a moved span is a refusal. */
|
|
816
|
+
function authoritativeRegions(planning) {
|
|
817
|
+
const authoritative = planFrame(planning.size, planning.view, planning.state);
|
|
818
|
+
return authoritative.kind === "frame" ? authoritative.regions : undefined;
|
|
819
|
+
}
|
|
820
|
+
/** Production paint consumes only resolved plan inputs and fails closed with
|
|
821
|
+
* the same conformance assertion the acceptance suite runs. */
|
|
822
|
+
export function RunCockpitFrameFromPlan({ data, plannedFrame, }) {
|
|
823
|
+
if (plannedFrame.plan.kind === "frame") {
|
|
824
|
+
assertPlanSpans(plannedFrame.plan);
|
|
825
|
+
}
|
|
826
|
+
const { stdout } = useStdout();
|
|
827
|
+
const { exit } = useApp();
|
|
828
|
+
const regionNodes = useRef(new Map());
|
|
829
|
+
const regionRefCallbacks = useRef(new Map());
|
|
830
|
+
useInsertionEffect(() => {
|
|
831
|
+
const plan = plannedFrame.plan;
|
|
832
|
+
if (plan.kind !== "frame" || plannedFrame.content === undefined)
|
|
833
|
+
return;
|
|
834
|
+
const originalWrite = stdout.write;
|
|
835
|
+
const writeFrame = ((chunk, ...args) => {
|
|
836
|
+
const bytes = typeof chunk === "string"
|
|
837
|
+
? chunk
|
|
838
|
+
: Buffer.from(chunk).toString("utf8");
|
|
839
|
+
if (bytes.replace(ANSI, "").trim().length === 0) {
|
|
840
|
+
return Reflect.apply(originalWrite, stdout, [chunk, ...args]);
|
|
841
|
+
}
|
|
842
|
+
// Byte spans refuse before the frame leaves; the region check measures the nodes its commit painted.
|
|
843
|
+
try {
|
|
844
|
+
assertFrameConformance(plan, bytes);
|
|
845
|
+
}
|
|
846
|
+
catch (error) {
|
|
847
|
+
queueMicrotask(() => exit(error));
|
|
848
|
+
return true;
|
|
849
|
+
}
|
|
850
|
+
const planning = plannedFrame.planning;
|
|
851
|
+
queueMicrotask(() => {
|
|
852
|
+
try {
|
|
853
|
+
if (planning !== undefined) {
|
|
854
|
+
const planned = authoritativeRegions(planning);
|
|
855
|
+
if (planned !== undefined) {
|
|
856
|
+
assertFrameConformance(plan, bytes, planned);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
assertFrameConformance(plan, bytes, drawnPlannedRegions(plan, regionNodes.current));
|
|
860
|
+
}
|
|
861
|
+
catch (error) {
|
|
862
|
+
exit(error);
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
return Reflect.apply(originalWrite, stdout, [chunk, ...args]);
|
|
866
|
+
});
|
|
867
|
+
stdout.write = writeFrame;
|
|
868
|
+
return () => {
|
|
869
|
+
if (stdout.write === writeFrame)
|
|
870
|
+
stdout.write = originalWrite;
|
|
871
|
+
};
|
|
872
|
+
}, [plannedFrame, stdout, exit]);
|
|
873
|
+
if (plannedFrame.plan.kind === "plain" && plannedFrame.rows !== undefined) {
|
|
874
|
+
return _jsx(PlainFallbackPaint, { data: data, size: plannedFrame.plan.size });
|
|
875
|
+
}
|
|
876
|
+
return (_jsx(RunCockpitPaint, { data: data, columns: plannedFrame.columns, plan: plannedFrame.plan.kind === "frame" ? plannedFrame.plan : undefined, content: plannedFrame.content, interaction: plannedFrame.interaction, keyProjection: () => plannedFrame.keyEntries, onRegion: (id) => {
|
|
877
|
+
let callback = regionRefCallbacks.current.get(id);
|
|
878
|
+
if (callback === undefined) {
|
|
879
|
+
callback = (node) => {
|
|
880
|
+
if (node === null)
|
|
881
|
+
regionNodes.current.delete(id);
|
|
882
|
+
else
|
|
883
|
+
regionNodes.current.set(id, node);
|
|
884
|
+
};
|
|
885
|
+
regionRefCallbacks.current.set(id, callback);
|
|
886
|
+
}
|
|
887
|
+
return callback;
|
|
888
|
+
} }));
|
|
248
889
|
}
|