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.
- package/dist/adapters/kimi.js +23 -6
- package/dist/brand.d.ts +4 -0
- package/dist/brand.js +4 -0
- package/dist/cli/commands/ui.d.ts +1 -1
- package/dist/cli/commands/ui.js +45 -4
- package/dist/drivers/herdr.js +28 -6
- package/dist/tui/cockpit/capture.d.ts +117 -0
- package/dist/tui/cockpit/capture.js +329 -0
- package/dist/tui/cockpit/components.d.ts +142 -0
- package/dist/tui/cockpit/components.js +274 -0
- package/dist/tui/cockpit/demo.d.ts +41 -0
- package/dist/tui/cockpit/demo.js +144 -0
- package/dist/tui/cockpit/derive.d.ts +39 -0
- package/dist/tui/cockpit/derive.js +390 -0
- package/dist/tui/cockpit/keys.d.ts +149 -0
- package/dist/tui/cockpit/keys.js +265 -0
- package/dist/tui/cockpit/layout.d.ts +76 -0
- package/dist/tui/cockpit/layout.js +84 -0
- package/dist/tui/cockpit/live.d.ts +31 -0
- package/dist/tui/cockpit/live.js +135 -0
- package/dist/tui/cockpit/run-cockpit.d.ts +26 -0
- package/dist/tui/cockpit/run-cockpit.js +248 -0
- package/dist/tui/cockpit/setup-cockpit.d.ts +44 -0
- package/dist/tui/cockpit/setup-cockpit.js +532 -0
- package/dist/tui/cockpit/theme.d.ts +161 -0
- package/dist/tui/cockpit/theme.js +170 -0
- package/package.json +2 -1
package/dist/adapters/kimi.js
CHANGED
|
@@ -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
|
|
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) =>
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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(
|
|
5
|
+
export declare function ui(argv: string[], io?: Partial<StudioIO>, cwd?: string): Promise<string | {
|
|
6
6
|
out: string;
|
|
7
7
|
code: number;
|
|
8
8
|
}>;
|
package/dist/cli/commands/ui.js
CHANGED
|
@@ -1,11 +1,52 @@
|
|
|
1
|
-
const NON_TTY_MSG = "tickmarkr ui:
|
|
2
|
-
export async function ui(
|
|
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
|
-
|
|
9
|
-
|
|
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
|
}
|
package/dist/drivers/herdr.js
CHANGED
|
@@ -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
|
|
160
|
-
|
|
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
|
|
587
|
-
// the
|
|
588
|
-
//
|
|
589
|
-
|
|
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
|
+
}
|