tickmarkr 1.80.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.
@@ -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,5 +1,5 @@
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) {
@@ -15,7 +15,38 @@ export async function ui(argv, io = {}) {
15
15
  });
16
16
  return "ui: closed";
17
17
  }
18
- const { runStudioInk } = await import("../../tui/ink/studio-app.js");
19
- await runStudioInk({ input, output });
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 });
20
51
  return "ui: closed";
21
52
  }
@@ -19,50 +19,11 @@ export type KeybarItem = {
19
19
  readonly label: string;
20
20
  };
21
21
  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: "⏎";
22
+ readonly run: readonly import("./keys.js").KeybarEntry[];
23
+ readonly setup: readonly [import("./keys.js").KeybarEntry, {
24
+ readonly key: string;
52
25
  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
- }, {
26
+ }, import("./keys.js").KeybarEntry, import("./keys.js").KeybarEntry, import("./keys.js").KeybarEntry, import("./keys.js").KeybarEntry, {
66
27
  readonly key: "␣";
67
28
  readonly label: "Toggle";
68
29
  }, {
@@ -171,9 +132,11 @@ export declare function StatusStrip({ items, width, }: {
171
132
  items: readonly StatusStripItem[];
172
133
  width: number;
173
134
  }): ReactElement;
174
- export declare function JournalRowPanel({ rows, width, focused, title, }: {
135
+ export declare function JournalRowPanel({ rows, width, focused, title, selection, }: {
175
136
  rows: readonly JournalRow[];
176
137
  width?: number | string;
177
138
  focused?: boolean;
178
139
  title?: string;
140
+ /** Index of the row the pointer marks; undefined draws no pointer. */
141
+ selection?: number;
179
142
  }): ReactElement;
@@ -6,22 +6,16 @@ import { GLYPHS } from "../../brand.js";
6
6
  import { COCKPIT_DATA_RAMP, COCKPIT_INKS, TEXT_EMPHASIS_TOKENS, } from "./theme.js";
7
7
  import { SPARKLINE_BUCKET_WINDOW } from "./derive.js";
8
8
  export { SPARKLINE_BUCKET_WINDOW } from "./derive.js";
9
+ import { keybarEntries, RUN_KEY_BINDINGS } from "./keys.js";
9
10
  const SPARKLINE_GLYPHS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
10
11
  const BAND_GAP_COLUMNS = 1;
11
12
  const PANEL_HORIZONTAL_CHROME = 4;
12
13
  const READABLE_PANEL_COLUMNS = 20;
13
14
  export const PANEL_CHROME_ROWS = 3;
14
15
  export const BAND_CONTINUATION_PREFIX = "↳ ";
15
- const RUN_KEYS = [
16
- { key: "↑↓", label: "Move" },
17
- { key: "⏎", label: "Open" },
18
- { key: "←", label: "Back" },
19
- { key: "Tab", label: "Panel" },
20
- { key: "?", label: "Help" },
21
- { key: "q", label: "Quit" },
22
- { key: "f", label: "Follow" },
23
- { key: "/", label: "Filter" },
24
- ];
16
+ // The run keybar is generated from the registered handlers (keys.ts), so a
17
+ // key with no handler cannot be advertised here.
18
+ const RUN_KEYS = keybarEntries(RUN_KEY_BINDINGS);
25
19
  const SETUP_ADDITIONAL_KEYS = [
26
20
  { key: "␣", label: "Toggle" },
27
21
  { key: "a", label: "All" },
@@ -275,6 +269,6 @@ export function Keybar({ surface, width, }) {
275
269
  export function StatusStrip({ items, width, }) {
276
270
  return (_jsx(OneLineItems, { width: width, label: "cockpit status", children: items.map((item, index) => (_jsxs(Box, { flexShrink: 0, children: [index > 0 && _jsx(BodyText, { emphasis: "dim", children: " \u00B7 " }), _jsx(StateGlyph, { state: item.state }), _jsxs(BodyText, { children: [" ", item.text] })] }, `${item.state}:${item.text}`))) }));
277
271
  }
278
- export function JournalRowPanel({ rows, width, focused, title = "JOURNAL", }) {
279
- return (_jsx(Panel, { title: title, focused: focused, width: width, children: rows.map((row, index) => (_jsxs(Box, { flexDirection: "row", flexWrap: "nowrap", height: 1, overflow: "hidden", children: [_jsx(BodyText, { emphasis: "dim", children: row.time }), _jsx(BodyText, { children: " " }), _jsx(StateGlyph, { state: row.state }), _jsxs(BodyText, { children: [" ", row.text] })] }, row.id ?? `${row.time}:${row.state}:${index}`))) }));
272
+ export function JournalRowPanel({ rows, width, focused, title = "JOURNAL", selection, }) {
273
+ return (_jsx(Panel, { title: title, focused: focused, width: width, children: rows.map((row, index) => (_jsxs(Box, { flexDirection: "row", flexWrap: "nowrap", height: 1, overflow: "hidden", children: [_jsx(BodyText, { emphasis: "dim", children: row.time }), _jsx(BodyText, { children: " " }), _jsx(StateGlyph, { state: row.state }), _jsx(BodyText, { children: " " }), index === selection && (_jsxs(BodyText, { emphasis: "strong", children: [GLYPHS.pointer, " "] })), _jsx(BodyText, { children: row.text })] }, row.id ?? `${row.time}:${row.state}:${index}`))) }));
280
274
  }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * THE key contract for the run cockpit. This registry is the single source of
3
+ * truth for both halves of a key: what the keybar advertises and what the key
4
+ * does. The keybar's entries are derived from these bindings (`keybarEntries`),
5
+ * so a key with no registered handler cannot be drawn — an advertised key that
6
+ * does nothing is not expressible.
7
+ */
8
+ /** The subset of an ink key event the run bindings discriminate on. */
9
+ export type RunKeyEvent = {
10
+ readonly input: string;
11
+ readonly key: {
12
+ readonly upArrow?: boolean;
13
+ readonly downArrow?: boolean;
14
+ readonly leftArrow?: boolean;
15
+ readonly return?: boolean;
16
+ readonly tab?: boolean;
17
+ readonly escape?: boolean;
18
+ readonly backspace?: boolean;
19
+ readonly delete?: boolean;
20
+ readonly ctrl?: boolean;
21
+ };
22
+ };
23
+ /**
24
+ * The observable interaction state of the run surface. RunCockpitFrame renders
25
+ * every field of it — selection pointer, panel focus, opened event, follow
26
+ * indicator, filter prompt, help overlay — so the initial state below renders
27
+ * byte-identical to a frame that carries no interaction state at all, and every
28
+ * transition away from it is a change an operator can see.
29
+ */
30
+ export type RunInteractionState = {
31
+ /** The journal row the pointer marks — moved by ↑↓, -1 while nothing is selected. */
32
+ readonly selection: number;
33
+ /** The focused panel, index into RUN_PANEL_FOCUS_ORDER — cycled by Tab. */
34
+ readonly panel: number;
35
+ /** The selection opened by ⏎, or null once ← returns from it. */
36
+ readonly opened: number | null;
37
+ /** Follow mode — toggled by f; drawn as a status-strip indicator while on. */
38
+ readonly follow: boolean;
39
+ /** The / prompt is open and taking filter text. */
40
+ readonly filterPrompt: boolean;
41
+ /** The text the journal is narrowed to — empty shows every row. */
42
+ readonly filterQuery: string;
43
+ /** Help overlay — toggled by ?. */
44
+ readonly help: boolean;
45
+ /** Quit requested by q. */
46
+ readonly quit: boolean;
47
+ };
48
+ export declare function initialRunInteractionState(): RunInteractionState;
49
+ /**
50
+ * The panels Tab cycles focus through, in cycle order; index 0 is the focus
51
+ * the surface opens with, so the initial frame is the one the anchors pin.
52
+ */
53
+ export declare const RUN_PANEL_FOCUS_ORDER: readonly ["RUN", "VIEWS", "KEYS"];
54
+ export type RunKeyBinding = {
55
+ /** The glyph the keybar draws, e.g. "↑↓". */
56
+ readonly key: string;
57
+ /** The promise the keybar draws next to the glyph, e.g. "Move". */
58
+ readonly label: string;
59
+ /** Whether a key event activates this binding. */
60
+ readonly matches: (event: RunKeyEvent) => boolean;
61
+ /** The state transition the label promises. */
62
+ readonly apply: (state: RunInteractionState, event: RunKeyEvent) => RunInteractionState;
63
+ /**
64
+ * How a binding that carries on/off state reports it — the brand's reserved
65
+ * toggle glyphs, never bracket toggles. Present exactly on the bindings
66
+ * whose state is on/off.
67
+ */
68
+ readonly report?: (state: RunInteractionState) => string;
69
+ };
70
+ /** A toggle's on/off report: the brand's reserved glyphs, never brackets. */
71
+ export declare function toggleReport(label: string, on: boolean): string;
72
+ /**
73
+ * Every key the run surface advertises, in keybar order. Each entry carries
74
+ * its handler, so advertisement and behaviour cannot drift apart.
75
+ */
76
+ export declare const RUN_KEY_BINDINGS: readonly RunKeyBinding[];
77
+ /** The panels the setup surface cycles through, in their painted order. */
78
+ export declare const SETUP_PANEL_FOCUS_ORDER: readonly ["SETUP", "HARNESSES", "KEYS"];
79
+ /**
80
+ * The observable state owned by setup's bindings. Its initial value is the
81
+ * already-pinned setup frame: step two, the harness body focused, nothing
82
+ * selected, no modal action open. Every handler moves away from that state in
83
+ * a way SetupCockpitFrame paints.
84
+ */
85
+ export type SetupInteractionState = {
86
+ readonly selection: number;
87
+ readonly panel: number;
88
+ readonly step: number;
89
+ readonly selected: readonly number[];
90
+ readonly all: boolean;
91
+ readonly probeRevision: number;
92
+ readonly savePrompt: boolean;
93
+ readonly saved: boolean;
94
+ readonly help: boolean;
95
+ readonly quit: boolean;
96
+ };
97
+ export declare function initialSetupInteractionState(): SetupInteractionState;
98
+ export type SetupKeyBinding = {
99
+ readonly key: string;
100
+ readonly label: string;
101
+ readonly matches: (event: RunKeyEvent) => boolean;
102
+ readonly apply: (state: SetupInteractionState, event: RunKeyEvent) => SetupInteractionState;
103
+ readonly report?: (state: SetupInteractionState) => string;
104
+ };
105
+ /**
106
+ * Every setup advertisement and handler, in the exact order the surface has
107
+ * always drawn. The global six are registered here with setup semantics; the
108
+ * six setup-only bindings follow them on the same surface registry.
109
+ */
110
+ export declare const SETUP_KEY_BINDINGS: readonly SetupKeyBinding[];
111
+ /** One surface-aware registry serves both cockpit renderers and dispatchers. */
112
+ export declare const SURFACE_KEY_BINDINGS: {
113
+ readonly run: readonly RunKeyBinding[];
114
+ readonly setup: readonly SetupKeyBinding[];
115
+ };
116
+ /** Save confirmation owns input until it is accepted or dismissed. */
117
+ export declare function applySetupPromptInput(event: RunKeyEvent, state: SetupInteractionState): SetupInteractionState | undefined;
118
+ /**
119
+ * The transitions of the open / prompt. The prompt is not an advertised key,
120
+ * so it has no binding — but its transitions live here beside the registry
121
+ * because they rewrite the same interaction state: characters grow the query,
122
+ * backspace shrinks it, ⏎ applies it (the journal stays narrowed), escape
123
+ * cancels it. Anything else the prompt swallows unchanged.
124
+ */
125
+ export declare function applyFilterInput(event: RunKeyEvent, state: RunInteractionState): RunInteractionState | undefined;
126
+ /** What an advertisement looks like once the handler is stripped away. */
127
+ export type KeybarEntry = {
128
+ readonly key: string;
129
+ readonly label: string;
130
+ };
131
+ /**
132
+ * The keybar's entries, derived from a binding registry. This is the only way
133
+ * a keybar list is produced: remove a handler and its entry disappears.
134
+ */
135
+ export declare function keybarEntries(bindings: readonly {
136
+ readonly key: string;
137
+ readonly label: string;
138
+ }[]): readonly KeybarEntry[];
139
+ /** The binding a key event activates, or undefined when no handler owns it. */
140
+ export declare function resolveRunKeyBinding(bindings: readonly RunKeyBinding[], event: RunKeyEvent): RunKeyBinding | undefined;
141
+ /**
142
+ * Send a key event to the registry: the matching binding's handler produces
143
+ * the next interaction state. An event no handler owns changes nothing.
144
+ */
145
+ export declare function dispatchRunKey(event: RunKeyEvent, state: RunInteractionState, bindings?: readonly RunKeyBinding[]): RunInteractionState | undefined;
146
+ /** The setup binding a key event activates, or undefined when none owns it. */
147
+ export declare function resolveSetupKeyBinding(bindings: readonly SetupKeyBinding[], event: RunKeyEvent): SetupKeyBinding | undefined;
148
+ /** Send a key event through the setup half of the shared surface registry. */
149
+ export declare function dispatchSetupKey(event: RunKeyEvent, state: SetupInteractionState, bindings?: readonly SetupKeyBinding[]): SetupInteractionState | undefined;
@@ -0,0 +1,265 @@
1
+ import { GLYPHS } from "../../brand.js";
2
+ export function initialRunInteractionState() {
3
+ return {
4
+ selection: -1,
5
+ panel: 0,
6
+ opened: null,
7
+ follow: false,
8
+ filterPrompt: false,
9
+ filterQuery: "",
10
+ help: false,
11
+ quit: false,
12
+ };
13
+ }
14
+ /**
15
+ * The panels Tab cycles focus through, in cycle order; index 0 is the focus
16
+ * the surface opens with, so the initial frame is the one the anchors pin.
17
+ */
18
+ export const RUN_PANEL_FOCUS_ORDER = ["RUN", "VIEWS", "KEYS"];
19
+ /** A toggle's on/off report: the brand's reserved glyphs, never brackets. */
20
+ export function toggleReport(label, on) {
21
+ return `${label} ${on ? GLYPHS.toggleActive : GLYPHS.toggleInactive}`;
22
+ }
23
+ /**
24
+ * Every key the run surface advertises, in keybar order. Each entry carries
25
+ * its handler, so advertisement and behaviour cannot drift apart.
26
+ */
27
+ export const RUN_KEY_BINDINGS = [
28
+ {
29
+ key: "↑↓",
30
+ label: "Move",
31
+ matches: ({ key }) => key.upArrow === true || key.downArrow === true,
32
+ apply: (state, { key }) => ({
33
+ ...state,
34
+ selection: Math.max(-1, state.selection + (key.upArrow === true ? -1 : 1)),
35
+ }),
36
+ },
37
+ {
38
+ key: "⏎",
39
+ label: "Open",
40
+ matches: ({ key }) => key.return === true,
41
+ apply: (state) => state.selection >= 0 ? { ...state, opened: state.selection } : state,
42
+ },
43
+ {
44
+ key: "←",
45
+ label: "Back",
46
+ matches: ({ key }) => key.leftArrow === true,
47
+ apply: (state) => ({ ...state, opened: null }),
48
+ },
49
+ {
50
+ key: "Tab",
51
+ label: "Panel",
52
+ matches: ({ key }) => key.tab === true,
53
+ apply: (state) => ({
54
+ ...state,
55
+ panel: (state.panel + 1) % RUN_PANEL_FOCUS_ORDER.length,
56
+ }),
57
+ },
58
+ {
59
+ key: "?",
60
+ label: "Help",
61
+ matches: ({ input }) => input === "?",
62
+ apply: (state) => ({ ...state, help: !state.help }),
63
+ },
64
+ {
65
+ key: "q",
66
+ label: "Quit",
67
+ matches: ({ input }) => input === "q",
68
+ apply: (state) => ({ ...state, quit: true }),
69
+ },
70
+ {
71
+ key: "f",
72
+ label: "Follow",
73
+ matches: ({ input }) => input === "f",
74
+ apply: (state) => ({ ...state, follow: !state.follow }),
75
+ report: (state) => toggleReport("Follow", state.follow),
76
+ },
77
+ {
78
+ key: "/",
79
+ label: "Filter",
80
+ matches: ({ input }) => input === "/",
81
+ apply: (state) => ({ ...state, filterPrompt: true, filterQuery: "" }),
82
+ },
83
+ ];
84
+ /** The panels the setup surface cycles through, in their painted order. */
85
+ export const SETUP_PANEL_FOCUS_ORDER = ["SETUP", "HARNESSES", "KEYS"];
86
+ export function initialSetupInteractionState() {
87
+ return {
88
+ selection: -1,
89
+ panel: 1,
90
+ step: 2,
91
+ selected: [],
92
+ all: false,
93
+ probeRevision: 0,
94
+ savePrompt: false,
95
+ saved: false,
96
+ help: false,
97
+ quit: false,
98
+ };
99
+ }
100
+ function setupStep(state, delta) {
101
+ return { ...state, step: Math.min(6, Math.max(1, state.step + delta)) };
102
+ }
103
+ /**
104
+ * Every setup advertisement and handler, in the exact order the surface has
105
+ * always drawn. The global six are registered here with setup semantics; the
106
+ * six setup-only bindings follow them on the same surface registry.
107
+ */
108
+ export const SETUP_KEY_BINDINGS = [
109
+ {
110
+ key: "↑↓",
111
+ label: "Move",
112
+ matches: ({ key }) => key.upArrow === true || key.downArrow === true,
113
+ apply: (state, { key }) => ({
114
+ ...state,
115
+ selection: key.upArrow === true
116
+ ? Math.max(0, state.selection - 1)
117
+ : state.selection + 1,
118
+ }),
119
+ },
120
+ {
121
+ key: "⏎",
122
+ label: "Next",
123
+ matches: ({ key }) => key.return === true,
124
+ apply: (state) => setupStep(state, 1),
125
+ },
126
+ {
127
+ key: "←",
128
+ label: "Back",
129
+ matches: ({ key }) => key.leftArrow === true,
130
+ apply: (state) => setupStep(state, -1),
131
+ },
132
+ {
133
+ key: "Tab",
134
+ label: "Panel",
135
+ matches: ({ key }) => key.tab === true,
136
+ apply: (state) => ({
137
+ ...state,
138
+ panel: (state.panel + 1) % SETUP_PANEL_FOCUS_ORDER.length,
139
+ }),
140
+ },
141
+ {
142
+ key: "?",
143
+ label: "Help",
144
+ matches: ({ input }) => input === "?",
145
+ apply: (state) => ({ ...state, help: !state.help }),
146
+ },
147
+ {
148
+ key: "q",
149
+ label: "Quit",
150
+ matches: ({ input }) => input === "q",
151
+ apply: (state) => ({ ...state, quit: true }),
152
+ },
153
+ {
154
+ key: "␣",
155
+ label: "Toggle",
156
+ matches: ({ input }) => input === " ",
157
+ apply: (state) => {
158
+ const selection = Math.max(0, state.selection);
159
+ const selected = state.selected.includes(selection)
160
+ ? state.selected.filter((index) => index !== selection)
161
+ : [...state.selected, selection];
162
+ return { ...state, selection, selected, all: false };
163
+ },
164
+ report: (state) => toggleReport("Selection", state.all || state.selected.length > 0),
165
+ },
166
+ {
167
+ key: "a",
168
+ label: "All",
169
+ matches: ({ input }) => input === "a",
170
+ apply: (state) => ({ ...state, all: true, selected: [] }),
171
+ },
172
+ {
173
+ key: "r",
174
+ label: "Re-probe",
175
+ matches: ({ input }) => input === "r",
176
+ apply: (state) => ({ ...state, probeRevision: state.probeRevision + 1 }),
177
+ },
178
+ {
179
+ key: "s",
180
+ label: "Save",
181
+ matches: ({ input }) => input === "s",
182
+ apply: (state) => ({ ...state, savePrompt: true }),
183
+ report: (state) => toggleReport("Saved", state.saved),
184
+ },
185
+ {
186
+ key: "n",
187
+ label: "Next",
188
+ matches: ({ input }) => input === "n",
189
+ apply: (state) => setupStep(state, 1),
190
+ },
191
+ {
192
+ key: "p",
193
+ label: "Prev",
194
+ matches: ({ input }) => input === "p",
195
+ apply: (state) => setupStep(state, -1),
196
+ },
197
+ ];
198
+ /** One surface-aware registry serves both cockpit renderers and dispatchers. */
199
+ export const SURFACE_KEY_BINDINGS = {
200
+ run: RUN_KEY_BINDINGS,
201
+ setup: SETUP_KEY_BINDINGS,
202
+ };
203
+ /** Save confirmation owns input until it is accepted or dismissed. */
204
+ export function applySetupPromptInput(event, state) {
205
+ if (!state.savePrompt)
206
+ return undefined;
207
+ if (event.input === "y" || event.key.return === true) {
208
+ return { ...state, savePrompt: false, saved: true };
209
+ }
210
+ if (event.input === "n" || event.key.escape === true) {
211
+ return { ...state, savePrompt: false };
212
+ }
213
+ return state;
214
+ }
215
+ /**
216
+ * The transitions of the open / prompt. The prompt is not an advertised key,
217
+ * so it has no binding — but its transitions live here beside the registry
218
+ * because they rewrite the same interaction state: characters grow the query,
219
+ * backspace shrinks it, ⏎ applies it (the journal stays narrowed), escape
220
+ * cancels it. Anything else the prompt swallows unchanged.
221
+ */
222
+ export function applyFilterInput(event, state) {
223
+ if (!state.filterPrompt)
224
+ return undefined;
225
+ const { input, key } = event;
226
+ if (key.return === true)
227
+ return { ...state, filterPrompt: false };
228
+ if (key.escape === true) {
229
+ return { ...state, filterPrompt: false, filterQuery: "" };
230
+ }
231
+ if (key.backspace === true || key.delete === true) {
232
+ return { ...state, filterQuery: [...state.filterQuery].slice(0, -1).join("") };
233
+ }
234
+ if (input.length === 1 && key.ctrl !== true) {
235
+ return { ...state, filterQuery: state.filterQuery + input };
236
+ }
237
+ return state;
238
+ }
239
+ /**
240
+ * The keybar's entries, derived from a binding registry. This is the only way
241
+ * a keybar list is produced: remove a handler and its entry disappears.
242
+ */
243
+ export function keybarEntries(bindings) {
244
+ return bindings.map(({ key, label }) => ({ key, label }));
245
+ }
246
+ /** The binding a key event activates, or undefined when no handler owns it. */
247
+ export function resolveRunKeyBinding(bindings, event) {
248
+ return bindings.find((binding) => binding.matches(event));
249
+ }
250
+ /**
251
+ * Send a key event to the registry: the matching binding's handler produces
252
+ * the next interaction state. An event no handler owns changes nothing.
253
+ */
254
+ export function dispatchRunKey(event, state, bindings = RUN_KEY_BINDINGS) {
255
+ const binding = resolveRunKeyBinding(bindings, event);
256
+ return binding?.apply(state, event);
257
+ }
258
+ /** The setup binding a key event activates, or undefined when none owns it. */
259
+ export function resolveSetupKeyBinding(bindings, event) {
260
+ return bindings.find((binding) => binding.matches(event));
261
+ }
262
+ /** Send a key event through the setup half of the shared surface registry. */
263
+ export function dispatchSetupKey(event, state, bindings = SETUP_KEY_BINDINGS) {
264
+ return resolveSetupKeyBinding(bindings, event)?.apply(state, event);
265
+ }
@@ -0,0 +1,31 @@
1
+ import type { RunCockpitData, RunCockpitSource } from "./derive.js";
2
+ /**
3
+ * THE engagement selection rule — stated once, here, so it can change in one
4
+ * place without touching the renderer or the command: an explicit engagement
5
+ * reference from the command line wins; the bare command opens the most
6
+ * recently started engagement that has a readable journal.
7
+ */
8
+ export declare function selectEngagementRunId(cwd: string, explicit?: string): string | null;
9
+ /**
10
+ * The journal bytes of a real engagement — never a committed capture. A read
11
+ * failure throws: the caller refuses rather than drawing from nothing.
12
+ */
13
+ export declare function loadEngagementSource(cwd: string, runId: string): RunCockpitSource;
14
+ export declare function formatEventAge(ageMs: number): string;
15
+ /**
16
+ * Live derivation over a real engagement's journal bytes. The header's
17
+ * elapsed field additionally states how long ago the engagement's last event
18
+ * arrived, so an engagement that has stopped moving reads as stale rather
19
+ * than as current. Throws on unreadable bytes (empty capture, no run-start) —
20
+ * the caller refuses instead of rendering a plausible surface from nothing.
21
+ */
22
+ export declare function deriveLiveRunCockpitData(source: RunCockpitSource, binaryVersion: string, now?: () => number): RunCockpitData;
23
+ export declare function runLiveCockpit({ input, output, cwd, runId, binaryVersion, refreshMs, now, }: {
24
+ input: NodeJS.ReadStream;
25
+ output: NodeJS.WriteStream;
26
+ cwd: string;
27
+ runId: string;
28
+ binaryVersion: string;
29
+ refreshMs?: number;
30
+ now?: () => number;
31
+ }): Promise<void>;
@@ -0,0 +1,135 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { render, useApp, useInput } from "ink";
4
+ import { createElement, useEffect, useState } from "react";
5
+ import { stateDirName } from "../../graph/graph.js";
6
+ import { Journal, parseRunId } from "../../run/journal.js";
7
+ import { deriveRunCockpitData, RunCockpitFrame } from "./run-cockpit.js";
8
+ import { applyFilterInput, dispatchRunKey, initialRunInteractionState, SURFACE_KEY_BINDINGS, } from "./keys.js";
9
+ // ponytail: fixed 2s re-derive cadence, matching `status --watch`; promote to a
10
+ // config knob only if an operator asks.
11
+ const REFRESH_MS = 2_000;
12
+ /**
13
+ * THE engagement selection rule — stated once, here, so it can change in one
14
+ * place without touching the renderer or the command: an explicit engagement
15
+ * reference from the command line wins; the bare command opens the most
16
+ * recently started engagement that has a readable journal.
17
+ */
18
+ export function selectEngagementRunId(cwd, explicit) {
19
+ if (explicit !== undefined)
20
+ return parseRunId(explicit);
21
+ return Journal.latestRunId(cwd, { withJournal: true });
22
+ }
23
+ /**
24
+ * The journal bytes of a real engagement — never a committed capture. A read
25
+ * failure throws: the caller refuses rather than drawing from nothing.
26
+ */
27
+ export function loadEngagementSource(cwd, runId) {
28
+ const id = parseRunId(runId);
29
+ const path = join(cwd, stateDirName(cwd), "runs", id, "journal.jsonl");
30
+ const raw = readFileSync(path, "utf8");
31
+ return { fileName: `${id}.journal.jsonl`, raw };
32
+ }
33
+ // The same s/m/h age vocabulary `status` speaks (status.ts fmtAge).
34
+ export function formatEventAge(ageMs) {
35
+ const safe = Math.max(0, ageMs);
36
+ if (safe < 90_000)
37
+ return `${Math.floor(safe / 1_000)}s`;
38
+ if (safe < 5_400_000)
39
+ return `${Math.floor(safe / 60_000)}m`;
40
+ return `${Math.floor(safe / 3_600_000)}h`;
41
+ }
42
+ function lastEventMs(raw) {
43
+ const lines = raw.split("\n");
44
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
45
+ const line = lines[index].trim();
46
+ if (!line)
47
+ continue;
48
+ try {
49
+ const parsed = JSON.parse(line);
50
+ const ms = typeof parsed.ts === "string" ? Date.parse(parsed.ts) : NaN;
51
+ if (Number.isFinite(ms))
52
+ return ms;
53
+ }
54
+ catch {
55
+ // a torn trailing line does not hide the event written before it
56
+ }
57
+ }
58
+ return undefined;
59
+ }
60
+ /**
61
+ * Live derivation over a real engagement's journal bytes. The header's
62
+ * elapsed field additionally states how long ago the engagement's last event
63
+ * arrived, so an engagement that has stopped moving reads as stale rather
64
+ * than as current. Throws on unreadable bytes (empty capture, no run-start) —
65
+ * the caller refuses instead of rendering a plausible surface from nothing.
66
+ */
67
+ export function deriveLiveRunCockpitData(source, binaryVersion, now = Date.now) {
68
+ const data = deriveRunCockpitData(source, binaryVersion);
69
+ const lastMs = lastEventMs(source.raw);
70
+ const staleness = lastMs === undefined
71
+ ? "last event unknown"
72
+ : `last event ${formatEventAge(now() - lastMs)} ago`;
73
+ return { ...data, elapsed: `${data.elapsed} · ${staleness}` };
74
+ }
75
+ function LiveApp({ cwd, runId, binaryVersion, columns, rows, refreshMs, now, }) {
76
+ const { exit } = useApp();
77
+ const [data, setData] = useState(() => deriveLiveRunCockpitData(loadEngagementSource(cwd, runId), binaryVersion, now));
78
+ const [interaction, setInteraction] = useState(initialRunInteractionState);
79
+ useEffect(() => {
80
+ const timer = setInterval(() => {
81
+ try {
82
+ setData(deriveLiveRunCockpitData(loadEngagementSource(cwd, runId), binaryVersion, now));
83
+ }
84
+ catch {
85
+ // a torn read mid-append keeps the last good frame rather than blanking the surface
86
+ }
87
+ }, refreshMs);
88
+ return () => clearInterval(timer);
89
+ }, [cwd, runId, binaryVersion, refreshMs, now]);
90
+ useInput((input, key) => {
91
+ if (key.ctrl && input === "c") {
92
+ exit();
93
+ return;
94
+ }
95
+ // While the / prompt is open it owns the keyboard: characters grow the
96
+ // query and no advertised binding fires.
97
+ if (interaction.filterPrompt) {
98
+ const prompted = applyFilterInput({ input, key }, interaction);
99
+ if (prompted !== undefined)
100
+ setInteraction(prompted);
101
+ return;
102
+ }
103
+ const next = dispatchRunKey({ input, key }, interaction, SURFACE_KEY_BINDINGS.run);
104
+ if (next === undefined)
105
+ return;
106
+ setInteraction(next);
107
+ if (next.quit)
108
+ exit();
109
+ });
110
+ return createElement(RunCockpitFrame, { data, columns, rows, interaction });
111
+ }
112
+ export async function runLiveCockpit({ input, output, cwd, runId, binaryVersion, refreshMs = REFRESH_MS, now = Date.now, }) {
113
+ const columns = output.columns ?? 80;
114
+ const rows = output.rows ?? 24;
115
+ const app = render(createElement(LiveApp, {
116
+ cwd,
117
+ runId,
118
+ binaryVersion,
119
+ columns,
120
+ rows,
121
+ refreshMs,
122
+ now,
123
+ }), {
124
+ stdin: input,
125
+ stdout: output,
126
+ exitOnCtrlC: false,
127
+ patchConsole: false,
128
+ });
129
+ try {
130
+ await app.waitUntilExit();
131
+ }
132
+ finally {
133
+ app.unmount();
134
+ }
135
+ }
@@ -1,5 +1,6 @@
1
1
  import { type ReactElement } from "react";
2
2
  import type { RunCockpitData } from "./derive.js";
3
+ import { type RunInteractionState } from "./keys.js";
3
4
  export { deriveRunCockpitData } from "./derive.js";
4
5
  export type { RunCockpitData } from "./derive.js";
5
6
  export { PANEL_CHROME_ROWS } from "./components.js";
@@ -12,8 +13,14 @@ export { PANEL_CHROME_ROWS } from "./components.js";
12
13
  export declare function wrappedRows(text: string, columns: number): number;
13
14
  /** The rows a bordered panel occupies when its body wraps into `columns`. */
14
15
  export declare function panelRows(lines: readonly string[], columns: number): number;
15
- export declare function RunCockpitFrame({ data, columns, rows, }: {
16
+ export declare function RunCockpitFrame({ data, columns, rows, interaction, }: {
16
17
  data: RunCockpitData;
17
18
  columns: number;
18
19
  rows?: number;
20
+ /**
21
+ * The run surface's interaction state. Omitted — or at its initial value —
22
+ * the frame is byte-identical to the pinned appearance; every transition a
23
+ * binding makes is drawn from it.
24
+ */
25
+ interaction?: RunInteractionState;
19
26
  }): ReactElement;
@@ -3,6 +3,7 @@ import { Box } from "ink";
3
3
  import { cloneElement, } from "react";
4
4
  import { GLYPHS, PLAIN_COMPACT_LOCKUP, } from "../../brand.js";
5
5
  import { allocateBandColumns, BandLines, BodyText, CockpitGrid, composeBandLine, JournalRowPanel, KEYBAR_KEYS, Keybar, Panel, PANEL_CHROME_ROWS, ProgressMeter, StatTile, StatusStrip, } from "./components.js";
6
+ import { RUN_PANEL_FOCUS_ORDER, RUN_KEY_BINDINGS, } from "./keys.js";
6
7
  import { resolveCockpitLayout, } from "./layout.js";
7
8
  export { deriveRunCockpitData } from "./derive.js";
8
9
  export { PANEL_CHROME_ROWS } from "./components.js";
@@ -88,19 +89,61 @@ function NarrowHeaderBand({ columns, content, rightStrong = false, }) {
88
89
  const widths = allocateBandColumns(columns, content, NARROW_HEADER_ALLOCATION);
89
90
  return (_jsxs(CockpitGrid, { columns: columns, columnContents: content, allocation: NARROW_HEADER_ALLOCATION, children: [_jsx(BandLines, { columns: Math.max(1, widths[0]), lines: content[0].lines, emphasis: "strong" }), _jsx(Box, { alignItems: "flex-end", flexDirection: "column", children: _jsx(BandLines, { columns: Math.max(1, widths[1]), lines: content[1].lines, emphasis: rightStrong ? "strong" : "normal" }) })] }));
90
91
  }
91
- function NavigationPanel() {
92
- return (_jsxs(Panel, { title: "VIEWS", width: 16, children: [_jsxs(BodyText, { emphasis: "strong", children: [GLYPHS.pointer, " Run"] }), _jsx(BodyText, { children: " Tasks" }), _jsx(BodyText, { children: " Gates" }), _jsx(BodyText, { children: " Journal" }), _jsx(BodyText, { children: " Fleet" })] }));
92
+ function NavigationPanel({ focused = false }) {
93
+ return (_jsxs(Panel, { title: "VIEWS", width: 16, focused: focused, children: [_jsxs(BodyText, { emphasis: "strong", children: [GLYPHS.pointer, " Run"] }), _jsx(BodyText, { children: " Tasks" }), _jsx(BodyText, { children: " Gates" }), _jsx(BodyText, { children: " Journal" }), _jsx(BodyText, { children: " Fleet" })] }));
93
94
  }
94
- function KeysPanel() {
95
- return (_jsx(Panel, { title: "KEYS", width: 16, children: KEYBAR_KEYS.run.map((item) => (_jsxs(Box, { children: [_jsx(BodyText, { emphasis: "strong", children: item.key }), _jsxs(BodyText, { children: [" ", item.label] })] }, item.key))) }));
95
+ function KeysPanel({ focused = false }) {
96
+ return (_jsx(Panel, { title: "KEYS", width: 16, focused: focused, children: KEYBAR_KEYS.run.map((item) => (_jsxs(Box, { children: [_jsx(BodyText, { emphasis: "strong", children: item.key }), _jsxs(BodyText, { children: [" ", item.label] })] }, item.key))) }));
96
97
  }
97
- function SizedJournalPanel({ rows, bodyRows, }) {
98
+ /** The journal rows an interaction state leaves visible. */
99
+ function visibleJournalRows(rows, interaction) {
100
+ const query = interaction?.filterQuery.trim().toLowerCase() ?? "";
101
+ if (query === "")
102
+ return rows;
103
+ return rows.filter((row) => row.text.toLowerCase().includes(query));
104
+ }
105
+ function SizedJournalPanel({ rows, bodyRows, title, selection, }) {
98
106
  const visible = rows.slice(0, bodyRows);
99
107
  const emptyRows = Math.max(0, bodyRows - visible.length);
100
- const journal = JournalRowPanel({ rows: visible });
108
+ const journal = JournalRowPanel({ rows: visible, title, selection });
101
109
  const journalProps = journal.props;
102
110
  return cloneElement(journal, {}, journalProps.children, emptyRows > 0 ? _jsx(Box, { height: emptyRows }, "journal-space") : null);
103
111
  }
112
+ /** A body panel (help overlay, opened event) padded to the journal's rows. */
113
+ function SizedLinesPanel({ title, lines, bodyRows, }) {
114
+ const visible = lines.slice(0, bodyRows);
115
+ const emptyRows = Math.max(0, bodyRows - visible.length);
116
+ return (_jsxs(Panel, { title: title, children: [visible.map((line, index) => (_jsx(BodyText, { children: line }, `${index}:${line}`))), emptyRows > 0 && _jsx(Box, { height: emptyRows })] }));
117
+ }
118
+ /** The lines the help overlay draws: every advertised key and its promise. */
119
+ function helpLines() {
120
+ return RUN_KEY_BINDINGS.map((binding) => `${binding.key} ${binding.label}`);
121
+ }
122
+ /** The lines the opened-event view draws for one journal row. */
123
+ function eventLines(row) {
124
+ return [`${row.time} · ${row.state}`, row.text];
125
+ }
126
+ /**
127
+ * What the journal's slot of the RUN panel draws under an interaction state:
128
+ * the help overlay while ? is up, the opened event while ⏎'s choice stands,
129
+ * otherwise the journal itself — narrowed by the filter, marked at the
130
+ * selection, titled with the open / prompt.
131
+ */
132
+ function JournalBody({ data, interaction, bodyRows, }) {
133
+ const rows = visibleJournalRows(data.journalRows, interaction);
134
+ if (interaction?.help === true) {
135
+ return _jsx(SizedLinesPanel, { title: "HELP", lines: helpLines(), bodyRows: bodyRows });
136
+ }
137
+ const opened = interaction?.opened ?? null;
138
+ if (opened !== null && opened < rows.length) {
139
+ return (_jsx(SizedLinesPanel, { title: "EVENT", lines: eventLines(rows[opened]), bodyRows: bodyRows }));
140
+ }
141
+ return (_jsx(SizedJournalPanel, { rows: rows, bodyRows: bodyRows, title: interaction?.filterPrompt === true
142
+ ? `JOURNAL /${interaction.filterQuery}`
143
+ : undefined, selection: interaction === undefined || interaction.selection < 0
144
+ ? undefined
145
+ : interaction.selection }));
146
+ }
104
147
  function RunStats({ data, mode, }) {
105
148
  if (mode === "summary") {
106
149
  return (_jsx(BodyText, { children: `tasks ${data.tasks.done}/${data.tasks.total} · gates ${data.gates.passed}/${data.gates.total} · pass ${data.passRate.value}%` }));
@@ -154,7 +197,7 @@ function compactJournalRows(rows, layout, data, columns) {
154
197
  + 3; // journal border and title
155
198
  return Math.max(1, Math.min(layout.journalRows, Math.floor(rows) - fixedRows));
156
199
  }
157
- function LadderRunPanel({ data, layout, columns, rows, compactMeterWidth, }) {
200
+ function LadderRunPanel({ data, layout, columns, rows, compactMeterWidth, interaction, focused = true, }) {
158
201
  const compactBodyRows = compactJournalRows(rows, layout, data, columns);
159
202
  // The approved bordered progress panel has the same meter/caption as the
160
203
  // compact form plus its border, title row and closing border. It is restored
@@ -169,16 +212,27 @@ function LadderRunPanel({ data, layout, columns, rows, compactMeterWidth, }) {
169
212
  const journalBodyRows = approvedArrangement
170
213
  ? compactBodyRows - approvedProgressChromeRows
171
214
  : compactBodyRows;
172
- return (_jsxs(Panel, { title: "RUN", focused: true, flexGrow: 1, children: [_jsx(RunStats, { data: data, mode: layout.stats.mode }), layout.elements.progressBar && (approvedArrangement
215
+ return (_jsxs(Panel, { title: "RUN", focused: focused, flexGrow: 1, children: [_jsx(RunStats, { data: data, mode: layout.stats.mode }), layout.elements.progressBar && (approvedArrangement
173
216
  ? (_jsx(ApprovedProgress, { data: data, caption: layout.elements.progressCaption }))
174
- : (_jsx(CompactProgress, { data: data, caption: layout.elements.progressCaption, meterWidth: compactMeterWidth }))), _jsx(SizedJournalPanel, { rows: data.journalRows, bodyRows: journalBodyRows })] }));
175
- }
176
- function LegacyRunPanel({ data }) {
177
- return (_jsxs(Panel, { title: "RUN", focused: true, flexGrow: 1, children: [_jsx(RunStats, { data: data, mode: "tiles" }), _jsxs(Panel, { title: "PROGRESS", children: [_jsx(ProgressMeter, { value: data.progress, width: 28 }), _jsx(BodyText, { emphasis: "dim", children: data.progressCaption })] }), _jsx(JournalRowPanel, { rows: data.journalRows })] }));
178
- }
179
- export function RunCockpitFrame({ data, columns, rows, }) {
217
+ : (_jsx(CompactProgress, { data: data, caption: layout.elements.progressCaption, meterWidth: compactMeterWidth }))), _jsx(JournalBody, { data: data, interaction: interaction, bodyRows: journalBodyRows })] }));
218
+ }
219
+ function LegacyRunPanel({ data, interaction, focused = true, }) {
220
+ const rows = visibleJournalRows(data.journalRows, interaction);
221
+ return (_jsxs(Panel, { title: "RUN", focused: focused, flexGrow: 1, children: [_jsx(RunStats, { data: data, mode: "tiles" }), _jsxs(Panel, { title: "PROGRESS", children: [_jsx(ProgressMeter, { value: data.progress, width: 28 }), _jsx(BodyText, { emphasis: "dim", children: data.progressCaption })] }), _jsx(JournalBody, { data: data, interaction: interaction, bodyRows: rows.length })] }));
222
+ }
223
+ export function RunCockpitFrame({ data, columns, rows, interaction, }) {
224
+ const focus = interaction === undefined
225
+ ? 0
226
+ : interaction.panel % RUN_PANEL_FOCUS_ORDER.length;
227
+ const focusedPanel = RUN_PANEL_FOCUS_ORDER[focus] ?? "RUN";
228
+ // Follow mode reports its on state as a status-strip item carrying the
229
+ // brand's reserved active-toggle glyph; off it draws nothing, so the pinned
230
+ // frame is untouched.
231
+ const statusItems = interaction?.follow === true
232
+ ? [...data.statusItems, { state: "active", text: "Follow" }]
233
+ : data.statusItems;
180
234
  if (rows === undefined) {
181
- return (_jsxs(Box, { flexDirection: "column", width: columns, children: [_jsx(IdentityHeader, { data: data, columns: columns }), _jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(NavigationPanel, {}), _jsx(LegacyRunPanel, { data: data }), _jsx(KeysPanel, {})] }), _jsx(StatusStrip, { items: data.statusItems, width: columns }), _jsx(Keybar, { surface: "run", width: columns })] }));
235
+ return (_jsxs(Box, { flexDirection: "column", width: columns, children: [_jsx(IdentityHeader, { data: data, columns: columns }), _jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(NavigationPanel, { focused: focusedPanel === "VIEWS" }), _jsx(LegacyRunPanel, { data: data, interaction: interaction, focused: focusedPanel === "RUN" }), _jsx(KeysPanel, { focused: focusedPanel === "KEYS" })] }), _jsx(StatusStrip, { items: statusItems, width: columns }), _jsx(Keybar, { surface: "run", width: columns })] }));
182
236
  }
183
237
  const layout = resolveCockpitLayout(columns, rows);
184
238
  if (layout.renderer === "plain")
@@ -190,5 +244,5 @@ export function RunCockpitFrame({ data, columns, rows, }) {
190
244
  ...layout,
191
245
  stats: { ...layout.stats, mode: "summary", rows: 1 },
192
246
  };
193
- return (_jsxs(Box, { flexDirection: "column", width: columns, children: [_jsx(IdentityHeader, { data: data, columns: columns, secondary: layout.elements.secondaryHeader }), _jsxs(Box, { flexDirection: "row", gap: showSideRails ? 1 : 0, children: [showSideRails && _jsx(NavigationPanel, {}), _jsx(LadderRunPanel, { data: data, layout: contentLayout, columns: columns, rows: rows, compactMeterWidth: showSideRails ? undefined : 24 }), showSideRails && _jsx(KeysPanel, {})] }), _jsx(StatusStrip, { items: data.statusItems, width: columns }), _jsx(Keybar, { surface: "run", width: columns })] }));
247
+ return (_jsxs(Box, { flexDirection: "column", width: columns, children: [_jsx(IdentityHeader, { data: data, columns: columns, secondary: layout.elements.secondaryHeader }), _jsxs(Box, { flexDirection: "row", gap: showSideRails ? 1 : 0, children: [showSideRails && _jsx(NavigationPanel, { focused: focusedPanel === "VIEWS" }), _jsx(LadderRunPanel, { data: data, layout: contentLayout, columns: columns, rows: rows, compactMeterWidth: showSideRails ? undefined : 24, interaction: interaction, focused: focusedPanel === "RUN" }), showSideRails && _jsx(KeysPanel, { focused: focusedPanel === "KEYS" })] }), _jsx(StatusStrip, { items: statusItems, width: columns }), _jsx(Keybar, { surface: "run", width: columns })] }));
194
248
  }
@@ -2,6 +2,7 @@ import { type ReactElement } from "react";
2
2
  import { type TickmarkrConfig } from "../../config/config.js";
3
3
  import { type ComponentState, type JournalRow } from "./components.js";
4
4
  import type { DemoCaptures } from "./demo.js";
5
+ import { type SetupInteractionState, type SetupKeyBinding } from "./keys.js";
5
6
  export type SetupCockpitData = {
6
7
  readonly binaryVersion: string;
7
8
  readonly harnesses: readonly {
@@ -34,8 +35,10 @@ export declare function deriveSetupCockpitData(captures: DemoCaptures, binaryVer
34
35
  * describes the fleet.
35
36
  */
36
37
  export declare const SETUP_DETAIL_ORDER: readonly ["detected", "consultsAndReviewers", "overlayDiff", "fleetAndGates"];
37
- export declare function SetupCockpitFrame({ data, columns, rows, }: {
38
+ export declare function SetupCockpitFrame({ data, columns, rows, interaction, bindings, }: {
38
39
  data: SetupCockpitData;
39
40
  columns: number;
40
41
  rows?: number;
42
+ interaction?: SetupInteractionState;
43
+ bindings?: readonly SetupKeyBinding[];
41
44
  }): ReactElement;
@@ -5,10 +5,12 @@ import { parse } from "yaml";
5
5
  import { BANNER, GLYPHS, PLAIN_BANNER, } from "../../brand.js";
6
6
  import { DEFAULT_CONFIG, INTEGRITY_FLOOR_SHAPES, TIER_RANK, TickmarkrConfigSchema, unifiedYamlDiff, } from "../../config/config.js";
7
7
  import { SHAPES } from "../../graph/schema.js";
8
- import { allocateBandColumns, BandLines, BodyText, CockpitGrid, composeBandLine, JournalRowPanel, KEYBAR_KEYS, Keybar, measureBandRows, Panel, StateGlyph, StatTile, StatusStrip, } from "./components.js";
8
+ import { allocateBandColumns, BandLines, BodyText, CockpitGrid, composeBandLine, JournalRowPanel, measureBandRows, Panel, StateGlyph, StatTile, StatusStrip, } from "./components.js";
9
9
  import { FULL_JOURNAL_ROWS, resolveCockpitLayout, } from "./layout.js";
10
10
  import { deriveRunCockpitData, PANEL_CHROME_ROWS, panelRows, wrappedRows, } from "./run-cockpit.js";
11
+ import { initialSetupInteractionState, keybarEntries, SETUP_PANEL_FOCUS_ORDER, SURFACE_KEY_BINDINGS, } from "./keys.js";
11
12
  const APPROVED_SIDE_RAIL_COLUMN_FLOOR = 80;
13
+ const setupAdditionalBindings = (bindings) => bindings.filter((binding) => !SURFACE_KEY_BINDINGS.run.some((runBinding) => runBinding.key === binding.key));
12
14
  function deepMerge(base, overlay) {
13
15
  if (overlay === undefined || overlay === null)
14
16
  return base;
@@ -158,28 +160,79 @@ export function deriveSetupCockpitData(captures, binaryVersion) {
158
160
  function identityBannerLines() {
159
161
  return (process.env.NO_COLOR === undefined ? BANNER : PLAIN_BANNER).trimEnd().split("\n");
160
162
  }
161
- function identityVersionLines(data) {
162
- return [`v${data.binaryVersion} · binary ${GLYPHS.pass}`, "setup · step 2/6"];
163
+ function identityVersionLines(data, interaction) {
164
+ return [
165
+ `v${data.binaryVersion} · binary ${GLYPHS.pass}`,
166
+ `setup · step ${interaction?.step ?? 2}/6`,
167
+ ];
163
168
  }
164
- function IdentityHeader({ data, secondary = true, }) {
169
+ function IdentityHeader({ data, secondary = true, interaction, }) {
165
170
  const mark = identityBannerLines();
166
- const [version, step] = identityVersionLines(data);
171
+ const [version, step] = identityVersionLines(data, interaction);
167
172
  return (_jsxs(Box, { justifyContent: "space-between", children: [_jsx(Box, { flexDirection: "column", children: (secondary ? mark : mark.slice(0, 1)).map((line, index) => (_jsx(BodyText, { emphasis: "strong", children: line }, `${index}:${line}`))) }), _jsxs(Box, { flexDirection: "column", alignItems: "flex-end", children: [_jsx(BodyText, { emphasis: "strong", children: version }), secondary && _jsx(BodyText, { children: step })] })] }));
168
173
  }
169
- function NavigationPanel({ width = 16, compact = false, }) {
174
+ function NavigationPanel({ width = 16, compact = false, step = 2, focused = false, }) {
175
+ const entries = [
176
+ "Detect",
177
+ "Harnesses",
178
+ "Fleet",
179
+ "Gates",
180
+ "Consults",
181
+ "Reviewers",
182
+ "Review",
183
+ ];
170
184
  if (!compact) {
171
- return (_jsxs(Panel, { title: "SETUP", width: width, children: [_jsx(BodyText, { children: " Detect" }), _jsxs(BodyText, { emphasis: "strong", children: [GLYPHS.pointer, " Harnesses"] }), _jsx(BodyText, { children: " Fleet" }), _jsx(BodyText, { children: " Gates" }), _jsx(BodyText, { children: " Consults" }), _jsx(BodyText, { children: " Reviewers" }), _jsx(BodyText, { children: " Review" })] }));
185
+ return (_jsx(Panel, { title: "SETUP", width: width, focused: focused, children: entries.map((entry, index) => (_jsxs(BodyText, { emphasis: index + 1 === step ? "strong" : "normal", children: [index + 1 === step ? `${GLYPHS.pointer} ` : " ", entry] }, entry))) }));
172
186
  }
173
- return (_jsxs(Panel, { title: "SETUP", width: width, children: [_jsx(Box, { height: 1, overflow: "hidden", children: _jsx(BodyText, { children: " Detect" }) }), _jsx(Box, { height: 1, overflow: "hidden", children: _jsxs(BodyText, { emphasis: "strong", children: [GLYPHS.pointer, " Harnesses"] }) }), _jsx(Box, { height: 1, overflow: "hidden", children: _jsx(BodyText, { children: " Fleet" }) }), _jsx(Box, { height: 1, overflow: "hidden", children: _jsx(BodyText, { children: " Gates" }) }), _jsx(Box, { height: 1, overflow: "hidden", children: _jsx(BodyText, { children: " Consults" }) }), _jsx(Box, { height: 1, overflow: "hidden", children: _jsx(BodyText, { children: " Reviewers" }) }), _jsx(Box, { height: 1, overflow: "hidden", children: _jsx(BodyText, { children: " Review" }) })] }));
187
+ return (_jsx(Panel, { title: "SETUP", width: width, focused: focused, children: entries.map((entry, index) => (_jsx(Box, { height: 1, overflow: "hidden", children: _jsxs(BodyText, { emphasis: index + 1 === step ? "strong" : "normal", children: [index + 1 === step ? `${GLYPHS.pointer} ` : " ", entry] }) }, entry))) }));
174
188
  }
175
- function KeysPanel({ width = 16 }) {
176
- return (_jsx(Panel, { title: "KEYS", width: width, children: KEYBAR_KEYS.setup.map((item) => (_jsxs(Box, { children: [_jsx(BodyText, { emphasis: "strong", children: item.key }), _jsxs(BodyText, { children: [" ", item.label] })] }, item.key))) }));
189
+ function KeysPanel({ bindings, width = 16, focused = false, }) {
190
+ return (_jsx(Panel, { title: "KEYS", width: width, focused: focused, children: keybarEntries(bindings).map((item) => (_jsxs(Box, { children: [_jsx(BodyText, { emphasis: "strong", children: item.key }), _jsxs(BodyText, { children: [" ", item.label] })] }, item.key))) }));
177
191
  }
178
- function CompactKeysPanel({ width = 16 }) {
192
+ function CompactKeysPanel({ bindings, width = 16, focused = false, }) {
179
193
  const contentColumns = Math.max(1, width - 4);
180
- return (_jsx(Panel, { title: "KEYS", width: width, children: KEYBAR_KEYS.setupAdditional.map((item) => (_jsxs(Box, { flexDirection: "row", flexWrap: "nowrap", height: 1, overflow: "hidden", children: [_jsx(BodyText, { emphasis: "strong", children: item.key }), _jsxs(BodyText, { children: [" ", item.label.slice(0, Math.max(0, contentColumns - [...item.key].length - 1))] })] }, item.key))) }));
194
+ return (_jsx(Panel, { title: "KEYS", width: width, focused: focused, children: keybarEntries(setupAdditionalBindings(bindings)).map((item) => (_jsxs(Box, { flexDirection: "row", flexWrap: "nowrap", height: 1, overflow: "hidden", children: [_jsx(BodyText, { emphasis: "strong", children: item.key }), _jsxs(BodyText, { children: [" ", item.label.slice(0, Math.max(0, contentColumns - [...item.key].length - 1))] })] }, item.key))) }));
195
+ }
196
+ function SetupKeybar({ bindings, width, }) {
197
+ return (_jsx(Box, { flexDirection: "row", flexWrap: "nowrap", width: width, height: 1, overflow: "hidden", "aria-label": "setup cockpit keys", children: keybarEntries(bindings).map((item, index) => (_jsxs(Box, { flexShrink: 0, children: [index > 0 && _jsx(BodyText, { emphasis: "dim", children: " \u00B7 " }), _jsx(BodyText, { emphasis: "strong", children: item.key }), _jsxs(BodyText, { emphasis: "dim", children: [" ", item.label] })] }, item.key))) }));
198
+ }
199
+ function SetupInteractionPanel({ data, interaction, bindings, }) {
200
+ if (interaction?.savePrompt === true) {
201
+ return (_jsx(LinePanel, { title: "SAVE", lines: ["confirm save · y yes · n cancel", ...overlayDiffLines(data)], columns: 72 }));
202
+ }
203
+ if (interaction?.help === true) {
204
+ return (_jsx(LinePanel, { title: "HELP", lines: keybarEntries(bindings).map((item) => `${item.key} ${item.label}`), columns: 72 }));
205
+ }
206
+ return null;
207
+ }
208
+ function setupStatusItems(data, interaction) {
209
+ const items = [
210
+ { state: "neutral", text: `overlay ${data.overlayTarget}` },
211
+ { state: "pass", text: "base untouched" },
212
+ interaction?.saved === true
213
+ ? { state: "active", text: "changes saved" }
214
+ : { state: "warn", text: `${data.stagedChanges.length} changes unsaved` },
215
+ ];
216
+ if (interaction !== undefined) {
217
+ if (interaction.all)
218
+ items.push({ state: "active", text: "all selected" });
219
+ else if (interaction.selected.length > 0) {
220
+ items.push({ state: "active", text: "selection selected" });
221
+ }
222
+ else if (interaction.selection >= 0) {
223
+ items.push({ state: "neutral", text: `selection ${interaction.selection + 1}` });
224
+ }
225
+ if (interaction.probeRevision > 0) {
226
+ items.push({ state: "pass", text: `re-probed ${interaction.probeRevision}` });
227
+ }
228
+ if (interaction.panel !== 1) {
229
+ const panel = SETUP_PANEL_FOCUS_ORDER[interaction.panel] ?? "HARNESSES";
230
+ items.push({ state: "neutral", text: `panel ${panel}` });
231
+ }
232
+ }
233
+ return items;
181
234
  }
182
- function fullKeysFit(rows, layout, data, columns) {
235
+ function fullKeysFit(rows, layout, data, columns, bindings) {
183
236
  if (!layout.elements.secondaryHeader)
184
237
  return false;
185
238
  const wrappedKeyRows = {
@@ -187,7 +240,7 @@ function fullKeysFit(rows, layout, data, columns) {
187
240
  "folded-keys": 2,
188
241
  stacked: 4,
189
242
  }[layout.arrangement];
190
- const panelRows = KEYBAR_KEYS.setup.length
243
+ const panelRows = bindings.length
191
244
  + wrappedKeyRows
192
245
  + 3; // panel borders and title row
193
246
  return headerRowsFor(layout, data, columns) + 2 + panelRows <= Math.floor(rows);
@@ -423,7 +476,7 @@ function baseRows(layout, data, columns, inner) {
423
476
  + statRowsFor(layout, data, inner)
424
477
  + PANEL_CHROME_ROWS; // review border and title
425
478
  }
426
- function LadderSetupPanel({ data, layout, columns, rows, }) {
479
+ function LadderSetupPanel({ data, layout, columns, rows, interaction, bindings, focused = true, }) {
427
480
  const inner = contentInnerWidth(columns, layout);
428
481
  const preferredReviewRows = Math.min(FULL_JOURNAL_ROWS, layout.journalRows);
429
482
  const base = baseRows(layout, data, columns, inner);
@@ -434,34 +487,32 @@ function LadderSetupPanel({ data, layout, columns, rows, }) {
434
487
  const visibleReviewRows = details.complete
435
488
  ? spare
436
489
  : Math.min(preferredReviewRows, spare);
437
- return (_jsxs(Panel, { title: "HARNESSES", focused: true, flexGrow: 1, children: [_jsx(SetupStats, { data: data, mode: layout.stats.mode }), details.retained.has("detected") && (_jsx(HarnessRows, { rows: details.detected, columns: Math.max(1, inner - 6) })), details.retained.has("fleetAndGates") && (_jsx(LineBand, { columns: inner, panels: [
438
- lineBandContent("FLEET", fleetLines(data)),
439
- lineBandContent("GATES", gatesLines(data)),
440
- ] })), details.retained.has("consultsAndReviewers") && (_jsx(LineBand, { columns: inner, panels: [
441
- lineBandContent("CONSULTS", consultsLines(data)),
442
- lineBandContent("REVIEWERS", reviewersLines(data)),
443
- ] })), details.retained.has("overlayDiff") && (_jsx(LinePanel, { title: "OVERLAY DIFF", lines: overlayDiffLines(data), columns: Math.max(1, inner - 4), emphasis: "dim" })), _jsx(SizedReviewPanel, { rows: data.reviewRows, bodyRows: visibleReviewRows })] }));
444
- }
445
- function SetupPanel({ data, inner, }) {
446
- return (_jsxs(Panel, { title: "HARNESSES", focused: true, flexGrow: 1, children: [_jsxs(CockpitGrid, { children: [_jsx(StatTile, { label: "FOUND", value: data.counts.found, samples: [data.counts.found] }), _jsx(StatTile, { label: "AUTHENTICATED", value: data.counts.authenticated, samples: [data.counts.authenticated] }), _jsx(StatTile, { label: "ROUTABLE", value: data.counts.routable, samples: [data.counts.routable] })] }), _jsx(HarnessRows, { rows: detectedRows(data), columns: Math.max(1, inner - 6) }), _jsx(LineBand, { columns: inner, panels: [
447
- lineBandContent("FLEET", fleetLines(data)),
448
- lineBandContent("GATES", gatesLines(data)),
449
- ] }), _jsx(LineBand, { columns: inner, panels: [
450
- lineBandContent("CONSULTS", consultsLines(data)),
451
- lineBandContent("REVIEWERS", reviewersLines(data)),
452
- ] }), _jsx(LinePanel, { title: "OVERLAY DIFF", lines: overlayDiffLines(data), columns: Math.max(1, inner - 4), emphasis: "dim" }), _jsx(SizedReviewPanel, { rows: data.reviewRows, bodyRows: data.reviewRows.length })] }));
453
- }
454
- export function SetupCockpitFrame({ data, columns, rows, }) {
490
+ const interactionPanel = SetupInteractionPanel({ data, interaction, bindings });
491
+ return (_jsx(Panel, { title: "HARNESSES", focused: focused, flexGrow: 1, children: interactionPanel ?? (_jsxs(_Fragment, { children: [_jsx(SetupStats, { data: data, mode: layout.stats.mode }), details.retained.has("detected") && (_jsx(HarnessRows, { rows: details.detected, columns: Math.max(1, inner - 6) })), details.retained.has("fleetAndGates") && (_jsx(LineBand, { columns: inner, panels: [
492
+ lineBandContent("FLEET", fleetLines(data)),
493
+ lineBandContent("GATES", gatesLines(data)),
494
+ ] })), details.retained.has("consultsAndReviewers") && (_jsx(LineBand, { columns: inner, panels: [
495
+ lineBandContent("CONSULTS", consultsLines(data)),
496
+ lineBandContent("REVIEWERS", reviewersLines(data)),
497
+ ] })), details.retained.has("overlayDiff") && (_jsx(LinePanel, { title: "OVERLAY DIFF", lines: overlayDiffLines(data), columns: Math.max(1, inner - 4), emphasis: "dim" })), _jsx(SizedReviewPanel, { rows: data.reviewRows, bodyRows: visibleReviewRows })] })) }));
498
+ }
499
+ function SetupPanel({ data, inner, interaction, bindings, focused = true, }) {
500
+ const interactionPanel = SetupInteractionPanel({ data, interaction, bindings });
501
+ return (_jsx(Panel, { title: "HARNESSES", focused: focused, flexGrow: 1, children: interactionPanel ?? (_jsxs(_Fragment, { children: [_jsxs(CockpitGrid, { children: [_jsx(StatTile, { label: "FOUND", value: data.counts.found, samples: [data.counts.found] }), _jsx(StatTile, { label: "AUTHENTICATED", value: data.counts.authenticated, samples: [data.counts.authenticated] }), _jsx(StatTile, { label: "ROUTABLE", value: data.counts.routable, samples: [data.counts.routable] })] }), _jsx(HarnessRows, { rows: detectedRows(data), columns: Math.max(1, inner - 6) }), _jsx(LineBand, { columns: inner, panels: [
502
+ lineBandContent("FLEET", fleetLines(data)),
503
+ lineBandContent("GATES", gatesLines(data)),
504
+ ] }), _jsx(LineBand, { columns: inner, panels: [
505
+ lineBandContent("CONSULTS", consultsLines(data)),
506
+ lineBandContent("REVIEWERS", reviewersLines(data)),
507
+ ] }), _jsx(LinePanel, { title: "OVERLAY DIFF", lines: overlayDiffLines(data), columns: Math.max(1, inner - 4), emphasis: "dim" }), _jsx(SizedReviewPanel, { rows: data.reviewRows, bodyRows: data.reviewRows.length })] })) }));
508
+ }
509
+ export function SetupCockpitFrame({ data, columns, rows, interaction, bindings = SURFACE_KEY_BINDINGS.setup, }) {
510
+ const currentInteraction = interaction ?? initialSetupInteractionState();
511
+ const focusedPanel = SETUP_PANEL_FOCUS_ORDER[currentInteraction.panel]
512
+ ?? "HARNESSES";
455
513
  if (rows === undefined) {
456
514
  const inner = Math.max(1, Math.floor(columns) - (2 * 16) - 2 - 4);
457
- return (_jsxs(Box, { flexDirection: "column", width: columns, children: [_jsx(IdentityHeader, { data: data }), _jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(NavigationPanel, {}), _jsx(SetupPanel, { data: data, inner: inner }), _jsx(KeysPanel, {})] }), _jsx(StatusStrip, { width: columns, items: [
458
- { state: "neutral", text: `overlay ${data.overlayTarget}` },
459
- { state: "pass", text: "base untouched" },
460
- {
461
- state: "warn",
462
- text: `${data.stagedChanges.length} changes unsaved`,
463
- },
464
- ] }), _jsx(Keybar, { surface: "setup", width: columns })] }));
515
+ return (_jsxs(Box, { flexDirection: "column", width: columns, children: [_jsx(IdentityHeader, { data: data, interaction: currentInteraction }), _jsxs(Box, { flexDirection: "row", gap: 1, children: [_jsx(NavigationPanel, { step: currentInteraction.step, focused: focusedPanel === "SETUP" }), _jsx(SetupPanel, { data: data, inner: inner, interaction: currentInteraction, bindings: bindings, focused: focusedPanel === "HARNESSES" }), _jsx(KeysPanel, { bindings: bindings, focused: focusedPanel === "KEYS" })] }), _jsx(StatusStrip, { width: columns, items: setupStatusItems(data, currentInteraction) }), _jsx(SetupKeybar, { bindings: bindings, width: columns })] }));
465
516
  }
466
517
  const layout = resolveCockpitLayout(columns, rows);
467
518
  if (layout.renderer === "plain")
@@ -473,16 +524,9 @@ export function SetupCockpitFrame({ data, columns, rows, }) {
473
524
  ...layout,
474
525
  stats: { ...layout.stats, mode: "summary", rows: 1 },
475
526
  };
476
- const showFullSidePanels = fullKeysFit(rows, contentLayout, data, columns);
527
+ const showFullSidePanels = fullKeysFit(rows, contentLayout, data, columns, bindings);
477
528
  const resolvedSidePanelWidth = sidePanelWidth(contentLayout);
478
- return (_jsxs(Box, { flexDirection: "column", width: columns, children: [_jsx(IdentityHeader, { data: data, secondary: layout.elements.secondaryHeader }), _jsxs(Box, { flexDirection: "row", gap: showSideRails ? 1 : 0, children: [showSideRails && (_jsx(Box, { width: resolvedSidePanelWidth, flexShrink: 0, children: _jsx(NavigationPanel, { width: resolvedSidePanelWidth, compact: !contentLayout.elements.secondaryHeader }) })), _jsx(LadderSetupPanel, { data: data, layout: contentLayout, columns: columns, rows: rows }), showSideRails && (showFullSidePanels
479
- ? (_jsx(Box, { width: resolvedSidePanelWidth, flexShrink: 0, children: _jsx(KeysPanel, { width: resolvedSidePanelWidth }) }))
480
- : (_jsx(Box, { width: resolvedSidePanelWidth, flexShrink: 0, children: _jsx(CompactKeysPanel, { width: resolvedSidePanelWidth }) })))] }), _jsx(StatusStrip, { width: columns, items: [
481
- { state: "neutral", text: `overlay ${data.overlayTarget}` },
482
- { state: "pass", text: "base untouched" },
483
- {
484
- state: "warn",
485
- text: `${data.stagedChanges.length} changes unsaved`,
486
- },
487
- ] }), _jsx(Keybar, { surface: "setup", width: columns })] }));
529
+ return (_jsxs(Box, { flexDirection: "column", width: columns, children: [_jsx(IdentityHeader, { data: data, secondary: layout.elements.secondaryHeader, interaction: currentInteraction }), _jsxs(Box, { flexDirection: "row", gap: showSideRails ? 1 : 0, children: [showSideRails && (_jsx(Box, { width: resolvedSidePanelWidth, flexShrink: 0, children: _jsx(NavigationPanel, { width: resolvedSidePanelWidth, compact: !contentLayout.elements.secondaryHeader, step: currentInteraction.step, focused: focusedPanel === "SETUP" }) })), _jsx(LadderSetupPanel, { data: data, layout: contentLayout, columns: columns, rows: rows, interaction: currentInteraction, bindings: bindings, focused: focusedPanel === "HARNESSES" }), showSideRails && (showFullSidePanels
530
+ ? (_jsx(Box, { width: resolvedSidePanelWidth, flexShrink: 0, children: _jsx(KeysPanel, { bindings: bindings, width: resolvedSidePanelWidth, focused: focusedPanel === "KEYS" }) }))
531
+ : (_jsx(Box, { width: resolvedSidePanelWidth, flexShrink: 0, children: _jsx(CompactKeysPanel, { bindings: bindings, width: resolvedSidePanelWidth, focused: focusedPanel === "KEYS" }) })))] }), _jsx(StatusStrip, { width: columns, items: setupStatusItems(data, currentInteraction) }), _jsx(SetupKeybar, { bindings: bindings, width: columns })] }));
488
532
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tickmarkr",
3
- "version": "1.80.0",
3
+ "version": "1.81.0",
4
4
  "description": "Spec in, verified work out.",
5
5
  "type": "module",
6
6
  "license": "MIT",