tickmarkr 1.79.0 → 1.81.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.
@@ -176,19 +176,36 @@ const KIMI_ANSI_SGR_RE = /\u001B\[[0-9;]*m/g;
176
176
  const KIMI_EDITOR_TOP_RE = /^╭─+╮$/;
177
177
  const KIMI_EDITOR_INPUT_RE = /^│ > .*│$/;
178
178
  const KIMI_EDITOR_EMPTY_RE = /^│ > +│$/;
179
+ // OBS-181: any interior row of the box. An editor carrying a prompt renders one or more blank
180
+ // continuation rows beneath it, so the bottom border is not always the very next line.
181
+ const KIMI_EDITOR_ROW_RE = /^│.*│$/;
179
182
  const KIMI_EDITOR_BOTTOM_RE = /^╰─+╯$/;
180
183
  function matchesKimiEditorBox(paneText, empty) {
181
184
  // OBS-152: kimi renders its whole TUI indented one column, so `^`-anchored row matches never fire
182
185
  // on a LIVE editor box — probe 6 timed out readiness against a frame whose box was fully painted
183
186
  // and interactive. Trim per line: the left margin is chrome, and what actually distinguishes the
184
- // editor from the welcome panel (also a bordered box) is the three-row adjacency asserted below.
187
+ // editor from the welcome panel (also a bordered box) is the border adjacency asserted below.
188
+ //
189
+ // `match` means THE EDITOR BOX IS PAINTED (readiness), and is deliberately true for an empty box.
190
+ // `emptyMatch` is the stricter "painted AND carrying nothing". Callers deciding submission must
191
+ // therefore test emptyMatch FIRST — see HerdrDriver.submissionRegistered.
192
+ //
193
+ // OBS-181: the adjacency used to demand the bottom border on the line directly below the prompt.
194
+ // A wedged worker pane rendered FOUR rows — top, prompt, a blank continuation row, bottom — so
195
+ // NEITHER matcher fired, and the driver's submission check fell through to a fallback that always
196
+ // answers "delivered". Walk the interior to the border instead of assuming a three-row box.
185
197
  const lines = paneText.replace(KIMI_ANSI_SGR_RE, "").split("\n").map((line) => line.trim());
186
198
  const input = empty ? KIMI_EDITOR_EMPTY_RE : KIMI_EDITOR_INPUT_RE;
187
- return lines.some((line, index) => input.test(line)
188
- && index > 0
189
- && index + 1 < lines.length
190
- && KIMI_EDITOR_TOP_RE.test(lines[index - 1])
191
- && KIMI_EDITOR_BOTTOM_RE.test(lines[index + 1]));
199
+ return lines.some((line, index) => {
200
+ if (!input.test(line))
201
+ return false;
202
+ if (index === 0 || !KIMI_EDITOR_TOP_RE.test(lines[index - 1]))
203
+ return false;
204
+ let below = index + 1;
205
+ while (below < lines.length && KIMI_EDITOR_ROW_RE.test(lines[below]))
206
+ below++;
207
+ return below < lines.length && KIMI_EDITOR_BOTTOM_RE.test(lines[below]);
208
+ });
192
209
  }
193
210
  // v1.75 T1 / OBS-136: Kimi's actual editor is a three-line bordered box whose content row starts
194
211
  // `│ > `. The welcome panel separately contains "Send /help..." and is only launch-liveness
package/dist/brand.d.ts CHANGED
@@ -2,6 +2,10 @@ import type { OwnedName } from "./drivers/types.js";
2
2
  export declare const BANNER: string;
3
3
  /** ANSI-stripped, trailing-space-trimmed twin of BANNER — README hero and other plain surfaces. */
4
4
  export declare const PLAIN_BANNER: string;
5
+ /** Two-line run-cockpit lockup, sliced from BANNER so its mark and product name cannot drift. */
6
+ export declare const COMPACT_LOCKUP: string;
7
+ /** ANSI-stripped, trailing-space-trimmed twin of COMPACT_LOCKUP for colourless surfaces. */
8
+ export declare const PLAIN_COMPACT_LOCKUP: string;
5
9
  export declare const PANE_IDENTITY_ENV = "TICKMARKR_PANE_IDENTITY";
6
10
  /** One-line pane identity through the T1 ownership contract: role · task · attempt · run. */
7
11
  export declare function paneIdentityLine(o: OwnedName): string;
package/dist/brand.js CHANGED
@@ -12,6 +12,10 @@ export const BANNER = [
12
12
  ].join("\n");
13
13
  /** ANSI-stripped, trailing-space-trimmed twin of BANNER — README hero and other plain surfaces. */
14
14
  export const PLAIN_BANNER = BANNER.replace(/\x1b\[[0-9;]*m/g, "").replace(/[ \t]+$/gm, "");
15
+ /** Two-line run-cockpit lockup, sliced from BANNER so its mark and product name cannot drift. */
16
+ export const COMPACT_LOCKUP = BANNER.split("\n").slice(1, 3).join("\n");
17
+ /** ANSI-stripped, trailing-space-trimmed twin of COMPACT_LOCKUP for colourless surfaces. */
18
+ export const PLAIN_COMPACT_LOCKUP = COMPACT_LOCKUP.replace(/\x1b\[[0-9;]*m/g, "").replace(/[ \t]+$/gm, "");
15
19
  // T5: the pane identity every visible pane announces under the logo. HerdrDriver seeds this env var
16
20
  // into each pane shell at slot() time (paneIdentityLine of the pane's T1 owned name); the banner's
17
21
  // identity line reads it at pane runtime, so every role — worker/judge/review/consult — wears the
@@ -2,7 +2,7 @@ type StudioIO = {
2
2
  input: NodeJS.ReadStream;
3
3
  output: NodeJS.WriteStream;
4
4
  };
5
- export declare function ui(_argv: string[], io?: Partial<StudioIO>): Promise<string | {
5
+ export declare function ui(argv: string[], io?: Partial<StudioIO>, cwd?: string): Promise<string | {
6
6
  out: string;
7
7
  code: number;
8
8
  }>;
@@ -1,11 +1,52 @@
1
- const NON_TTY_MSG = "tickmarkr ui: studio requires a TTY — use `tickmarkr fleet --print` or `tickmarkr status --watch` for line-mode output";
2
- export async function ui(_argv, io = {}) {
1
+ const NON_TTY_MSG = "tickmarkr ui: the cockpit requires a TTY — use `tickmarkr fleet --print` or `tickmarkr status --watch` for line-mode output";
2
+ export async function ui(argv, io = {}, cwd = process.cwd()) {
3
3
  const input = io.input ?? process.stdin;
4
4
  const output = io.output ?? process.stdout;
5
5
  if (input.isTTY !== true || output.isTTY !== true) {
6
6
  return { out: NON_TTY_MSG, code: 1 };
7
7
  }
8
- const { runStudioInk } = await import("../../tui/ink/studio-app.js");
9
- await runStudioInk({ input, output });
8
+ if (argv.includes("--demo")) {
9
+ const { runCockpitDemo } = await import("../../tui/cockpit/demo.js");
10
+ const { version } = await import("./version.js");
11
+ await runCockpitDemo({
12
+ input,
13
+ output,
14
+ binaryVersion: await version(),
15
+ });
16
+ return "ui: closed";
17
+ }
18
+ const unknownFlag = argv.find((arg) => arg.startsWith("-"));
19
+ if (unknownFlag) {
20
+ return { out: `tickmarkr ui: unknown flag ${unknownFlag}`, code: 1 };
21
+ }
22
+ const live = await import("../../tui/cockpit/live.js");
23
+ const { version } = await import("./version.js");
24
+ const binaryVersion = await version();
25
+ const explicit = argv.find((arg) => !arg.startsWith("-"));
26
+ let runId;
27
+ try {
28
+ runId = live.selectEngagementRunId(cwd, explicit);
29
+ }
30
+ catch (error) {
31
+ return { out: `tickmarkr ui: ${error.message}`, code: 1 };
32
+ }
33
+ if (runId === null) {
34
+ return {
35
+ out: "tickmarkr ui: no engagement found — start one with `tickmarkr run` first",
36
+ code: 1,
37
+ };
38
+ }
39
+ // Refuse before drawing anything: unreadable journal bytes must never
40
+ // become a plausible surface derived from nothing.
41
+ try {
42
+ live.deriveLiveRunCockpitData(live.loadEngagementSource(cwd, runId), binaryVersion);
43
+ }
44
+ catch (error) {
45
+ return {
46
+ out: `tickmarkr ui: cannot read engagement ${runId}: ${error.message}`,
47
+ code: 1,
48
+ };
49
+ }
50
+ await live.runLiveCockpit({ input, output, cwd, runId, binaryVersion });
10
51
  return "ui: closed";
11
52
  }
@@ -156,9 +156,29 @@ export class HerdrDriver {
156
156
  return true;
157
157
  if (promptAt < 0)
158
158
  return inputBox !== undefined && matchesEmptyInputBox(transcript, inputBox);
159
- if (inputBox && matchesInputBox(transcript, inputBox)) {
160
- return hay.lastIndexOf(norm(inputBox.fingerprint)) > promptAt;
159
+ if (inputBox) {
160
+ // OBS-181: an EMPTY declared input box is positive evidence of submission even while the
161
+ // prompt is still visible above as the echoed user turn — which is exactly how every TUI
162
+ // renders the moment after Enter lands. Test it FIRST: `match` means "the box is painted" and
163
+ // is deliberately true for an empty box, so asking `match` first cannot decide submission.
164
+ if (matchesEmptyInputBox(transcript, inputBox))
165
+ return true;
166
+ // The box is painted and is NOT empty: the prompt is still sitting in it. Only a fingerprint
167
+ // appearing AFTER the prompt — a fresh input line below the submitted text — counts.
168
+ //
169
+ // This is the branch a wedged kimi pane belongs in, and could not reach: its occupied editor
170
+ // renders a blank continuation row, the matcher demanded the bottom border immediately below
171
+ // the prompt, so NEITHER matcher fired and the positional fallback below answered "delivered"
172
+ // for a pane that had processed nothing. The matcher fix (adapters/kimi.ts) is what routes it
173
+ // here; keeping the fallback unreachable for a painted box is what stops the next one.
174
+ if (matchesInputBox(transcript, inputBox)) {
175
+ return hay.lastIndexOf(norm(inputBox.fingerprint)) > promptAt;
176
+ }
161
177
  }
178
+ // No declared box, or the box is not on screen at all (it may have scrolled out of the read
179
+ // window). Positional evidence only — weaker, and unsound for any surface that paints chrome
180
+ // below its prompt, which is why a declared box must be able to recognise its own OCCUPIED
181
+ // state. Every adapter declaring an inputBox needs a captured occupied frame in its tests.
162
182
  return promptAt + needle.length < hay.length;
163
183
  }
164
184
  static available() {
@@ -583,10 +603,12 @@ export class HerdrDriver {
583
603
  transcript = read.stdout || transcript;
584
604
  const waitedMs = this.time.now() - started;
585
605
  if (read.code !== 0) {
586
- // Once at least one valid frame has been observed, a read that consumes the remainder of
587
- // the readiness budget is the bounded window expiring, not a new submission/protocol
588
- // identity. A first-read timeout and every non-timeout read failure remain structural.
589
- if (read.timedOut && previous !== undefined && waitedMs >= timeoutMs) {
606
+ // Once at least one valid frame has been observed, a timed-out read IS the bounded window
607
+ // expiring: the read was given exactly the remaining readiness budget as its real timeout,
608
+ // so real time genuinely elapsed even when an injected clock has not advanced (an injected
609
+ // clock never moves during a read, so a wall-clock comparison here can never hold under
610
+ // one). A first-read timeout and every non-timeout read failure remain structural.
611
+ if (read.timedOut && previous !== undefined) {
590
612
  throw new DeliveryReadinessError(waitedMs, transcript);
591
613
  }
592
614
  const detail = read.stderr || read.stdout || `exit ${read.code}`;
@@ -0,0 +1,117 @@
1
+ import { type ReactNode } from "react";
2
+ import { type DemoCaptures } from "./demo.js";
3
+ export type CockpitName = "run" | "setup";
4
+ export type CockpitRenderer = "frame" | "plain";
5
+ export type RendererCaptureOptions = {
6
+ readonly columns: number;
7
+ readonly rows: number;
8
+ readonly colour: boolean;
9
+ };
10
+ export type FrameRenderer = (node: ReactNode, options: RendererCaptureOptions) => Promise<string>;
11
+ export type CockpitEmission = {
12
+ readonly renderer: CockpitRenderer;
13
+ readonly output: string;
14
+ };
15
+ export type CaptureCockpitOptions = {
16
+ readonly cockpit: CockpitName;
17
+ readonly output: NodeJS.WriteStream;
18
+ readonly binaryVersion: string;
19
+ readonly columns?: number;
20
+ readonly rows?: number;
21
+ readonly interactive?: boolean;
22
+ readonly colour?: boolean;
23
+ readonly ci?: boolean;
24
+ readonly captures?: DemoCaptures;
25
+ readonly plainRenderer?: (cockpit: CockpitName, binaryVersion: string, captures: DemoCaptures) => string;
26
+ readonly frameRenderer?: FrameRenderer;
27
+ };
28
+ export type WidthBandCase = {
29
+ readonly arrangement: "stacked" | "folded-keys" | "three-column";
30
+ readonly columns: number;
31
+ };
32
+ export declare const WIDTH_BAND_CASES: readonly [{
33
+ readonly arrangement: "stacked";
34
+ readonly columns: 80;
35
+ }, {
36
+ readonly arrangement: "folded-keys";
37
+ readonly columns: 100;
38
+ }, {
39
+ readonly arrangement: "three-column";
40
+ readonly columns: 140;
41
+ }];
42
+ export declare const HEIGHT_TIER_BOUNDARIES: readonly [14, 18, 24, 40];
43
+ export type GoldenFrameCase = {
44
+ readonly id: string;
45
+ readonly fixture: string;
46
+ readonly cockpit: CockpitName;
47
+ readonly axis: "width" | "height" | "variant";
48
+ readonly columns: number;
49
+ readonly rows: number;
50
+ readonly interactive: boolean;
51
+ readonly colour: boolean;
52
+ readonly ci: boolean;
53
+ };
54
+ export declare const GOLDEN_FRAME_CASES: readonly GoldenFrameCase[];
55
+ export type ColourFrameCase = {
56
+ readonly id: "healthy-colour" | "healthy-no-colour" | "warning-failure-colour";
57
+ readonly fixture: string;
58
+ readonly sourceFileName: string;
59
+ readonly columns: number;
60
+ readonly rows: number;
61
+ readonly colour: boolean;
62
+ };
63
+ /**
64
+ * The colour corpus has a deliberately separate manifest from the layout
65
+ * corpus. Layout cases stay colourless; these cases alone exercise real ink.
66
+ */
67
+ export declare const COLOUR_FRAME_CASES: readonly [{
68
+ readonly id: "healthy-colour";
69
+ readonly fixture: "run-20260718-000943.colour.140x24.txt";
70
+ readonly sourceFileName: "run-20260718-000943.journal.jsonl";
71
+ readonly columns: 140;
72
+ readonly rows: 24;
73
+ readonly colour: true;
74
+ }, {
75
+ readonly id: "healthy-no-colour";
76
+ readonly fixture: "run-20260718-000943.no-colour.140x24.txt";
77
+ readonly sourceFileName: "run-20260718-000943.journal.jsonl";
78
+ readonly columns: 140;
79
+ readonly rows: 24;
80
+ readonly colour: false;
81
+ }, {
82
+ readonly id: "warning-failure-colour";
83
+ readonly fixture: "run-20260725-025004.interrupted.colour.140x24.txt";
84
+ readonly sourceFileName: "run-20260725-025004.interrupted.journal.jsonl";
85
+ readonly columns: 140;
86
+ readonly rows: 24;
87
+ readonly colour: true;
88
+ }];
89
+ /**
90
+ * Capture exactly the bytes Ink writes for one static paint. Debug mode is
91
+ * deliberate: it exposes the complete frame rather than terminal patch
92
+ * instructions, so the committed corpus is the renderer's frame output.
93
+ */
94
+ export declare function captureRendererOutput(node: ReactNode, options: RendererCaptureOptions): Promise<string>;
95
+ /**
96
+ * The single interactive/plain boundary. It decides before mounting Ink, then
97
+ * falls back again if a renderer returns an empty paint.
98
+ */
99
+ export declare function captureCockpitOutput(options: CaptureCockpitOptions): Promise<CockpitEmission>;
100
+ export type RegeneratedGoldenFrame = GoldenFrameCase & {
101
+ readonly renderer: CockpitRenderer;
102
+ readonly output: string;
103
+ readonly emitted: string;
104
+ };
105
+ export declare function regenerateGoldenFrames(binaryVersion?: string): Promise<readonly RegeneratedGoldenFrame[]>;
106
+ export declare function findGoldenFrameMismatches(regenerated: readonly RegeneratedGoldenFrame[], committed: ReadonlyMap<string, string>): string[];
107
+ export type RegeneratedColourFrame = ColourFrameCase & {
108
+ readonly output: string;
109
+ readonly emitted: string;
110
+ };
111
+ /**
112
+ * Regenerate only the colour contract corpus from its own manifest. Historical
113
+ * run sources are read and rendered byte-for-byte with their captured daemon
114
+ * stopped, matching the demo path and avoiding live-process state in committed
115
+ * bytes.
116
+ */
117
+ export declare function regenerateColourFrames(binaryVersion?: string): Promise<readonly RegeneratedColourFrame[]>;
@@ -0,0 +1,329 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { PassThrough } from "node:stream";
4
+ import { fileURLToPath } from "node:url";
5
+ import chalk from "chalk";
6
+ import { render } from "ink";
7
+ import { cloneElement, createElement, isValidElement, } from "react";
8
+ import { GLYPHS } from "../../brand.js";
9
+ import { version } from "../../cli/commands/version.js";
10
+ import { KEYBAR_KEYS } from "./components.js";
11
+ import { loadDemoCaptures } from "./demo.js";
12
+ import { resolveCockpitLayout, } from "./layout.js";
13
+ import { deriveRunCockpitData, RunCockpitFrame, } from "./run-cockpit.js";
14
+ import { deriveSetupCockpitData, SetupCockpitFrame, } from "./setup-cockpit.js";
15
+ export const WIDTH_BAND_CASES = [
16
+ { arrangement: "stacked", columns: 80 },
17
+ { arrangement: "folded-keys", columns: 100 },
18
+ { arrangement: "three-column", columns: 140 },
19
+ ];
20
+ export const HEIGHT_TIER_BOUNDARIES = [14, 18, 24, 40];
21
+ const fixtureName = (cockpit, id, columns, rows) => `${cockpit}.${id}.${columns}x${rows}.txt`;
22
+ const goldenCasesFor = (cockpit) => [
23
+ ...WIDTH_BAND_CASES.map(({ arrangement, columns }) => ({
24
+ id: `${cockpit}-width-${arrangement}`,
25
+ fixture: fixtureName(cockpit, `width-${arrangement}`, columns, 24),
26
+ cockpit,
27
+ axis: "width",
28
+ columns,
29
+ rows: 24,
30
+ interactive: true,
31
+ colour: false,
32
+ ci: false,
33
+ })),
34
+ ...HEIGHT_TIER_BOUNDARIES.map((rows) => ({
35
+ id: `${cockpit}-height-${rows}`,
36
+ fixture: fixtureName(cockpit, `height-${rows}`, 140, rows),
37
+ cockpit,
38
+ axis: "height",
39
+ columns: 140,
40
+ rows,
41
+ interactive: true,
42
+ colour: false,
43
+ ci: false,
44
+ })),
45
+ {
46
+ id: `${cockpit}-no-colour`,
47
+ fixture: fixtureName(cockpit, "no-colour", 140, 24),
48
+ cockpit,
49
+ axis: "variant",
50
+ columns: 140,
51
+ rows: 24,
52
+ interactive: true,
53
+ colour: false,
54
+ ci: false,
55
+ },
56
+ {
57
+ id: `${cockpit}-non-tty`,
58
+ fixture: fixtureName(cockpit, "non-tty", 140, 24),
59
+ cockpit,
60
+ axis: "variant",
61
+ columns: 140,
62
+ rows: 24,
63
+ interactive: false,
64
+ colour: false,
65
+ ci: false,
66
+ },
67
+ {
68
+ id: `${cockpit}-ci`,
69
+ fixture: fixtureName(cockpit, "ci", 140, 24),
70
+ cockpit,
71
+ axis: "variant",
72
+ columns: 140,
73
+ rows: 24,
74
+ interactive: true,
75
+ colour: false,
76
+ ci: true,
77
+ },
78
+ ];
79
+ export const GOLDEN_FRAME_CASES = [
80
+ ...goldenCasesFor("run"),
81
+ ...goldenCasesFor("setup"),
82
+ ];
83
+ /**
84
+ * The colour corpus has a deliberately separate manifest from the layout
85
+ * corpus. Layout cases stay colourless; these cases alone exercise real ink.
86
+ */
87
+ export const COLOUR_FRAME_CASES = [
88
+ {
89
+ id: "healthy-colour",
90
+ fixture: "run-20260718-000943.colour.140x24.txt",
91
+ sourceFileName: "run-20260718-000943.journal.jsonl",
92
+ columns: 140,
93
+ rows: 24,
94
+ colour: true,
95
+ },
96
+ {
97
+ id: "healthy-no-colour",
98
+ fixture: "run-20260718-000943.no-colour.140x24.txt",
99
+ sourceFileName: "run-20260718-000943.journal.jsonl",
100
+ columns: 140,
101
+ rows: 24,
102
+ colour: false,
103
+ },
104
+ {
105
+ id: "warning-failure-colour",
106
+ fixture: "run-20260725-025004.interrupted.colour.140x24.txt",
107
+ sourceFileName: "run-20260725-025004.interrupted.journal.jsonl",
108
+ columns: 140,
109
+ rows: 24,
110
+ colour: true,
111
+ },
112
+ ];
113
+ const COLOUR_SOURCE_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), "../../../tests/fixtures/cockpit/colour/sources");
114
+ function derivedData(cockpit, binaryVersion, captures) {
115
+ if (cockpit === "setup") {
116
+ return {
117
+ cockpit,
118
+ data: deriveSetupCockpitData(captures, binaryVersion),
119
+ };
120
+ }
121
+ const source = captures.journals.find((capture) => capture.fileName === "run-20260724-231138.journal.jsonl");
122
+ if (!source)
123
+ throw new Error("healthy cockpit capture is missing");
124
+ return {
125
+ cockpit,
126
+ data: deriveRunCockpitData(source, binaryVersion),
127
+ };
128
+ }
129
+ function stateGlyph(state) {
130
+ if (state === "active" || state === "pass")
131
+ return GLYPHS.pass;
132
+ if (state === "inactive")
133
+ return GLYPHS.toggleInactive;
134
+ if (state === "fail")
135
+ return GLYPHS.fail;
136
+ if (state === "warn")
137
+ return GLYPHS.attention;
138
+ return GLYPHS.neutral;
139
+ }
140
+ function defaultPlainRenderer(cockpit, binaryVersion, captures) {
141
+ const derived = derivedData(cockpit, binaryVersion, captures);
142
+ if (derived.cockpit === "run") {
143
+ const data = derived.data;
144
+ return [
145
+ `tickmarkr v${binaryVersion}`,
146
+ `${data.runId} ${data.status} ${data.elapsed}`,
147
+ ...data.statusItems.map((item) => `${stateGlyph(item.state)} ${item.text}`),
148
+ `keys ${KEYBAR_KEYS.run.map((item) => `${item.key} ${item.label}`).join(" · ")}`,
149
+ "",
150
+ ].join("\n");
151
+ }
152
+ const data = derived.data;
153
+ return [
154
+ `tickmarkr v${binaryVersion}`,
155
+ `setup ${data.counts.found} found ${data.counts.authenticated} authed ${data.counts.routable} routable`,
156
+ ...data.harnesses.map((harness) => `${stateGlyph(harness.state)} ${harness.stateWord} ${harness.id}`),
157
+ ...data.deniedChannels.map((denied) => `${GLYPHS.fail} denied ${denied.channel} - ${denied.reason}`),
158
+ `${GLYPHS.pass} base untouched`,
159
+ `${GLYPHS.attention} ${data.stagedChanges.length} changes unsaved`,
160
+ `keys ${KEYBAR_KEYS.setup.map((item) => `${item.key} ${item.label}`).join(" · ")}`,
161
+ "",
162
+ ].join("\n");
163
+ }
164
+ function frameNode(derived, columns, rows) {
165
+ if (derived.cockpit === "run") {
166
+ return createElement(RunCockpitFrame, {
167
+ data: derived.data,
168
+ columns,
169
+ rows,
170
+ });
171
+ }
172
+ return createElement(SetupCockpitFrame, {
173
+ data: derived.data,
174
+ columns,
175
+ rows,
176
+ });
177
+ }
178
+ /**
179
+ * Capture exactly the bytes Ink writes for one static paint. Debug mode is
180
+ * deliberate: it exposes the complete frame rather than terminal patch
181
+ * instructions, so the committed corpus is the renderer's frame output.
182
+ */
183
+ export async function captureRendererOutput(node, options) {
184
+ const sizedNode = isValidElement(node)
185
+ && (node.type === RunCockpitFrame || node.type === SetupCockpitFrame)
186
+ ? cloneElement(node, { rows: options.rows })
187
+ : node;
188
+ const output = new PassThrough();
189
+ output.isTTY = true;
190
+ output.columns = options.columns;
191
+ output.rows = options.rows;
192
+ const writes = [];
193
+ const write = output.write.bind(output);
194
+ output.write = ((chunk, ...args) => {
195
+ writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
196
+ return Reflect.apply(write, output, [chunk, ...args]);
197
+ });
198
+ const previousColourLevel = chalk.level;
199
+ const previousNoColour = process.env.NO_COLOR;
200
+ chalk.level = options.colour ? 3 : 0;
201
+ if (options.colour)
202
+ delete process.env.NO_COLOR;
203
+ else
204
+ process.env.NO_COLOR = "1";
205
+ let painted;
206
+ const firstPaint = new Promise((resolve) => {
207
+ painted = resolve;
208
+ });
209
+ const app = render(sizedNode, {
210
+ stdout: output,
211
+ debug: true,
212
+ patchConsole: false,
213
+ onRender: painted,
214
+ });
215
+ try {
216
+ await firstPaint;
217
+ return writes.at(-1) ?? "";
218
+ }
219
+ finally {
220
+ app.unmount();
221
+ app.cleanup();
222
+ chalk.level = previousColourLevel;
223
+ if (previousNoColour === undefined)
224
+ delete process.env.NO_COLOR;
225
+ else
226
+ process.env.NO_COLOR = previousNoColour;
227
+ }
228
+ }
229
+ /**
230
+ * The single interactive/plain boundary. It decides before mounting Ink, then
231
+ * falls back again if a renderer returns an empty paint.
232
+ */
233
+ export async function captureCockpitOutput(options) {
234
+ const columns = options.columns ?? options.output.columns ?? 80;
235
+ const rows = options.rows ?? options.output.rows ?? 24;
236
+ const interactive = options.interactive
237
+ ?? options.output.isTTY === true;
238
+ const colour = options.colour
239
+ ?? process.env.NO_COLOR === undefined;
240
+ const ci = options.ci ?? process.env.CI === "true";
241
+ const captures = options.captures ?? loadDemoCaptures();
242
+ const plain = () => (options.plainRenderer ?? defaultPlainRenderer)(options.cockpit, options.binaryVersion, captures);
243
+ const layout = resolveCockpitLayout(columns, rows);
244
+ if (!interactive || options.output.isTTY !== true || ci || layout.renderer === "plain") {
245
+ const plainOutput = plain();
246
+ options.output.write(plainOutput);
247
+ return { renderer: "plain", output: plainOutput };
248
+ }
249
+ const derived = derivedData(options.cockpit, options.binaryVersion, captures);
250
+ const frameOutput = await (options.frameRenderer ?? captureRendererOutput)(frameNode(derived, columns, rows), { columns, rows, colour });
251
+ if (frameOutput.length === 0) {
252
+ const plainOutput = plain();
253
+ options.output.write(plainOutput);
254
+ return { renderer: "plain", output: plainOutput };
255
+ }
256
+ options.output.write(frameOutput);
257
+ return { renderer: "frame", output: frameOutput };
258
+ }
259
+ export async function regenerateGoldenFrames(binaryVersion) {
260
+ const resolvedVersion = binaryVersion ?? await version();
261
+ const captures = loadDemoCaptures();
262
+ const regenerated = [];
263
+ for (const golden of GOLDEN_FRAME_CASES) {
264
+ const output = new PassThrough();
265
+ output.isTTY = golden.interactive;
266
+ output.columns = golden.columns;
267
+ output.rows = golden.rows;
268
+ const writes = [];
269
+ const write = output.write.bind(output);
270
+ output.write = ((chunk, ...args) => {
271
+ writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
272
+ return Reflect.apply(write, output, [chunk, ...args]);
273
+ });
274
+ const emission = await captureCockpitOutput({
275
+ cockpit: golden.cockpit,
276
+ output: output,
277
+ binaryVersion: resolvedVersion,
278
+ columns: golden.columns,
279
+ rows: golden.rows,
280
+ interactive: golden.interactive,
281
+ colour: golden.colour,
282
+ ci: golden.ci,
283
+ captures,
284
+ });
285
+ regenerated.push({
286
+ ...golden,
287
+ ...emission,
288
+ emitted: writes.join(""),
289
+ });
290
+ }
291
+ return regenerated;
292
+ }
293
+ export function findGoldenFrameMismatches(regenerated, committed) {
294
+ const expected = new Set(regenerated.map((frame) => frame.fixture));
295
+ return [
296
+ ...regenerated.flatMap((frame) => committed.get(frame.fixture) === frame.output ? [] : [frame.fixture]),
297
+ ...[...committed.keys()].filter((fixture) => !expected.has(fixture)),
298
+ ].sort();
299
+ }
300
+ /**
301
+ * Regenerate only the colour contract corpus from its own manifest. Historical
302
+ * run sources are read and rendered byte-for-byte with their captured daemon
303
+ * stopped, matching the demo path and avoiding live-process state in committed
304
+ * bytes.
305
+ */
306
+ export async function regenerateColourFrames(binaryVersion) {
307
+ const resolvedVersion = binaryVersion ?? await version();
308
+ const regenerated = [];
309
+ for (const colourCase of COLOUR_FRAME_CASES) {
310
+ const source = {
311
+ fileName: colourCase.sourceFileName,
312
+ raw: readFileSync(join(COLOUR_SOURCE_DIRECTORY, colourCase.sourceFileName), "utf8"),
313
+ };
314
+ const data = deriveRunCockpitData(source, resolvedVersion, {
315
+ isDaemonAlive: () => false,
316
+ });
317
+ const output = await captureRendererOutput(frameNode({ cockpit: "run", data }, colourCase.columns, colourCase.rows), {
318
+ columns: colourCase.columns,
319
+ rows: colourCase.rows,
320
+ colour: colourCase.colour,
321
+ });
322
+ regenerated.push({
323
+ ...colourCase,
324
+ output,
325
+ emitted: output,
326
+ });
327
+ }
328
+ return regenerated;
329
+ }