tickmarkr 1.81.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.js +13 -1
- 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 +12 -46
- package/dist/tui/cockpit/components.js +48 -48
- package/dist/tui/cockpit/derive.d.ts +73 -1
- package/dist/tui/cockpit/derive.js +211 -15
- package/dist/tui/cockpit/keys.d.ts +114 -19
- package/dist/tui/cockpit/keys.js +413 -44
- package/dist/tui/cockpit/layout.d.ts +162 -0
- package/dist/tui/cockpit/layout.js +229 -0
- package/dist/tui/cockpit/live.d.ts +52 -3
- package/dist/tui/cockpit/live.js +216 -40
- package/dist/tui/cockpit/run-cockpit.d.ts +64 -8
- package/dist/tui/cockpit/run-cockpit.js +686 -163
- package/dist/tui/cockpit/setup-cockpit.d.ts +146 -1
- package/dist/tui/cockpit/setup-cockpit.js +337 -5
- 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
package/dist/tui/cockpit/keys.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { GLYPHS } from "../../brand.js";
|
|
2
|
+
import { moveRunViewSelection, moveSetupSectionSelection, runViewAt, runViewForNumber, } from "./views.js";
|
|
2
3
|
export function initialRunInteractionState() {
|
|
3
4
|
return {
|
|
4
|
-
|
|
5
|
+
tab: "watch",
|
|
6
|
+
selection: null,
|
|
5
7
|
panel: 0,
|
|
8
|
+
railSelection: 0,
|
|
9
|
+
activeView: "run",
|
|
6
10
|
opened: null,
|
|
7
11
|
follow: false,
|
|
8
12
|
filterPrompt: false,
|
|
@@ -12,10 +16,111 @@ export function initialRunInteractionState() {
|
|
|
12
16
|
};
|
|
13
17
|
}
|
|
14
18
|
/**
|
|
15
|
-
* The panels
|
|
16
|
-
* the
|
|
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.
|
|
17
74
|
*/
|
|
18
|
-
|
|
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";
|
|
19
124
|
/** A toggle's on/off report: the brand's reserved glyphs, never brackets. */
|
|
20
125
|
export function toggleReport(label, on) {
|
|
21
126
|
return `${label} ${on ? GLYPHS.toggleActive : GLYPHS.toggleInactive}`;
|
|
@@ -29,58 +134,268 @@ export const RUN_KEY_BINDINGS = [
|
|
|
29
134
|
key: "↑↓",
|
|
30
135
|
label: "Move",
|
|
31
136
|
matches: ({ key }) => key.upArrow === true || key.downArrow === true,
|
|
32
|
-
apply: (state, { key }) =>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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))),
|
|
36
198
|
},
|
|
37
199
|
{
|
|
38
200
|
key: "⏎",
|
|
39
201
|
label: "Open",
|
|
40
|
-
matches: ({ key }) => key.return === true,
|
|
41
|
-
apply: (state) =>
|
|
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),
|
|
42
223
|
},
|
|
43
224
|
{
|
|
44
225
|
key: "←",
|
|
45
226
|
label: "Back",
|
|
46
|
-
matches: ({ key }) => key.leftArrow === true,
|
|
47
|
-
apply: (state) =>
|
|
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"))),
|
|
48
247
|
},
|
|
49
248
|
{
|
|
50
249
|
key: "Tab",
|
|
51
|
-
label:
|
|
250
|
+
label: TAB_DESTINATION_DECISIONS,
|
|
251
|
+
destination: tabDestination,
|
|
52
252
|
matches: ({ key }) => key.tab === true,
|
|
53
|
-
apply:
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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,
|
|
57
259
|
},
|
|
58
260
|
{
|
|
59
261
|
key: "?",
|
|
60
262
|
label: "Help",
|
|
61
263
|
matches: ({ input }) => input === "?",
|
|
62
264
|
apply: (state) => ({ ...state, help: !state.help }),
|
|
265
|
+
available: () => true,
|
|
63
266
|
},
|
|
64
267
|
{
|
|
65
268
|
key: "q",
|
|
66
269
|
label: "Quit",
|
|
67
270
|
matches: ({ input }) => input === "q",
|
|
68
271
|
apply: (state) => ({ ...state, quit: true }),
|
|
272
|
+
available: ({ interaction }) => !interaction.quit,
|
|
69
273
|
},
|
|
70
274
|
{
|
|
71
275
|
key: "f",
|
|
72
276
|
label: "Follow",
|
|
73
277
|
matches: ({ input }) => input === "f",
|
|
74
|
-
apply: (state) =>
|
|
278
|
+
apply: (state) => state.tab === "watch"
|
|
279
|
+
&& (state.activeView === "run" || state.activeView === "journal")
|
|
280
|
+
? { ...state, follow: !state.follow }
|
|
281
|
+
: state,
|
|
75
282
|
report: (state) => toggleReport("Follow", state.follow),
|
|
283
|
+
available: ({ interaction }) => interaction.tab === "watch"
|
|
284
|
+
&& (interaction.activeView === "run" || interaction.activeView === "journal"),
|
|
76
285
|
},
|
|
77
286
|
{
|
|
78
287
|
key: "/",
|
|
79
288
|
label: "Filter",
|
|
80
289
|
matches: ({ input }) => input === "/",
|
|
81
|
-
apply: (state) =>
|
|
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,
|
|
82
391
|
},
|
|
83
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
|
+
];
|
|
84
399
|
/** The panels the setup surface cycles through, in their painted order. */
|
|
85
400
|
export const SETUP_PANEL_FOCUS_ORDER = ["SETUP", "HARNESSES", "KEYS"];
|
|
86
401
|
export function initialSetupInteractionState() {
|
|
@@ -212,29 +527,25 @@ export function applySetupPromptInput(event, state) {
|
|
|
212
527
|
}
|
|
213
528
|
return state;
|
|
214
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
|
+
}
|
|
215
534
|
/**
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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.
|
|
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.
|
|
221
537
|
*/
|
|
222
|
-
export function
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
if (input.length === 1 && key.ctrl !== true) {
|
|
235
|
-
return { ...state, filterQuery: state.filterQuery + input };
|
|
236
|
-
}
|
|
237
|
-
return state;
|
|
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
|
+
}));
|
|
238
549
|
}
|
|
239
550
|
/**
|
|
240
551
|
* The keybar's entries, derived from a binding registry. This is the only way
|
|
@@ -247,13 +558,71 @@ export function keybarEntries(bindings) {
|
|
|
247
558
|
export function resolveRunKeyBinding(bindings, event) {
|
|
248
559
|
return bindings.find((binding) => binding.matches(event));
|
|
249
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
|
+
}
|
|
250
578
|
/**
|
|
251
579
|
* 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
|
|
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.
|
|
253
584
|
*/
|
|
254
|
-
export function dispatchRunKey(event, state, bindings =
|
|
255
|
-
const
|
|
256
|
-
|
|
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 };
|
|
257
626
|
}
|
|
258
627
|
/** The setup binding a key event activates, or undefined when none owns it. */
|
|
259
628
|
export function resolveSetupKeyBinding(bindings, event) {
|