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
|
@@ -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,76 @@
|
|
|
1
|
+
export declare const COCKPIT_ROW_FLOOR = 14;
|
|
2
|
+
export declare const COCKPIT_COLUMN_FLOOR = 40;
|
|
3
|
+
export declare const FULL_JOURNAL_ROWS = 3;
|
|
4
|
+
/**
|
|
5
|
+
* Ordered from highest priority (last surrendered) to lowest (first
|
|
6
|
+
* surrendered). A minimum row preserves an element in the compact frame; its
|
|
7
|
+
* preferred rows restore the full presentation before the next step receives
|
|
8
|
+
* any rows. The stats minimum is its one-line summary, not the full tiles.
|
|
9
|
+
*/
|
|
10
|
+
declare const ORDERED_POLICY: readonly [{
|
|
11
|
+
readonly element: "keybar";
|
|
12
|
+
readonly minimumRows: 1;
|
|
13
|
+
readonly preferredRows: 1;
|
|
14
|
+
}, {
|
|
15
|
+
readonly element: "statusStrip";
|
|
16
|
+
readonly minimumRows: 1;
|
|
17
|
+
readonly preferredRows: 1;
|
|
18
|
+
}, {
|
|
19
|
+
readonly element: "primaryHeader";
|
|
20
|
+
readonly minimumRows: 1;
|
|
21
|
+
readonly preferredRows: 1;
|
|
22
|
+
}, {
|
|
23
|
+
readonly element: "progressBar";
|
|
24
|
+
readonly minimumRows: 1;
|
|
25
|
+
readonly preferredRows: 1;
|
|
26
|
+
}, {
|
|
27
|
+
readonly element: "journal";
|
|
28
|
+
readonly minimumRows: 1;
|
|
29
|
+
readonly preferredRows: 3;
|
|
30
|
+
}, {
|
|
31
|
+
readonly element: "progressCaption";
|
|
32
|
+
readonly minimumRows: 0;
|
|
33
|
+
readonly preferredRows: 1;
|
|
34
|
+
}, {
|
|
35
|
+
readonly element: "statTiles";
|
|
36
|
+
readonly minimumRows: 1;
|
|
37
|
+
readonly preferredRows: 2;
|
|
38
|
+
}, {
|
|
39
|
+
readonly element: "secondaryHeader";
|
|
40
|
+
readonly minimumRows: 0;
|
|
41
|
+
readonly preferredRows: 1;
|
|
42
|
+
}];
|
|
43
|
+
type PolicyElement = (typeof ORDERED_POLICY)[number]["element"];
|
|
44
|
+
type RowAllocation = Record<PolicyElement, number>;
|
|
45
|
+
export declare const LAYOUT_PRIORITY: readonly PolicyElement[];
|
|
46
|
+
export declare const FULL_LAYOUT_ROWS: number;
|
|
47
|
+
export type CockpitArrangement = "stacked" | "folded-keys" | "three-column";
|
|
48
|
+
export interface FrameCockpitLayout {
|
|
49
|
+
renderer: "frame";
|
|
50
|
+
arrangement: CockpitArrangement;
|
|
51
|
+
elements: {
|
|
52
|
+
version: true;
|
|
53
|
+
keybar: true;
|
|
54
|
+
statusStrip: true;
|
|
55
|
+
primaryHeader: true;
|
|
56
|
+
progressBar: boolean;
|
|
57
|
+
progressCaption: boolean;
|
|
58
|
+
secondaryHeader: boolean;
|
|
59
|
+
};
|
|
60
|
+
stats: {
|
|
61
|
+
mode: "tiles" | "summary";
|
|
62
|
+
rows: number;
|
|
63
|
+
figures: readonly ["tasks", "gates", "pass"];
|
|
64
|
+
};
|
|
65
|
+
journalRows: number;
|
|
66
|
+
rowAllocation: {
|
|
67
|
+
chrome: number;
|
|
68
|
+
} & RowAllocation;
|
|
69
|
+
}
|
|
70
|
+
export interface PlainCockpitLayout {
|
|
71
|
+
renderer: "plain";
|
|
72
|
+
arrangement: "plain";
|
|
73
|
+
}
|
|
74
|
+
export type CockpitLayout = FrameCockpitLayout | PlainCockpitLayout;
|
|
75
|
+
export declare function resolveCockpitLayout(columns: number, rows: number): CockpitLayout;
|
|
76
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export const COCKPIT_ROW_FLOOR = 14;
|
|
2
|
+
export const COCKPIT_COLUMN_FLOOR = 40;
|
|
3
|
+
export const FULL_JOURNAL_ROWS = 3;
|
|
4
|
+
const FRAME_CHROME_ROWS = 8;
|
|
5
|
+
/**
|
|
6
|
+
* Ordered from highest priority (last surrendered) to lowest (first
|
|
7
|
+
* surrendered). A minimum row preserves an element in the compact frame; its
|
|
8
|
+
* preferred rows restore the full presentation before the next step receives
|
|
9
|
+
* any rows. The stats minimum is its one-line summary, not the full tiles.
|
|
10
|
+
*/
|
|
11
|
+
const ORDERED_POLICY = [
|
|
12
|
+
{ element: "keybar", minimumRows: 1, preferredRows: 1 },
|
|
13
|
+
{ element: "statusStrip", minimumRows: 1, preferredRows: 1 },
|
|
14
|
+
{ element: "primaryHeader", minimumRows: 1, preferredRows: 1 },
|
|
15
|
+
{ element: "progressBar", minimumRows: 1, preferredRows: 1 },
|
|
16
|
+
{ element: "journal", minimumRows: 1, preferredRows: FULL_JOURNAL_ROWS },
|
|
17
|
+
{ element: "progressCaption", minimumRows: 0, preferredRows: 1 },
|
|
18
|
+
{ element: "statTiles", minimumRows: 1, preferredRows: 2 },
|
|
19
|
+
{ element: "secondaryHeader", minimumRows: 0, preferredRows: 1 },
|
|
20
|
+
];
|
|
21
|
+
export const LAYOUT_PRIORITY = ORDERED_POLICY.map(({ element }) => element);
|
|
22
|
+
export const FULL_LAYOUT_ROWS = FRAME_CHROME_ROWS +
|
|
23
|
+
ORDERED_POLICY.reduce((rows, step) => rows + step.preferredRows, 0);
|
|
24
|
+
function arrangementFor(columns) {
|
|
25
|
+
if (columns >= 120)
|
|
26
|
+
return "three-column";
|
|
27
|
+
if (columns >= 90)
|
|
28
|
+
return "folded-keys";
|
|
29
|
+
return "stacked";
|
|
30
|
+
}
|
|
31
|
+
function allocateRows(rows) {
|
|
32
|
+
const allocation = Object.fromEntries(ORDERED_POLICY.map(({ element }) => [element, 0]));
|
|
33
|
+
let available = rows - FRAME_CHROME_ROWS;
|
|
34
|
+
// Preserve each element's compact form. At the contracted floor these
|
|
35
|
+
// minima consume the budget exactly.
|
|
36
|
+
for (const step of ORDERED_POLICY) {
|
|
37
|
+
const granted = Math.min(step.minimumRows, available);
|
|
38
|
+
allocation[step.element] = granted;
|
|
39
|
+
available -= granted;
|
|
40
|
+
}
|
|
41
|
+
// Restore full forms in priority order, never funding a lower-priority
|
|
42
|
+
// element while a higher-priority element remains compact.
|
|
43
|
+
for (const step of ORDERED_POLICY) {
|
|
44
|
+
const wanted = step.preferredRows - allocation[step.element];
|
|
45
|
+
const granted = Math.min(wanted, available);
|
|
46
|
+
allocation[step.element] += granted;
|
|
47
|
+
available -= granted;
|
|
48
|
+
}
|
|
49
|
+
// Once every element is full, journal history is the sole useful sink.
|
|
50
|
+
allocation.journal += available;
|
|
51
|
+
return allocation;
|
|
52
|
+
}
|
|
53
|
+
export function resolveCockpitLayout(columns, rows) {
|
|
54
|
+
if (!Number.isFinite(columns) ||
|
|
55
|
+
!Number.isFinite(rows) ||
|
|
56
|
+
columns < COCKPIT_COLUMN_FLOOR ||
|
|
57
|
+
rows < COCKPIT_ROW_FLOOR) {
|
|
58
|
+
return { renderer: "plain", arrangement: "plain" };
|
|
59
|
+
}
|
|
60
|
+
const allocation = allocateRows(Math.floor(rows));
|
|
61
|
+
return {
|
|
62
|
+
renderer: "frame",
|
|
63
|
+
arrangement: arrangementFor(Math.floor(columns)),
|
|
64
|
+
elements: {
|
|
65
|
+
version: true,
|
|
66
|
+
keybar: true,
|
|
67
|
+
statusStrip: true,
|
|
68
|
+
primaryHeader: true,
|
|
69
|
+
progressBar: allocation.progressBar > 0,
|
|
70
|
+
progressCaption: allocation.progressCaption > 0,
|
|
71
|
+
secondaryHeader: allocation.secondaryHeader > 0,
|
|
72
|
+
},
|
|
73
|
+
stats: {
|
|
74
|
+
mode: allocation.statTiles > 1 ? "tiles" : "summary",
|
|
75
|
+
rows: allocation.statTiles,
|
|
76
|
+
figures: ["tasks", "gates", "pass"],
|
|
77
|
+
},
|
|
78
|
+
journalRows: allocation.journal,
|
|
79
|
+
rowAllocation: {
|
|
80
|
+
chrome: FRAME_CHROME_ROWS,
|
|
81
|
+
...allocation,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
import type { RunCockpitData } from "./derive.js";
|
|
3
|
+
import { type RunInteractionState } from "./keys.js";
|
|
4
|
+
export { deriveRunCockpitData } from "./derive.js";
|
|
5
|
+
export type { RunCockpitData } from "./derive.js";
|
|
6
|
+
export { PANEL_CHROME_ROWS } from "./components.js";
|
|
7
|
+
/**
|
|
8
|
+
* How many rows one line of body text occupies once the renderer wraps it into
|
|
9
|
+
* `columns`. This mirrors the renderer's own word wrap — greedy, and breaking a
|
|
10
|
+
* word that cannot fit on a line of its own — so a frame can charge a panel the
|
|
11
|
+
* rows it will really occupy instead of a hand-tuned constant.
|
|
12
|
+
*/
|
|
13
|
+
export declare function wrappedRows(text: string, columns: number): number;
|
|
14
|
+
/** The rows a bordered panel occupies when its body wraps into `columns`. */
|
|
15
|
+
export declare function panelRows(lines: readonly string[], columns: number): number;
|
|
16
|
+
export declare function RunCockpitFrame({ data, columns, rows, interaction, }: {
|
|
17
|
+
data: RunCockpitData;
|
|
18
|
+
columns: number;
|
|
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;
|
|
26
|
+
}): ReactElement;
|