tickmarkr 1.80.0 → 1.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/brand.d.ts +4 -1
- package/dist/brand.js +46 -7
- package/dist/cli/commands/approve.js +53 -8
- package/dist/cli/commands/plan.js +17 -2
- package/dist/cli/commands/report.js +15 -6
- package/dist/cli/commands/ui.d.ts +1 -1
- package/dist/cli/commands/ui.js +47 -4
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/compile/collateral.d.ts +19 -0
- package/dist/compile/collateral.js +90 -0
- package/dist/compile/index.js +18 -4
- package/dist/drivers/herdr.d.ts +1 -0
- package/dist/drivers/herdr.js +19 -0
- package/dist/drivers/types.d.ts +1 -0
- package/dist/gates/acceptance.js +50 -8
- package/dist/gates/llm.js +34 -19
- package/dist/gates/review.d.ts +9 -1
- package/dist/gates/review.js +173 -7
- package/dist/gates/run-gates.d.ts +1 -0
- package/dist/gates/run-gates.js +18 -1
- package/dist/report/bundle.d.ts +6 -1
- package/dist/report/bundle.js +13 -3
- package/dist/run/daemon.d.ts +5 -0
- package/dist/run/daemon.js +188 -27
- package/dist/run/journal.d.ts +5 -0
- package/dist/run/journal.js +71 -1
- package/dist/tui/cockpit/capture.d.ts +107 -1
- package/dist/tui/cockpit/capture.js +287 -10
- package/dist/tui/cockpit/components.d.ts +15 -86
- package/dist/tui/cockpit/components.js +49 -55
- package/dist/tui/cockpit/derive.d.ts +73 -1
- package/dist/tui/cockpit/derive.js +211 -15
- package/dist/tui/cockpit/keys.d.ts +244 -0
- package/dist/tui/cockpit/keys.js +634 -0
- package/dist/tui/cockpit/layout.d.ts +162 -0
- package/dist/tui/cockpit/layout.js +229 -0
- package/dist/tui/cockpit/live.d.ts +80 -0
- package/dist/tui/cockpit/live.js +311 -0
- package/dist/tui/cockpit/run-cockpit.d.ts +65 -2
- package/dist/tui/cockpit/run-cockpit.js +705 -128
- package/dist/tui/cockpit/setup-cockpit.d.ts +149 -1
- package/dist/tui/cockpit/setup-cockpit.js +432 -56
- package/dist/tui/cockpit/views.d.ts +72 -0
- package/dist/tui/cockpit/views.js +56 -0
- package/dist/tui/cockpit/width.d.ts +105 -0
- package/dist/tui/cockpit/width.js +276 -0
- package/fixtures/speckit-sample/tasks.md +2 -2
- package/package.json +3 -1
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import { GLYPHS } from "../../brand.js";
|
|
2
|
+
import { moveRunViewSelection, moveSetupSectionSelection, runViewAt, runViewForNumber, } from "./views.js";
|
|
3
|
+
export function initialRunInteractionState() {
|
|
4
|
+
return {
|
|
5
|
+
tab: "watch",
|
|
6
|
+
selection: null,
|
|
7
|
+
panel: 0,
|
|
8
|
+
railSelection: 0,
|
|
9
|
+
activeView: "run",
|
|
10
|
+
opened: null,
|
|
11
|
+
follow: false,
|
|
12
|
+
filterPrompt: false,
|
|
13
|
+
filterQuery: "",
|
|
14
|
+
help: false,
|
|
15
|
+
quit: false,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* The panels the run surface's focus moves between, in their painted order;
|
|
20
|
+
* index 0 is the content panel, and the sidebar is the level every view backs
|
|
21
|
+
* out to. A width that hides the sidebar resolves every index onto the panel
|
|
22
|
+
* that remains (`runPanelFocusOrder`).
|
|
23
|
+
*/
|
|
24
|
+
export const RUN_PANEL_FOCUS_ORDER = ["CONTENT", "VIEWS"];
|
|
25
|
+
/** The sidebar's position in the focus order — the level every view backs out to. */
|
|
26
|
+
const SIDEBAR_PANEL = RUN_PANEL_FOCUS_ORDER.indexOf("VIEWS");
|
|
27
|
+
/**
|
|
28
|
+
* The state the live surface opens in: the watch tab, the sidebar focused, so
|
|
29
|
+
* the very first arrow key moves the view selection rather than a row. This is
|
|
30
|
+
* a second constructor rather than a change to the one above: the committed
|
|
31
|
+
* captures depict a surface with no interaction state at all, and only an
|
|
32
|
+
* operator-accepted capture may re-freeze them.
|
|
33
|
+
*/
|
|
34
|
+
export function openingRunInteractionState(tab = "watch") {
|
|
35
|
+
return { ...initialRunInteractionState(), tab, panel: SIDEBAR_PANEL };
|
|
36
|
+
}
|
|
37
|
+
export function openingRunSurfaceState() {
|
|
38
|
+
return {
|
|
39
|
+
interaction: openingRunInteractionState("watch"),
|
|
40
|
+
stashed: openingRunInteractionState("setup"),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The rows of a view a key may actually stand on. The journal is a tail, not a
|
|
45
|
+
* list: it is never selected, opened or filtered, in either tab. Stated once
|
|
46
|
+
* here so the dispatcher and the advertisement cannot disagree about it.
|
|
47
|
+
*/
|
|
48
|
+
export function selectableRunViewRowIds(viewId, rowIds) {
|
|
49
|
+
return viewId === "journal" ? [] : rowIds;
|
|
50
|
+
}
|
|
51
|
+
/** The one width predicate shared by rendering, focus and key projection. */
|
|
52
|
+
export const RUN_SIDE_RAIL_COLUMN_FLOOR = 80;
|
|
53
|
+
/**
|
|
54
|
+
* The width a dispatch assumes when the caller names none: wide enough that
|
|
55
|
+
* every width-gated key is offered. Callers that know the terminal — the live
|
|
56
|
+
* surface — pass the real width, and a narrow one withholds what it hides.
|
|
57
|
+
*/
|
|
58
|
+
const UNMEASURED_COLUMNS = Number.MAX_SAFE_INTEGER;
|
|
59
|
+
export function runSideRailVisible(columns) {
|
|
60
|
+
return Number.isFinite(columns) && Math.floor(columns) >= RUN_SIDE_RAIL_COLUMN_FLOOR;
|
|
61
|
+
}
|
|
62
|
+
export function runPanelFocusOrder(columns) {
|
|
63
|
+
return runSideRailVisible(columns) ? RUN_PANEL_FOCUS_ORDER : ["CONTENT"];
|
|
64
|
+
}
|
|
65
|
+
export function runFocusedPanel({ interaction, columns, }) {
|
|
66
|
+
const order = runPanelFocusOrder(columns);
|
|
67
|
+
return order[interaction.panel % order.length] ?? "CONTENT";
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Where focus effectively rests at the width the surface is drawn: the sidebar
|
|
71
|
+
* a width hides cannot hold focus, so a state pointing at it resolves to the
|
|
72
|
+
* content panel — the same resolution the renderer paints. One predicate, so
|
|
73
|
+
* no handler can act on a panel the frame does not show as focused.
|
|
74
|
+
*/
|
|
75
|
+
function railFocused(state, columns = UNMEASURED_COLUMNS) {
|
|
76
|
+
return runFocusedPanel({ interaction: state, columns }) === "VIEWS";
|
|
77
|
+
}
|
|
78
|
+
function openView(state, index) {
|
|
79
|
+
const view = runViewAt(index);
|
|
80
|
+
if (view === undefined)
|
|
81
|
+
return state;
|
|
82
|
+
return {
|
|
83
|
+
...state,
|
|
84
|
+
activeView: view.id,
|
|
85
|
+
railSelection: index,
|
|
86
|
+
panel: 0,
|
|
87
|
+
selection: null,
|
|
88
|
+
opened: null,
|
|
89
|
+
filterPrompt: false,
|
|
90
|
+
filterQuery: "",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** Diving into the setup tab's selected section: the rail keeps its position. */
|
|
94
|
+
function openSetupSection(state) {
|
|
95
|
+
return { ...state, panel: 0, opened: null, filterPrompt: false, filterQuery: "" };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* THE tab switch, written once and shared by every scope that owns Tab: the
|
|
99
|
+
* drawn tab steps aside for the one waiting behind it. Stated here so the
|
|
100
|
+
* normal roster and the filter prompt's roster cannot disagree about what Tab
|
|
101
|
+
* does — a prompt that swallowed Tab would strand an operator on one tab.
|
|
102
|
+
*/
|
|
103
|
+
function switchTab(state) {
|
|
104
|
+
return { ...state, tab: state.tab === "watch" ? "setup" : "watch" };
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The word the run keybar draws beside Tab for the second tab: its real name.
|
|
108
|
+
* The tab is named DECISIONS wherever it is drawn AS A TAB — the header strip,
|
|
109
|
+
* its own panel title, and its own tab line (views.ts owns those constants) —
|
|
110
|
+
* and the advertisement of the key that draws it names the same destination.
|
|
111
|
+
* The dormant configuration wizard's own header strings ("SETUP",
|
|
112
|
+
* "setup · step N/6") are not advertisements of a destination and are none of
|
|
113
|
+
* this constant's business.
|
|
114
|
+
*/
|
|
115
|
+
const TAB_DESTINATION_DECISIONS = "Decisions";
|
|
116
|
+
/**
|
|
117
|
+
* The word the keybar draws beside Tab: the tab the key will draw — the
|
|
118
|
+
* second tab's advertised word while watch is drawn, "Watch" while it is.
|
|
119
|
+
* Resolved against the live state at projection time, so the advertisement is
|
|
120
|
+
* the action rather than a word frozen beside it. One function, so the normal
|
|
121
|
+
* roster and the filter prompt's roster cannot disagree about what Tab promises.
|
|
122
|
+
*/
|
|
123
|
+
const tabDestination = (state) => state.tab === "watch" ? TAB_DESTINATION_DECISIONS : "Watch";
|
|
124
|
+
/** A toggle's on/off report: the brand's reserved glyphs, never brackets. */
|
|
125
|
+
export function toggleReport(label, on) {
|
|
126
|
+
return `${label} ${on ? GLYPHS.toggleActive : GLYPHS.toggleInactive}`;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Every key the run surface advertises, in keybar order. Each entry carries
|
|
130
|
+
* its handler, so advertisement and behaviour cannot drift apart.
|
|
131
|
+
*/
|
|
132
|
+
export const RUN_KEY_BINDINGS = [
|
|
133
|
+
{
|
|
134
|
+
key: "↑↓",
|
|
135
|
+
label: "Move",
|
|
136
|
+
matches: ({ key }) => key.upArrow === true || key.downArrow === true,
|
|
137
|
+
apply: (state, { key }, context) => {
|
|
138
|
+
const delta = key.upArrow === true ? -1 : 1;
|
|
139
|
+
const columns = context?.columns ?? UNMEASURED_COLUMNS;
|
|
140
|
+
if (railFocused(state, columns)) {
|
|
141
|
+
if (state.tab === "setup") {
|
|
142
|
+
return {
|
|
143
|
+
...state,
|
|
144
|
+
railSelection: moveSetupSectionSelection(state.railSelection, delta),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
...state,
|
|
149
|
+
railSelection: moveRunViewSelection(state.railSelection, delta),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
if (state.tab === "setup")
|
|
153
|
+
return state;
|
|
154
|
+
// A width that hides the rail leaves the overview no pointer to move, so
|
|
155
|
+
// there the arrow moves the view selection itself and the body follows:
|
|
156
|
+
// the first arrow at the floor acts exactly as it does on the rail.
|
|
157
|
+
if (!runSideRailVisible(columns) && state.activeView === "run") {
|
|
158
|
+
const index = moveRunViewSelection(state.railSelection, delta);
|
|
159
|
+
const view = runViewAt(index);
|
|
160
|
+
if (view === undefined || index === state.railSelection)
|
|
161
|
+
return state;
|
|
162
|
+
return {
|
|
163
|
+
...state,
|
|
164
|
+
railSelection: index,
|
|
165
|
+
activeView: view.id,
|
|
166
|
+
selection: null,
|
|
167
|
+
opened: null,
|
|
168
|
+
filterPrompt: false,
|
|
169
|
+
filterQuery: "",
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
if (state.activeView === "run")
|
|
173
|
+
return state;
|
|
174
|
+
const rowIds = context?.rowIds ?? [];
|
|
175
|
+
if (rowIds.length === 0)
|
|
176
|
+
return state;
|
|
177
|
+
const current = state.selection === null
|
|
178
|
+
? -1
|
|
179
|
+
: rowIds.indexOf(state.selection);
|
|
180
|
+
if (delta < 0) {
|
|
181
|
+
if (current < 0)
|
|
182
|
+
return state;
|
|
183
|
+
return current === 0
|
|
184
|
+
? { ...state, selection: null }
|
|
185
|
+
: { ...state, selection: rowIds[current - 1] };
|
|
186
|
+
}
|
|
187
|
+
const next = Math.min(rowIds.length - 1, current + 1);
|
|
188
|
+
if (next === current)
|
|
189
|
+
return state;
|
|
190
|
+
return { ...state, selection: rowIds[next] };
|
|
191
|
+
},
|
|
192
|
+
available: ({ interaction, columns, rowIds }) => railFocused(interaction, columns)
|
|
193
|
+
|| (interaction.tab === "watch"
|
|
194
|
+
&& interaction.opened === null
|
|
195
|
+
&& !interaction.help
|
|
196
|
+
&& ((!runSideRailVisible(columns) && interaction.activeView === "run")
|
|
197
|
+
|| (interaction.activeView !== "run" && (rowIds?.length ?? 0) > 0))),
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
key: "⏎",
|
|
201
|
+
label: "Open",
|
|
202
|
+
matches: ({ key }) => key.return === true || key.rightArrow === true,
|
|
203
|
+
apply: (state, _event, context) => {
|
|
204
|
+
const columns = context?.columns ?? UNMEASURED_COLUMNS;
|
|
205
|
+
if (railFocused(state, columns) && state.tab === "setup")
|
|
206
|
+
return openSetupSection(state);
|
|
207
|
+
if (railFocused(state, columns))
|
|
208
|
+
return openView(state, state.railSelection);
|
|
209
|
+
if (state.tab === "setup"
|
|
210
|
+
|| state.activeView === "run"
|
|
211
|
+
|| state.selection === null
|
|
212
|
+
|| !(context?.rowIds.includes(state.selection) ?? false))
|
|
213
|
+
return state;
|
|
214
|
+
return { ...state, opened: state.selection };
|
|
215
|
+
},
|
|
216
|
+
available: ({ interaction, columns, rowIds }) => railFocused(interaction, columns)
|
|
217
|
+
|| (interaction.tab === "watch"
|
|
218
|
+
&& interaction.activeView !== "run"
|
|
219
|
+
&& interaction.opened === null
|
|
220
|
+
&& interaction.selection !== null
|
|
221
|
+
&& (rowIds?.includes(interaction.selection) ?? false)
|
|
222
|
+
&& !interaction.help),
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
key: "←",
|
|
226
|
+
label: "Back",
|
|
227
|
+
matches: ({ key }) => key.leftArrow === true || key.escape === true,
|
|
228
|
+
apply: (state, _event, context) => {
|
|
229
|
+
const columns = context?.columns ?? UNMEASURED_COLUMNS;
|
|
230
|
+
if (state.opened !== null)
|
|
231
|
+
return { ...state, opened: null };
|
|
232
|
+
if (railFocused(state, columns))
|
|
233
|
+
return state;
|
|
234
|
+
if (state.tab === "watch" && state.activeView === "run")
|
|
235
|
+
return state;
|
|
236
|
+
// A width that hides the rail gives back no sidebar to hand focus to, so
|
|
237
|
+
// there back steps out of the view itself and returns to the overview.
|
|
238
|
+
if (state.tab === "watch" && !runSideRailVisible(columns)) {
|
|
239
|
+
return { ...state, activeView: "run", railSelection: 0, selection: null };
|
|
240
|
+
}
|
|
241
|
+
return { ...state, panel: SIDEBAR_PANEL };
|
|
242
|
+
},
|
|
243
|
+
available: ({ interaction, columns }) => interaction.opened !== null
|
|
244
|
+
|| (!railFocused(interaction, columns)
|
|
245
|
+
&& ((interaction.tab === "setup" && runSideRailVisible(columns))
|
|
246
|
+
|| (interaction.tab === "watch" && interaction.activeView !== "run"))),
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
key: "Tab",
|
|
250
|
+
label: TAB_DESTINATION_DECISIONS,
|
|
251
|
+
destination: tabDestination,
|
|
252
|
+
matches: ({ key }) => key.tab === true,
|
|
253
|
+
apply: switchTab,
|
|
254
|
+
// Tab is a top-level surface action, not a sidebar action: it is offered
|
|
255
|
+
// and dispatchable at every supported width, including the widths that
|
|
256
|
+
// hide the sidebar. One predicate governs the advertisement and the
|
|
257
|
+
// switch together, and that predicate is the surface's, not the rail's.
|
|
258
|
+
available: () => true,
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
key: "?",
|
|
262
|
+
label: "Help",
|
|
263
|
+
matches: ({ input }) => input === "?",
|
|
264
|
+
apply: (state) => ({ ...state, help: !state.help }),
|
|
265
|
+
available: () => true,
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
key: "q",
|
|
269
|
+
label: "Quit",
|
|
270
|
+
matches: ({ input }) => input === "q",
|
|
271
|
+
apply: (state) => ({ ...state, quit: true }),
|
|
272
|
+
available: ({ interaction }) => !interaction.quit,
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
key: "f",
|
|
276
|
+
label: "Follow",
|
|
277
|
+
matches: ({ input }) => input === "f",
|
|
278
|
+
apply: (state) => state.tab === "watch"
|
|
279
|
+
&& (state.activeView === "run" || state.activeView === "journal")
|
|
280
|
+
? { ...state, follow: !state.follow }
|
|
281
|
+
: state,
|
|
282
|
+
report: (state) => toggleReport("Follow", state.follow),
|
|
283
|
+
available: ({ interaction }) => interaction.tab === "watch"
|
|
284
|
+
&& (interaction.activeView === "run" || interaction.activeView === "journal"),
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
key: "/",
|
|
288
|
+
label: "Filter",
|
|
289
|
+
matches: ({ input }) => input === "/",
|
|
290
|
+
apply: (state, _event, context) => state.tab === "setup"
|
|
291
|
+
|| state.activeView === "run"
|
|
292
|
+
|| state.activeView === "journal"
|
|
293
|
+
|| railFocused(state, context?.columns ?? UNMEASURED_COLUMNS)
|
|
294
|
+
|| state.opened !== null
|
|
295
|
+
|| state.help
|
|
296
|
+
? state
|
|
297
|
+
: { ...state, filterPrompt: true, filterQuery: "" },
|
|
298
|
+
available: ({ interaction, columns }) => !railFocused(interaction, columns)
|
|
299
|
+
&& interaction.tab === "watch"
|
|
300
|
+
&& interaction.activeView !== "run"
|
|
301
|
+
// The journal is a tail, never a list: nothing filters it.
|
|
302
|
+
&& interaction.activeView !== "journal"
|
|
303
|
+
&& interaction.opened === null
|
|
304
|
+
&& !interaction.help,
|
|
305
|
+
},
|
|
306
|
+
];
|
|
307
|
+
const RUN_VIEWS_INDEX = {
|
|
308
|
+
run: 0,
|
|
309
|
+
tasks: 1,
|
|
310
|
+
gates: 2,
|
|
311
|
+
journal: 3,
|
|
312
|
+
fleet: 4,
|
|
313
|
+
};
|
|
314
|
+
/**
|
|
315
|
+
* Number jumps name a view, and a digit means that one view on the whole
|
|
316
|
+
* surface — one key, one meaning, everywhere. Pressed on the setup tab, which
|
|
317
|
+
* holds sections rather than views, a digit switches to the tab that holds
|
|
318
|
+
* the views and lands on the one it numbers; setup waits behind with its own
|
|
319
|
+
* state intact for the round trip.
|
|
320
|
+
*/
|
|
321
|
+
export const RUN_VIEW_KEY_BINDING = {
|
|
322
|
+
key: "1–5",
|
|
323
|
+
label: "Move",
|
|
324
|
+
matches: ({ input }) => /^\d$/u.test(input),
|
|
325
|
+
apply: (state, { input }) => {
|
|
326
|
+
const view = runViewForNumber(input);
|
|
327
|
+
if (view === undefined)
|
|
328
|
+
return state;
|
|
329
|
+
const jumped = openView(state, RUN_VIEWS_INDEX[view.id]);
|
|
330
|
+
return state.tab === "setup" ? { ...jumped, tab: "watch" } : jumped;
|
|
331
|
+
},
|
|
332
|
+
available: () => true,
|
|
333
|
+
};
|
|
334
|
+
/** The filter prompt's own advertised and executable input roster. */
|
|
335
|
+
export const RUN_FILTER_PROMPT_BINDINGS = [
|
|
336
|
+
{
|
|
337
|
+
key: "text",
|
|
338
|
+
label: "Edit",
|
|
339
|
+
scope: "filter-prompt",
|
|
340
|
+
matches: ({ input, key }) => input.length === 1
|
|
341
|
+
&& key.ctrl !== true
|
|
342
|
+
&& key.return !== true
|
|
343
|
+
&& key.escape !== true
|
|
344
|
+
&& key.backspace !== true
|
|
345
|
+
&& key.delete !== true,
|
|
346
|
+
apply: (state, { input }) => ({
|
|
347
|
+
...state,
|
|
348
|
+
filterQuery: state.filterQuery + input,
|
|
349
|
+
}),
|
|
350
|
+
available: () => true,
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
key: "⌫",
|
|
354
|
+
label: "Delete",
|
|
355
|
+
scope: "filter-prompt",
|
|
356
|
+
matches: ({ key }) => key.backspace === true || key.delete === true,
|
|
357
|
+
apply: (state) => ({
|
|
358
|
+
...state,
|
|
359
|
+
filterQuery: [...state.filterQuery].slice(0, -1).join(""),
|
|
360
|
+
}),
|
|
361
|
+
available: ({ interaction }) => interaction.filterQuery.length > 0,
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
key: "⏎",
|
|
365
|
+
label: "Apply",
|
|
366
|
+
scope: "filter-prompt",
|
|
367
|
+
matches: ({ key }) => key.return === true,
|
|
368
|
+
apply: (state) => ({ ...state, filterPrompt: false }),
|
|
369
|
+
available: () => true,
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
key: "Esc",
|
|
373
|
+
label: "Cancel",
|
|
374
|
+
scope: "filter-prompt",
|
|
375
|
+
matches: ({ key }) => key.escape === true,
|
|
376
|
+
apply: (state) => ({ ...state, filterPrompt: false, filterQuery: "" }),
|
|
377
|
+
available: () => true,
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
// Tab belongs to the surface, not to the view under it: an open prompt
|
|
381
|
+
// narrows one view's rows and never takes the key that leaves the tab.
|
|
382
|
+
// The prompt keeps its query while the other tab is drawn, so tabbing
|
|
383
|
+
// back returns to the prompt exactly as it was left.
|
|
384
|
+
key: "Tab",
|
|
385
|
+
label: TAB_DESTINATION_DECISIONS,
|
|
386
|
+
destination: tabDestination,
|
|
387
|
+
scope: "filter-prompt",
|
|
388
|
+
matches: ({ key }) => key.tab === true,
|
|
389
|
+
apply: switchTab,
|
|
390
|
+
available: () => true,
|
|
391
|
+
},
|
|
392
|
+
];
|
|
393
|
+
/** Every input binding the live run cockpit dispatches or the prompt owns. */
|
|
394
|
+
export const RUN_INPUT_BINDINGS = [
|
|
395
|
+
...RUN_KEY_BINDINGS,
|
|
396
|
+
RUN_VIEW_KEY_BINDING,
|
|
397
|
+
...RUN_FILTER_PROMPT_BINDINGS,
|
|
398
|
+
];
|
|
399
|
+
/** The panels the setup surface cycles through, in their painted order. */
|
|
400
|
+
export const SETUP_PANEL_FOCUS_ORDER = ["SETUP", "HARNESSES", "KEYS"];
|
|
401
|
+
export function initialSetupInteractionState() {
|
|
402
|
+
return {
|
|
403
|
+
selection: -1,
|
|
404
|
+
panel: 1,
|
|
405
|
+
step: 2,
|
|
406
|
+
selected: [],
|
|
407
|
+
all: false,
|
|
408
|
+
probeRevision: 0,
|
|
409
|
+
savePrompt: false,
|
|
410
|
+
saved: false,
|
|
411
|
+
help: false,
|
|
412
|
+
quit: false,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
function setupStep(state, delta) {
|
|
416
|
+
return { ...state, step: Math.min(6, Math.max(1, state.step + delta)) };
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Every setup advertisement and handler, in the exact order the surface has
|
|
420
|
+
* always drawn. The global six are registered here with setup semantics; the
|
|
421
|
+
* six setup-only bindings follow them on the same surface registry.
|
|
422
|
+
*/
|
|
423
|
+
export const SETUP_KEY_BINDINGS = [
|
|
424
|
+
{
|
|
425
|
+
key: "↑↓",
|
|
426
|
+
label: "Move",
|
|
427
|
+
matches: ({ key }) => key.upArrow === true || key.downArrow === true,
|
|
428
|
+
apply: (state, { key }) => ({
|
|
429
|
+
...state,
|
|
430
|
+
selection: key.upArrow === true
|
|
431
|
+
? Math.max(0, state.selection - 1)
|
|
432
|
+
: state.selection + 1,
|
|
433
|
+
}),
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
key: "⏎",
|
|
437
|
+
label: "Next",
|
|
438
|
+
matches: ({ key }) => key.return === true,
|
|
439
|
+
apply: (state) => setupStep(state, 1),
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
key: "←",
|
|
443
|
+
label: "Back",
|
|
444
|
+
matches: ({ key }) => key.leftArrow === true,
|
|
445
|
+
apply: (state) => setupStep(state, -1),
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
key: "Tab",
|
|
449
|
+
label: "Panel",
|
|
450
|
+
matches: ({ key }) => key.tab === true,
|
|
451
|
+
apply: (state) => ({
|
|
452
|
+
...state,
|
|
453
|
+
panel: (state.panel + 1) % SETUP_PANEL_FOCUS_ORDER.length,
|
|
454
|
+
}),
|
|
455
|
+
},
|
|
456
|
+
{
|
|
457
|
+
key: "?",
|
|
458
|
+
label: "Help",
|
|
459
|
+
matches: ({ input }) => input === "?",
|
|
460
|
+
apply: (state) => ({ ...state, help: !state.help }),
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
key: "q",
|
|
464
|
+
label: "Quit",
|
|
465
|
+
matches: ({ input }) => input === "q",
|
|
466
|
+
apply: (state) => ({ ...state, quit: true }),
|
|
467
|
+
},
|
|
468
|
+
{
|
|
469
|
+
key: "␣",
|
|
470
|
+
label: "Toggle",
|
|
471
|
+
matches: ({ input }) => input === " ",
|
|
472
|
+
apply: (state) => {
|
|
473
|
+
const selection = Math.max(0, state.selection);
|
|
474
|
+
const selected = state.selected.includes(selection)
|
|
475
|
+
? state.selected.filter((index) => index !== selection)
|
|
476
|
+
: [...state.selected, selection];
|
|
477
|
+
return { ...state, selection, selected, all: false };
|
|
478
|
+
},
|
|
479
|
+
report: (state) => toggleReport("Selection", state.all || state.selected.length > 0),
|
|
480
|
+
},
|
|
481
|
+
{
|
|
482
|
+
key: "a",
|
|
483
|
+
label: "All",
|
|
484
|
+
matches: ({ input }) => input === "a",
|
|
485
|
+
apply: (state) => ({ ...state, all: true, selected: [] }),
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
key: "r",
|
|
489
|
+
label: "Re-probe",
|
|
490
|
+
matches: ({ input }) => input === "r",
|
|
491
|
+
apply: (state) => ({ ...state, probeRevision: state.probeRevision + 1 }),
|
|
492
|
+
},
|
|
493
|
+
{
|
|
494
|
+
key: "s",
|
|
495
|
+
label: "Save",
|
|
496
|
+
matches: ({ input }) => input === "s",
|
|
497
|
+
apply: (state) => ({ ...state, savePrompt: true }),
|
|
498
|
+
report: (state) => toggleReport("Saved", state.saved),
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
key: "n",
|
|
502
|
+
label: "Next",
|
|
503
|
+
matches: ({ input }) => input === "n",
|
|
504
|
+
apply: (state) => setupStep(state, 1),
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
key: "p",
|
|
508
|
+
label: "Prev",
|
|
509
|
+
matches: ({ input }) => input === "p",
|
|
510
|
+
apply: (state) => setupStep(state, -1),
|
|
511
|
+
},
|
|
512
|
+
];
|
|
513
|
+
/** One surface-aware registry serves both cockpit renderers and dispatchers. */
|
|
514
|
+
export const SURFACE_KEY_BINDINGS = {
|
|
515
|
+
run: RUN_KEY_BINDINGS,
|
|
516
|
+
setup: SETUP_KEY_BINDINGS,
|
|
517
|
+
};
|
|
518
|
+
/** Save confirmation owns input until it is accepted or dismissed. */
|
|
519
|
+
export function applySetupPromptInput(event, state) {
|
|
520
|
+
if (!state.savePrompt)
|
|
521
|
+
return undefined;
|
|
522
|
+
if (event.input === "y" || event.key.return === true) {
|
|
523
|
+
return { ...state, savePrompt: false, saved: true };
|
|
524
|
+
}
|
|
525
|
+
if (event.input === "n" || event.key.escape === true) {
|
|
526
|
+
return { ...state, savePrompt: false };
|
|
527
|
+
}
|
|
528
|
+
return state;
|
|
529
|
+
}
|
|
530
|
+
/** The lossless text vocabulary shared by frame and plain advertisements. */
|
|
531
|
+
export function formatKeybarEntries(entries) {
|
|
532
|
+
return entries.map(({ key, label }) => `${key} ${label}`).join(" · ");
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* THE one context projection over the one run registry. Consumers receive its
|
|
536
|
+
* return value whole; none may filter, append or independently map bindings.
|
|
537
|
+
*/
|
|
538
|
+
export function projectRunKeyEntries(context, bindings = RUN_INPUT_BINDINGS) {
|
|
539
|
+
const scope = context.interaction.filterPrompt ? "filter-prompt" : "normal";
|
|
540
|
+
return bindings
|
|
541
|
+
.filter((binding) => (binding.scope ?? "normal") === scope)
|
|
542
|
+
.filter((binding) => binding.available?.(context) ?? true)
|
|
543
|
+
.map((binding) => ({
|
|
544
|
+
key: binding.key,
|
|
545
|
+
label: binding.report?.(context.interaction)
|
|
546
|
+
?? binding.destination?.(context.interaction)
|
|
547
|
+
?? binding.label,
|
|
548
|
+
}));
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* The keybar's entries, derived from a binding registry. This is the only way
|
|
552
|
+
* a keybar list is produced: remove a handler and its entry disappears.
|
|
553
|
+
*/
|
|
554
|
+
export function keybarEntries(bindings) {
|
|
555
|
+
return bindings.map(({ key, label }) => ({ key, label }));
|
|
556
|
+
}
|
|
557
|
+
/** The binding a key event activates, or undefined when no handler owns it. */
|
|
558
|
+
export function resolveRunKeyBinding(bindings, event) {
|
|
559
|
+
return bindings.find((binding) => binding.matches(event));
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Re-establish the row invariant after a derivation or filter changes. The
|
|
563
|
+
* identities themselves are authoritative: an in-range position is never a
|
|
564
|
+
* substitute for the row that used to occupy it.
|
|
565
|
+
*/
|
|
566
|
+
export function reconcileRunInteraction(state, rowIds) {
|
|
567
|
+
const available = new Set(rowIds);
|
|
568
|
+
const selection = state.selection !== null && available.has(state.selection)
|
|
569
|
+
? state.selection
|
|
570
|
+
: null;
|
|
571
|
+
const opened = state.opened !== null && available.has(state.opened)
|
|
572
|
+
? state.opened
|
|
573
|
+
: null;
|
|
574
|
+
return selection === state.selection && opened === state.opened
|
|
575
|
+
? state
|
|
576
|
+
: { ...state, selection, opened };
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Send a key event to the registry: the matching binding's handler produces
|
|
580
|
+
* the next interaction state. An event no handler owns changes nothing — and
|
|
581
|
+
* while the / prompt is open the prompt's own scope is the only roster that
|
|
582
|
+
* owns anything, so a character grows the query rather than quitting the
|
|
583
|
+
* surface. One dispatcher decides that, so no caller can own the prompt twice.
|
|
584
|
+
*/
|
|
585
|
+
export function dispatchRunKey(event, state, bindings = RUN_INPUT_BINDINGS, rowIds = [], columns = UNMEASURED_COLUMNS) {
|
|
586
|
+
const scope = state.filterPrompt ? "filter-prompt" : "normal";
|
|
587
|
+
const context = { interaction: state, columns, rowIds };
|
|
588
|
+
const binding = resolveRunKeyBinding(bindings.filter((candidate) => (candidate.scope ?? "normal") === scope
|
|
589
|
+
// A key acts exactly where it is advertised. One predicate decides both,
|
|
590
|
+
// so a keybar that hides a key cannot leave its handler live behind it.
|
|
591
|
+
&& (candidate.available?.(context) ?? true)), event);
|
|
592
|
+
const next = binding?.apply(state, event, { rowIds, columns });
|
|
593
|
+
return next === undefined ? undefined : reconcileRunInteraction(next, rowIds);
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Send a key event to the whole surface. The registry owns every transition,
|
|
597
|
+
* including the tab flip; the surface only decides what a flipped tab means —
|
|
598
|
+
* the tab that was waiting comes forward carrying the state it was left in, and
|
|
599
|
+
* the tab that was drawn is what waits now.
|
|
600
|
+
*/
|
|
601
|
+
export function dispatchRunSurfaceKey(event, surface, bindings = RUN_INPUT_BINDINGS, rowIds = [], columns = UNMEASURED_COLUMNS) {
|
|
602
|
+
const next = dispatchRunKey(event, surface.interaction, bindings, rowIds, columns);
|
|
603
|
+
if (next === undefined)
|
|
604
|
+
return undefined;
|
|
605
|
+
if (next.tab !== surface.interaction.tab) {
|
|
606
|
+
const flipOnly = Object.keys(next)
|
|
607
|
+
.every((field) => field === "tab" || next[field] === surface.interaction[field]);
|
|
608
|
+
if (flipOnly) {
|
|
609
|
+
// A pure switch: the tab that was drawn waits behind with everything it
|
|
610
|
+
// had, and the tab that was waiting comes forward exactly as it was left.
|
|
611
|
+
return {
|
|
612
|
+
interaction: { ...surface.stashed, tab: next.tab },
|
|
613
|
+
stashed: surface.interaction,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
// The key changed more than the tab — a digit pressed on the sections tab
|
|
617
|
+
// means its view on the tab it switches to. The event is replayed against
|
|
618
|
+
// the tab that was waiting, so the change lands on that tab's own state
|
|
619
|
+
// rather than on a coincidence of the tab it was pressed on.
|
|
620
|
+
const replayed = dispatchRunKey(event, { ...surface.stashed, tab: next.tab }, bindings, rowIds, columns);
|
|
621
|
+
if (replayed === undefined)
|
|
622
|
+
return undefined;
|
|
623
|
+
return { interaction: replayed, stashed: surface.interaction };
|
|
624
|
+
}
|
|
625
|
+
return next === surface.interaction ? surface : { ...surface, interaction: next };
|
|
626
|
+
}
|
|
627
|
+
/** The setup binding a key event activates, or undefined when none owns it. */
|
|
628
|
+
export function resolveSetupKeyBinding(bindings, event) {
|
|
629
|
+
return bindings.find((binding) => binding.matches(event));
|
|
630
|
+
}
|
|
631
|
+
/** Send a key event through the setup half of the shared surface registry. */
|
|
632
|
+
export function dispatchSetupKey(event, state, bindings = SETUP_KEY_BINDINGS) {
|
|
633
|
+
return resolveSetupKeyBinding(bindings, event)?.apply(state, event);
|
|
634
|
+
}
|