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