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,5 +1,6 @@
1
1
  import { type ReactNode } from "react";
2
2
  import { type DemoCaptures } from "./demo.js";
3
+ import { type RunInteractionState, type RunKeyProjection } from "./keys.js";
3
4
  export type CockpitName = "run" | "setup";
4
5
  export type CockpitRenderer = "frame" | "plain";
5
6
  export type RendererCaptureOptions = {
@@ -21,6 +22,8 @@ export type CaptureCockpitOptions = {
21
22
  readonly interactive?: boolean;
22
23
  readonly colour?: boolean;
23
24
  readonly ci?: boolean;
25
+ readonly runInteraction?: RunInteractionState;
26
+ readonly runKeyProjection?: RunKeyProjection;
24
27
  readonly captures?: DemoCaptures;
25
28
  readonly plainRenderer?: (cockpit: CockpitName, binaryVersion: string, captures: DemoCaptures) => string;
26
29
  readonly frameRenderer?: FrameRenderer;
@@ -40,6 +43,98 @@ export declare const WIDTH_BAND_CASES: readonly [{
40
43
  readonly columns: 140;
41
44
  }];
42
45
  export declare const HEIGHT_TIER_BOUNDARIES: readonly [14, 18, 24, 40];
46
+ export declare const COCKPIT_CONTRACT_DOMAIN: {
47
+ readonly columns: {
48
+ readonly minimum: 40;
49
+ readonly maximum: 220;
50
+ };
51
+ readonly rows: {
52
+ readonly minimum: 14;
53
+ readonly maximum: 50;
54
+ };
55
+ };
56
+ /**
57
+ * What the contract's artwork draws for the rail: a 15-cell content band, then
58
+ * one blank gutter cell, then the divider at cell 16. Both numbers are measured
59
+ * from the approved frames in the design contract, and the rail span the plan
60
+ * allocates is the same 15.
61
+ */
62
+ export declare const COCKPIT_SIDEBAR_CONTENT_COLUMNS = 15;
63
+ export declare const COCKPIT_SIDEBAR_GUTTER_COLUMNS = 1;
64
+ /** Content plus gutter — the cells before the divider. */
65
+ export declare const COCKPIT_SIDEBAR_COLUMNS: number;
66
+ /**
67
+ * Where the paint puts the frame's own region boundaries, read off the bytes.
68
+ * The contract's artwork draws a region edge as a glyph — a full-width rule
69
+ * between row bands, a vertical between the rail and the body — so a region
70
+ * that moved or that overlaps its neighbour is visible here as a boundary in
71
+ * the wrong place, at unchanged outer dimensions.
72
+ */
73
+ export type CockpitFrameBoundaries = {
74
+ /** The paint is exactly `rows` lines of exactly `columns` cells. */
75
+ readonly grid: boolean;
76
+ /** Rows drawn as a full-width horizontal rule, in order. */
77
+ readonly ruleRows: readonly number[];
78
+ /** Columns carrying a vertical boundary inside the body band, in order. */
79
+ readonly dividerColumns: readonly number[];
80
+ /** Every contiguous vertical boundary span the paint draws in the body. */
81
+ readonly dividerSpans: readonly CockpitBoundarySpan[];
82
+ /** Rows carrying an advertised key label, in order. */
83
+ readonly keyRows: readonly number[];
84
+ };
85
+ export type CockpitBoundarySpan = {
86
+ readonly column: number;
87
+ readonly row: number;
88
+ readonly rows: number;
89
+ };
90
+ export type CockpitFrameContractInspection = {
91
+ readonly tiles: boolean;
92
+ /** The boundary geometry `tiles` is decided from. */
93
+ readonly boundaries: CockpitFrameBoundaries;
94
+ readonly withinWidth: boolean;
95
+ readonly headerCount: number;
96
+ readonly markGlyphCount: number;
97
+ readonly keybarContained: boolean;
98
+ /** Cells the rail's content band occupies; 0 where the plan draws no rail. */
99
+ readonly sidebarContentColumns: number;
100
+ /** Blank cells between that band and the divider; 0 where there is no rail. */
101
+ readonly sidebarGutterColumns: number;
102
+ /** Focus markers inside the navigation zone alone — never the whole frame. */
103
+ readonly sidebarFocusMarkers: number;
104
+ /** Selection markers inside the active list zone alone. */
105
+ readonly listSelectionMarkers: number;
106
+ };
107
+ /**
108
+ * The mark's alphabet: the block range minus the glyphs the approved artwork
109
+ * itself draws — the sparkline's eighth-block ramp (▁▂▃▄▅▆▇█) and the meter's
110
+ * shades (░▒▓). Subtracting them is what lets the scan run over the whole frame
111
+ * rather than over a header band a renderer could redefine, which is the point:
112
+ * a mark reintroduced anywhere is found.
113
+ */
114
+ export declare const COCKPIT_MARK_ALPHABET = "[\u2580\u2589-\u2590\u2594-\u259F]";
115
+ /** Where the paint drew the header signature, in cells rather than in bytes. */
116
+ export type HeaderSignatureHit = {
117
+ readonly row: number;
118
+ readonly column: number;
119
+ readonly text: string;
120
+ };
121
+ /**
122
+ * Every header signature in a painted frame, at whatever indentation it was
123
+ * drawn. The inspector counts these and the falsification lever plants one, so
124
+ * neither can hold a private idea of what a header looks like.
125
+ */
126
+ export declare function findHeaderSignatures(lines: readonly string[]): HeaderSignatureHit[];
127
+ /**
128
+ * Inspect emitted terminal bytes without re-planning their geometry. This is
129
+ * deliberately renderer-agnostic: the sweep and the production capture path
130
+ * can judge the same paint, and a future renderer cannot move this target by
131
+ * changing its own plan. The one thing it does take from the plan is where the
132
+ * zones are, because "one zone holds focus" is a statement about zones and
133
+ * cannot be counted as a frame-wide glyph total.
134
+ */
135
+ export declare function inspectCockpitFrameContract(output: string, columns: number, rows: number): CockpitFrameContractInspection;
136
+ /** What the contract's artwork draws at one size — the sweep's expectation. */
137
+ export declare function cockpitFrameContractExpectation(columns: number, rows: number): Omit<CockpitFrameContractInspection, "listSelectionMarkers" | "boundaries">;
43
138
  export type GoldenFrameCase = {
44
139
  readonly id: string;
45
140
  readonly fixture: string;
@@ -51,6 +146,14 @@ export type GoldenFrameCase = {
51
146
  readonly colour: boolean;
52
147
  readonly ci: boolean;
53
148
  };
149
+ /** The surfaces whose rendered appearance this redesign moved. */
150
+ export declare const RETIRED_RENDERED_COCKPITS: readonly CockpitName[];
151
+ /**
152
+ * A golden case is RETIRED when its cockpit's rendered appearance moved and
153
+ * the case is a rendered one. Retirement keeps the superseded bytes committed
154
+ * and the case in the shipped manifest — only regeneration skips it (OBS-207).
155
+ */
156
+ export declare const isRetiredGoldenFrame: (golden: GoldenFrameCase) => boolean;
54
157
  export declare const GOLDEN_FRAME_CASES: readonly GoldenFrameCase[];
55
158
  export type ColourFrameCase = {
56
159
  readonly id: "healthy-colour" | "healthy-no-colour" | "warning-failure-colour";
@@ -62,7 +165,9 @@ export type ColourFrameCase = {
62
165
  };
63
166
  /**
64
167
  * The colour corpus has a deliberately separate manifest from the layout
65
- * corpus. Layout cases stay colourless; these cases alone exercise real ink.
168
+ * corpus. Layout cases stay colourless; these cases alone exercise real ink,
169
+ * and their committed bytes are RETIRED superseded evidence: the ink oracles
170
+ * render each case fresh and judge it.
66
171
  */
67
172
  export declare const COLOUR_FRAME_CASES: readonly [{
68
173
  readonly id: "healthy-colour";
@@ -86,6 +191,7 @@ export declare const COLOUR_FRAME_CASES: readonly [{
86
191
  readonly rows: 24;
87
192
  readonly colour: true;
88
193
  }];
194
+ export declare function defaultPlainRenderer(cockpit: CockpitName, binaryVersion: string, captures: DemoCaptures, columns?: number, runInteraction?: RunInteractionState, runKeyProjection?: RunKeyProjection): string;
89
195
  /**
90
196
  * Capture exactly the bytes Ink writes for one static paint. Debug mode is
91
197
  * deliberate: it exposes the complete frame rather than terminal patch
@@ -9,17 +9,259 @@ import { GLYPHS } from "../../brand.js";
9
9
  import { version } from "../../cli/commands/version.js";
10
10
  import { KEYBAR_KEYS } from "./components.js";
11
11
  import { loadDemoCaptures } from "./demo.js";
12
- import { resolveCockpitLayout, } from "./layout.js";
13
- import { deriveRunCockpitData, RunCockpitFrame, } from "./run-cockpit.js";
12
+ import { planFrame, RAIL_COLUMNS, resolveCockpitLayout, } from "./layout.js";
13
+ import { deriveRunCockpitData, deriveRunViewRows, RunCockpitFrame, } from "./run-cockpit.js";
14
14
  import { deriveSetupCockpitData, SetupCockpitFrame, } from "./setup-cockpit.js";
15
+ import { formatKeybarEntries, initialRunInteractionState, projectRunKeyEntries, } from "./keys.js";
16
+ import { cellWidth } from "./width.js";
15
17
  export const WIDTH_BAND_CASES = [
16
18
  { arrangement: "stacked", columns: 80 },
17
19
  { arrangement: "folded-keys", columns: 100 },
18
20
  { arrangement: "three-column", columns: 140 },
19
21
  ];
20
22
  export const HEIGHT_TIER_BOUNDARIES = [14, 18, 24, 40];
23
+ export const COCKPIT_CONTRACT_DOMAIN = {
24
+ columns: { minimum: 40, maximum: 220 },
25
+ rows: { minimum: 14, maximum: 50 },
26
+ };
27
+ /**
28
+ * What the contract's artwork draws for the rail: a 15-cell content band, then
29
+ * one blank gutter cell, then the divider at cell 16. Both numbers are measured
30
+ * from the approved frames in the design contract, and the rail span the plan
31
+ * allocates is the same 15.
32
+ */
33
+ export const COCKPIT_SIDEBAR_CONTENT_COLUMNS = RAIL_COLUMNS;
34
+ export const COCKPIT_SIDEBAR_GUTTER_COLUMNS = 1;
35
+ /** Content plus gutter — the cells before the divider. */
36
+ export const COCKPIT_SIDEBAR_COLUMNS = COCKPIT_SIDEBAR_CONTENT_COLUMNS + COCKPIT_SIDEBAR_GUTTER_COLUMNS;
37
+ const CAPTURE_ANSI = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
38
+ /**
39
+ * The mark's alphabet: the block range minus the glyphs the approved artwork
40
+ * itself draws — the sparkline's eighth-block ramp (▁▂▃▄▅▆▇█) and the meter's
41
+ * shades (░▒▓). Subtracting them is what lets the scan run over the whole frame
42
+ * rather than over a header band a renderer could redefine, which is the point:
43
+ * a mark reintroduced anywhere is found.
44
+ */
45
+ export const COCKPIT_MARK_ALPHABET = "[▀▉-▐▔-▟]";
46
+ const COCKPIT_MARK_GLYPHS = new RegExp(COCKPIT_MARK_ALPHABET, "gu");
47
+ const KEYBAR_LABEL = /(?:↑↓ Move|⏎ Open|← Back|Tab (?:Setup|Watch)|1–5 View|f Follow|\? Help|q Quit)/u;
48
+ /**
49
+ * The header signature: the brand word standing on its own, wherever the paint
50
+ * puts it. Indentation is not part of the signature — the artwork insets the
51
+ * word on wide frames, so anchoring at column zero would read a valid header as
52
+ * absent and an indented duplicate as absent too. A path or branch reference
53
+ * that merely contains the word (`tickmarkr/run-…`) is not a header, so both
54
+ * sides are bounded against the characters such a reference joins with.
55
+ */
56
+ const HEADER_SIGNATURE = /(?<![\w/-])tickmarkr(?![\w/-])/gu;
57
+ const FOCUS_MARKER = "❯";
58
+ /** The glyphs the contract's artwork draws a region edge with. */
59
+ const RULE_GLYPH = "─";
60
+ const DIVIDER_GLYPH = "│";
61
+ const sameSequence = (observed, planned) => observed.length === planned.length
62
+ && observed.every((value, index) => value === planned[index]);
63
+ const sameBoundarySpans = (observed, planned) => observed.length === planned.length
64
+ && observed.every((span, index) => {
65
+ const expected = planned[index];
66
+ return expected !== undefined
67
+ && span.column === expected.column
68
+ && span.row === expected.row
69
+ && span.rows === expected.rows;
70
+ });
71
+ /** Display-cell offsets carrying `glyph`, never code-point offsets. */
72
+ function glyphColumns(line, glyph) {
73
+ return [...line.matchAll(new RegExp(glyph, "gu"))].map((match) => cellWidth(line.slice(0, match.index)));
74
+ }
75
+ /** Contiguous row spans for every column carrying the vertical boundary. */
76
+ function verticalBoundarySpans(lines, region) {
77
+ if (!region)
78
+ return [];
79
+ const rowsByColumn = new Map();
80
+ for (let row = region.row; row < region.row + region.rows; row += 1) {
81
+ for (const column of glyphColumns(lines[row] ?? "", DIVIDER_GLYPH)) {
82
+ const observedRows = rowsByColumn.get(column) ?? [];
83
+ observedRows.push(row);
84
+ rowsByColumn.set(column, observedRows);
85
+ }
86
+ }
87
+ return [...rowsByColumn.entries()]
88
+ .sort(([left], [right]) => left - right)
89
+ .flatMap(([column, observedRows]) => {
90
+ const spans = [];
91
+ let start = observedRows[0];
92
+ let previous = start;
93
+ for (const row of observedRows.slice(1)) {
94
+ if (previous !== undefined && row === previous + 1) {
95
+ previous = row;
96
+ continue;
97
+ }
98
+ if (start !== undefined && previous !== undefined) {
99
+ spans.push({ column, row: start, rows: previous - start + 1 });
100
+ }
101
+ start = row;
102
+ previous = row;
103
+ }
104
+ if (start !== undefined && previous !== undefined) {
105
+ spans.push({ column, row: start, rows: previous - start + 1 });
106
+ }
107
+ return spans;
108
+ });
109
+ }
110
+ /**
111
+ * Every header signature in a painted frame, at whatever indentation it was
112
+ * drawn. The inspector counts these and the falsification lever plants one, so
113
+ * neither can hold a private idea of what a header looks like.
114
+ */
115
+ export function findHeaderSignatures(lines) {
116
+ return lines.flatMap((line, row) => [...line.matchAll(HEADER_SIGNATURE)].map((match) => ({
117
+ row,
118
+ // Cells, not code units — the same count the frame's cells are read in.
119
+ column: cellWidth(line.slice(0, match.index)),
120
+ text: match[0],
121
+ })));
122
+ }
123
+ /** The cells of one row inside a planned region's column span. */
124
+ function regionSlice(line, region) {
125
+ const cells = [...line];
126
+ return cells.slice(region.column, region.column + region.columns).join("");
127
+ }
128
+ function markersIn(lines, region) {
129
+ if (!region)
130
+ return 0;
131
+ let markers = 0;
132
+ for (let row = region.row; row < region.row + region.rows; row += 1) {
133
+ const line = lines[row];
134
+ if (line === undefined)
135
+ continue;
136
+ markers += [...regionSlice(line, region)]
137
+ .filter((glyph) => glyph === FOCUS_MARKER).length;
138
+ }
139
+ return markers;
140
+ }
141
+ /**
142
+ * Inspect emitted terminal bytes without re-planning their geometry. This is
143
+ * deliberately renderer-agnostic: the sweep and the production capture path
144
+ * can judge the same paint, and a future renderer cannot move this target by
145
+ * changing its own plan. The one thing it does take from the plan is where the
146
+ * zones are, because "one zone holds focus" is a statement about zones and
147
+ * cannot be counted as a frame-wide glyph total.
148
+ */
149
+ export function inspectCockpitFrameContract(output, columns, rows) {
150
+ const visible = output.replace(CAPTURE_ANSI, "").replace(/\n$/u, "");
151
+ const lines = visible.split("\n");
152
+ const widths = lines.map((line) => cellWidth(line));
153
+ const headerCount = findHeaderSignatures(lines).length;
154
+ const markGlyphCount = lines.reduce((count, line) => count + [...line.matchAll(COCKPIT_MARK_GLYPHS)].length, 0);
155
+ const keyRows = lines.flatMap((line, row) => KEYBAR_LABEL.test(line) ? [row] : []);
156
+ const plan = planFrame({ columns, rows });
157
+ const regions = plan.kind === "frame" ? plan.regions : [];
158
+ const region = (id) => regions.find((candidate) => candidate.id === id);
159
+ const body = region("body");
160
+ const rail = region("rail");
161
+ const navigation = rail ?? region("strip");
162
+ const keybar = region("keybar");
163
+ // The boundaries the paint actually draws. A full-width rule is a row band's
164
+ // edge; a vertical inside the body band is the rail's edge. Both are read
165
+ // from the bytes and neither is inferred from whichever row happens to be
166
+ // painted last.
167
+ const ruleRows = lines.flatMap((line, row) => {
168
+ const cells = [...line];
169
+ return cells.length > 0 && cellWidth(line) === columns
170
+ && cells.every((glyph) => glyph === RULE_GLYPH)
171
+ ? [row]
172
+ : [];
173
+ });
174
+ const dividerSpans = verticalBoundarySpans(lines, body);
175
+ const dividerColumns = [...new Set(dividerSpans.map(({ column }) => column))];
176
+ const boundaries = {
177
+ grid: lines.length === rows
178
+ && lines.every((line) => cellWidth(line) === columns),
179
+ ruleRows,
180
+ dividerColumns,
181
+ dividerSpans,
182
+ keyRows,
183
+ };
184
+ // What the plan says those boundaries must be: a rule at each rule band, and
185
+ // — only where the artwork draws a rail — one vertical, at the divider cell
186
+ // the rail's content band and its gutter end at.
187
+ const plannedRuleRows = regions
188
+ .filter((candidate) => candidate.id === "rule" || candidate.id === "rule2")
189
+ .map((candidate) => candidate.row)
190
+ .sort((left, right) => left - right);
191
+ const plannedDividerColumns = rail ? [COCKPIT_SIDEBAR_COLUMNS] : [];
192
+ const plannedDividerSpans = rail && body
193
+ ? [{
194
+ column: COCKPIT_SIDEBAR_COLUMNS,
195
+ row: body.row,
196
+ rows: body.rows,
197
+ }]
198
+ : [];
199
+ // The rail's content band and its gutter, read off the paint: the divider
200
+ // column the body rows agree on, less the blank cells every one of them
201
+ // leaves in front of it.
202
+ let sidebarContentColumns = 0;
203
+ let sidebarGutterColumns = 0;
204
+ if (rail && body) {
205
+ const railRows = lines
206
+ .slice(body.row, body.row + body.rows)
207
+ .filter((line) => line.includes("│"));
208
+ const dividers = new Set(railRows.map((line) => cellWidth(line.slice(0, line.indexOf("│")))));
209
+ if (railRows.length === body.rows && dividers.size === 1) {
210
+ const divider = [...dividers][0];
211
+ let gutter = 0;
212
+ while (gutter < divider
213
+ && railRows.every((line) => ([...line][divider - gutter - 1] ?? " ") === " ")) {
214
+ gutter += 1;
215
+ }
216
+ sidebarContentColumns = divider - gutter;
217
+ sidebarGutterColumns = gutter;
218
+ }
219
+ }
220
+ return {
221
+ // Tiling is region ownership, not a filled rectangle: the paint covers the
222
+ // whole grid AND every boundary sits at the offset the plan gives it, so a
223
+ // region moved or overlapped at unchanged outer dimensions is a violation.
224
+ tiles: boundaries.grid
225
+ && sameSequence(ruleRows, plannedRuleRows)
226
+ && sameSequence(dividerColumns, plannedDividerColumns)
227
+ && sameBoundarySpans(dividerSpans, plannedDividerSpans),
228
+ boundaries,
229
+ withinWidth: widths.every((width) => width <= columns),
230
+ headerCount,
231
+ markGlyphCount,
232
+ // Contained means inside the keybar band the plan allocated — never
233
+ // "wherever the last painted row turned out to be", which accepts a keybar
234
+ // one row above its own blank band.
235
+ keybarContained: keybar !== undefined
236
+ && keyRows.length > 0
237
+ && keyRows.every((row) => row === keybar.row),
238
+ sidebarContentColumns,
239
+ sidebarGutterColumns,
240
+ sidebarFocusMarkers: markersIn(lines, navigation),
241
+ listSelectionMarkers: markersIn(lines, body),
242
+ };
243
+ }
244
+ /** What the contract's artwork draws at one size — the sweep's expectation. */
245
+ export function cockpitFrameContractExpectation(columns, rows) {
246
+ const plan = planFrame({ columns, rows });
247
+ const sidebar = plan.kind === "frame" && plan.band === "sidebar";
248
+ return {
249
+ tiles: true,
250
+ withinWidth: true,
251
+ headerCount: 1,
252
+ markGlyphCount: 0,
253
+ keybarContained: true,
254
+ sidebarContentColumns: sidebar ? COCKPIT_SIDEBAR_CONTENT_COLUMNS : 0,
255
+ sidebarGutterColumns: sidebar ? COCKPIT_SIDEBAR_GUTTER_COLUMNS : 0,
256
+ sidebarFocusMarkers: 1,
257
+ };
258
+ }
21
259
  const fixtureName = (cockpit, id, columns, rows) => `${cockpit}.${id}.${columns}x${rows}.txt`;
22
- const goldenCasesFor = (cockpit) => [
260
+ /**
261
+ * The rendered corpus of one surface: a frame at each contracted width band and
262
+ * each height tier boundary, plus the colourless variant.
263
+ */
264
+ const renderedCasesFor = (cockpit) => [
23
265
  ...WIDTH_BAND_CASES.map(({ arrangement, columns }) => ({
24
266
  id: `${cockpit}-width-${arrangement}`,
25
267
  fixture: fixtureName(cockpit, `width-${arrangement}`, columns, 24),
@@ -53,6 +295,9 @@ const goldenCasesFor = (cockpit) => [
53
295
  colour: false,
54
296
  ci: false,
55
297
  },
298
+ ];
299
+ /** The two machine surfaces: output this redesign did not move. */
300
+ const machineCasesFor = (cockpit) => [
56
301
  {
57
302
  id: `${cockpit}-non-tty`,
58
303
  fixture: fixtureName(cockpit, "non-tty", 140, 24),
@@ -76,13 +321,29 @@ const goldenCasesFor = (cockpit) => [
76
321
  ci: true,
77
322
  },
78
323
  ];
324
+ /** The surfaces whose rendered appearance this redesign moved. */
325
+ export const RETIRED_RENDERED_COCKPITS = ["run"];
326
+ /**
327
+ * A golden case is RETIRED when its cockpit's rendered appearance moved and
328
+ * the case is a rendered one. Retirement keeps the superseded bytes committed
329
+ * and the case in the shipped manifest — only regeneration skips it (OBS-207).
330
+ */
331
+ export const isRetiredGoldenFrame = (golden) => RETIRED_RENDERED_COCKPITS.includes(golden.cockpit)
332
+ && golden.interactive
333
+ && !golden.ci;
334
+ const goldenCasesFor = (cockpit) => [
335
+ ...renderedCasesFor(cockpit),
336
+ ...machineCasesFor(cockpit),
337
+ ];
79
338
  export const GOLDEN_FRAME_CASES = [
80
339
  ...goldenCasesFor("run"),
81
340
  ...goldenCasesFor("setup"),
82
341
  ];
83
342
  /**
84
343
  * The colour corpus has a deliberately separate manifest from the layout
85
- * corpus. Layout cases stay colourless; these cases alone exercise real ink.
344
+ * corpus. Layout cases stay colourless; these cases alone exercise real ink,
345
+ * and their committed bytes are RETIRED superseded evidence: the ink oracles
346
+ * render each case fresh and judge it.
86
347
  */
87
348
  export const COLOUR_FRAME_CASES = [
88
349
  {
@@ -137,15 +398,20 @@ function stateGlyph(state) {
137
398
  return GLYPHS.attention;
138
399
  return GLYPHS.neutral;
139
400
  }
140
- function defaultPlainRenderer(cockpit, binaryVersion, captures) {
401
+ export function defaultPlainRenderer(cockpit, binaryVersion, captures, columns = 140, runInteraction = initialRunInteractionState(), runKeyProjection = projectRunKeyEntries) {
141
402
  const derived = derivedData(cockpit, binaryVersion, captures);
142
403
  if (derived.cockpit === "run") {
143
404
  const data = derived.data;
405
+ const rowIds = deriveRunViewRows(data, runInteraction.activeView, runInteraction.filterQuery).map((row) => row.id);
144
406
  return [
145
407
  `tickmarkr v${binaryVersion}`,
146
408
  `${data.runId} ${data.status} ${data.elapsed}`,
147
409
  ...data.statusItems.map((item) => `${stateGlyph(item.state)} ${item.text}`),
148
- `keys ${KEYBAR_KEYS.run.map((item) => `${item.key} ${item.label}`).join(" · ")}`,
410
+ `keys ${formatKeybarEntries(runKeyProjection({
411
+ interaction: runInteraction,
412
+ columns,
413
+ rowIds,
414
+ }))}`,
149
415
  "",
150
416
  ].join("\n");
151
417
  }
@@ -161,12 +427,14 @@ function defaultPlainRenderer(cockpit, binaryVersion, captures) {
161
427
  "",
162
428
  ].join("\n");
163
429
  }
164
- function frameNode(derived, columns, rows) {
430
+ function frameNode(derived, columns, rows, runInteraction = initialRunInteractionState(), runKeyProjection = projectRunKeyEntries) {
165
431
  if (derived.cockpit === "run") {
166
432
  return createElement(RunCockpitFrame, {
167
433
  data: derived.data,
168
434
  columns,
169
435
  rows,
436
+ interaction: runInteraction,
437
+ keyProjection: runKeyProjection,
170
438
  });
171
439
  }
172
440
  return createElement(SetupCockpitFrame, {
@@ -239,7 +507,9 @@ export async function captureCockpitOutput(options) {
239
507
  ?? process.env.NO_COLOR === undefined;
240
508
  const ci = options.ci ?? process.env.CI === "true";
241
509
  const captures = options.captures ?? loadDemoCaptures();
242
- const plain = () => (options.plainRenderer ?? defaultPlainRenderer)(options.cockpit, options.binaryVersion, captures);
510
+ const plain = () => options.plainRenderer === undefined
511
+ ? defaultPlainRenderer(options.cockpit, options.binaryVersion, captures, columns, options.runInteraction, options.runKeyProjection)
512
+ : options.plainRenderer(options.cockpit, options.binaryVersion, captures);
243
513
  const layout = resolveCockpitLayout(columns, rows);
244
514
  if (!interactive || options.output.isTTY !== true || ci || layout.renderer === "plain") {
245
515
  const plainOutput = plain();
@@ -247,7 +517,7 @@ export async function captureCockpitOutput(options) {
247
517
  return { renderer: "plain", output: plainOutput };
248
518
  }
249
519
  const derived = derivedData(options.cockpit, options.binaryVersion, captures);
250
- const frameOutput = await (options.frameRenderer ?? captureRendererOutput)(frameNode(derived, columns, rows), { columns, rows, colour });
520
+ const frameOutput = await (options.frameRenderer ?? captureRendererOutput)(frameNode(derived, columns, rows, options.runInteraction, options.runKeyProjection), { columns, rows, colour });
251
521
  if (frameOutput.length === 0) {
252
522
  const plainOutput = plain();
253
523
  options.output.write(plainOutput);
@@ -261,6 +531,10 @@ export async function regenerateGoldenFrames(binaryVersion) {
261
531
  const captures = loadDemoCaptures();
262
532
  const regenerated = [];
263
533
  for (const golden of GOLDEN_FRAME_CASES) {
534
+ // Regenerating a retired case would produce the new appearance, and
535
+ // byte-comparing would force the re-stamp the contract forbids.
536
+ if (isRetiredGoldenFrame(golden))
537
+ continue;
264
538
  const output = new PassThrough();
265
539
  output.isTTY = golden.interactive;
266
540
  output.columns = golden.columns;
@@ -291,7 +565,10 @@ export async function regenerateGoldenFrames(binaryVersion) {
291
565
  return regenerated;
292
566
  }
293
567
  export function findGoldenFrameMismatches(regenerated, committed) {
294
- const expected = new Set(regenerated.map((frame) => frame.fixture));
568
+ // Membership is judged against the whole shipped manifest, retired cases
569
+ // included: a retired fixture is claimed, so only its bytes are exempt.
570
+ // Anything committed that no case claims is still named.
571
+ const expected = new Set(GOLDEN_FRAME_CASES.map((frame) => frame.fixture));
295
572
  return [
296
573
  ...regenerated.flatMap((frame) => committed.get(frame.fixture) === frame.output ? [] : [frame.fixture]),
297
574
  ...[...committed.keys()].filter((fixture) => !expected.has(fixture)),
@@ -13,96 +13,16 @@ export type BandAllocationOptions = {
13
13
  };
14
14
  export type TextEmphasis = keyof typeof TEXT_EMPHASIS_TOKENS;
15
15
  export type ComponentState = "active" | "inactive" | "pass" | "fail" | "warn" | "neutral";
16
- export type KeybarSurface = "run" | "setup";
17
16
  export type KeybarItem = {
18
17
  readonly key: string;
19
18
  readonly label: string;
20
19
  };
21
20
  export declare const KEYBAR_KEYS: {
22
- readonly run: readonly [{
23
- readonly key: "↑↓";
24
- readonly label: "Move";
25
- }, {
26
- readonly key: "⏎";
27
- readonly label: "Open";
28
- }, {
29
- readonly key: "←";
30
- readonly label: "Back";
31
- }, {
32
- readonly key: "Tab";
33
- readonly label: "Panel";
34
- }, {
35
- readonly key: "?";
36
- readonly label: "Help";
37
- }, {
38
- readonly key: "q";
39
- readonly label: "Quit";
40
- }, {
41
- readonly key: "f";
42
- readonly label: "Follow";
43
- }, {
44
- readonly key: "/";
45
- readonly label: "Filter";
46
- }];
47
- readonly setup: readonly [{
48
- readonly key: "↑↓";
49
- readonly label: "Move";
50
- }, {
51
- readonly key: "⏎";
52
- readonly label: "Next";
53
- }, {
54
- readonly key: "←";
55
- readonly label: "Back";
56
- }, {
57
- readonly key: "Tab";
58
- readonly label: "Panel";
59
- }, {
60
- readonly key: "?";
61
- readonly label: "Help";
62
- }, {
63
- readonly key: "q";
64
- readonly label: "Quit";
65
- }, {
66
- readonly key: "␣";
67
- readonly label: "Toggle";
68
- }, {
69
- readonly key: "a";
70
- readonly label: "All";
71
- }, {
72
- readonly key: "r";
73
- readonly label: "Re-probe";
74
- }, {
75
- readonly key: "s";
76
- readonly label: "Save";
77
- }, {
78
- readonly key: "n";
79
- readonly label: "Next";
80
- }, {
81
- readonly key: "p";
82
- readonly label: "Prev";
83
- }];
84
- readonly setupAdditional: readonly [{
85
- readonly key: "␣";
86
- readonly label: "Toggle";
87
- }, {
88
- readonly key: "a";
89
- readonly label: "All";
90
- }, {
91
- readonly key: "r";
92
- readonly label: "Re-probe";
93
- }, {
94
- readonly key: "s";
95
- readonly label: "Save";
96
- }, {
97
- readonly key: "n";
98
- readonly label: "Next";
99
- }, {
100
- readonly key: "p";
101
- readonly label: "Prev";
102
- }];
21
+ readonly setup: readonly import("./keys.js").KeybarEntry[];
22
+ readonly setupAdditional: import("./keys.js").KeybarEntry[];
103
23
  };
104
24
  export type JournalRow = {
105
- readonly id?: string;
25
+ readonly id: string;
106
26
  readonly time: string;
107
27
  readonly state: ComponentState;
108
28
  readonly text: string;
@@ -159,8 +79,15 @@ export declare function ProgressMeter({ value, width, }: {
159
79
  value: number;
160
80
  width?: number;
161
81
  }): ReactElement;
162
- export declare function Keybar({ surface, width, }: {
163
- surface: KeybarSurface;
82
+ /**
83
+ * Wrap a projected roster without losing a byte. Unlike prose wrapping, these
84
+ * rows are exact consecutive slices: stripping continuation markers and
85
+ * joining the slices reconstructs the registry projection byte for byte.
86
+ */
87
+ export declare function keyRosterLines(entries: readonly KeybarItem[], columns: number): readonly string[];
88
+ /** The lossless, width-safe rendering of one projected key roster. */
89
+ export declare function KeyRoster({ entries, width, }: {
90
+ entries: readonly KeybarItem[];
164
91
  width: number;
165
92
  }): ReactElement;
166
93
  export type StatusStripItem = {
@@ -171,9 +98,11 @@ export declare function StatusStrip({ items, width, }: {
171
98
  items: readonly StatusStripItem[];
172
99
  width: number;
173
100
  }): ReactElement;
174
- export declare function JournalRowPanel({ rows, width, focused, title, }: {
101
+ export declare function JournalRowPanel({ rows, width, focused, title, selection, }: {
175
102
  rows: readonly JournalRow[];
176
103
  width?: number | string;
177
104
  focused?: boolean;
178
105
  title?: string;
106
+ /** Index of the row the pointer marks; undefined draws no pointer. */
107
+ selection?: number;
179
108
  }): ReactElement;