@cueloop/client 0.1.0-alpha.2 → 0.1.0-alpha.20
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/package.json +17 -11
- package/src/App.autoclose.test.tsx +109 -0
- package/src/App.diff.test.tsx +5 -18
- package/src/App.export.test.tsx +8 -13
- package/src/App.plan-review.test.tsx +232 -0
- package/src/App.readonly.test.tsx +6 -21
- package/src/App.rendering.test.tsx +100 -0
- package/src/App.submit-confirm.test.tsx +165 -0
- package/src/App.test.tsx +20 -58
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +208 -0
- package/src/components/AnnotationCard.stories.tsx +75 -0
- package/src/components/AnnotationCard.tsx +136 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +38 -0
- package/src/components/CodeBlock.stories.tsx +25 -0
- package/src/components/CodeBlock.tsx +68 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +49 -0
- package/src/components/ComposeBar.stories.tsx +18 -0
- package/src/components/ComposeBar.tsx +54 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +117 -0
- package/src/components/DiffSheet.stories.tsx +44 -0
- package/src/components/DiffSheet.tsx +243 -0
- package/src/components/InboxList.stories.tsx +18 -0
- package/src/components/InboxList.tsx +42 -0
- package/src/components/PlanSheet.stories.tsx +81 -0
- package/src/components/PlanSheet.tsx +309 -0
- package/src/components/ReviewRail.stories.tsx +103 -0
- package/src/components/ReviewRail.tsx +158 -0
- package/src/components/SettingsDialog.stories.tsx +63 -0
- package/src/components/SettingsDialog.tsx +132 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +92 -0
- package/src/components/WalkWizard.stories.tsx +89 -0
- package/src/components/WalkWizard.tsx +146 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1319 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +44 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +58 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +52 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +97 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/stories-app.tsx +94 -0
- package/src/components/stories.test.tsx +51 -0
- package/src/components/story-fixtures.ts +90 -0
- package/src/components/story.ts +66 -0
- package/src/components/syntax-highlight.ts +57 -0
- package/src/components/theme-context.stories.tsx +41 -0
- package/src/components/theme-context.tsx +30 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +54 -29
- package/src/config.ts +53 -13
- package/src/editor.test.ts +110 -0
- package/src/editor.ts +101 -13
- package/src/herdr.test.ts +37 -0
- package/src/herdr.ts +21 -0
- package/src/integrations.test.ts +29 -0
- package/src/integrations.ts +28 -0
- package/src/key-bindings.test.ts +120 -0
- package/src/key-bindings.ts +353 -0
- package/src/keymap.test.ts +307 -0
- package/src/keymap.ts +204 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +6 -6
- package/src/serve.ts +7 -7
- package/src/session-controller.ts +496 -0
- package/src/test-support.ts +87 -0
- package/src/theme.ts +23 -1
- package/src/view-diff.test.ts +8 -8
- package/src/view-diff.ts +22 -22
- package/src/view-plan.test.ts +179 -0
- package/src/view-plan.ts +362 -0
- package/src/walk.test.ts +110 -0
- package/src/walk.ts +65 -0
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binding resolution over @opentui/keymap: the loaded config keymap
|
|
3
|
+
* registers as layers (grammar, span, compose, submit, completion) on one
|
|
4
|
+
* Keymap instance, key events resolve to action names through its dispatch,
|
|
5
|
+
* and the status-line hints generate from getActiveKeys() - a rebound key
|
|
6
|
+
* shows its real binding in the hints. The pure intent reducer (keymap.ts)
|
|
7
|
+
* stays the dispatch layer beneath: this module answers "which action", the
|
|
8
|
+
* reducer answers "which intents".
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Keymap, type KeymapEvent, type KeymapHost } from "@opentui/keymap";
|
|
12
|
+
import { registerDefaultKeys } from "@opentui/keymap/addons";
|
|
13
|
+
import type { KeyState } from "./keymap";
|
|
14
|
+
|
|
15
|
+
export interface ResolvableKey {
|
|
16
|
+
name: string;
|
|
17
|
+
shift: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The layer-activation slice of the view state. */
|
|
21
|
+
export interface KeyLayerContext {
|
|
22
|
+
overlay: KeyState["overlay"];
|
|
23
|
+
spanMode: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Hint templates keyed by the same view states the status line shows. */
|
|
27
|
+
export type HintMode = "normal" | "card" | "span" | "compose" | "submit" | "walk" | "read-only";
|
|
28
|
+
|
|
29
|
+
type HintEntry =
|
|
30
|
+
| { text: string }
|
|
31
|
+
| { commands: string[]; label: string; labelFirst?: boolean };
|
|
32
|
+
|
|
33
|
+
const HINT_TEMPLATES: Record<HintMode, HintEntry[]> = {
|
|
34
|
+
normal: [
|
|
35
|
+
{ commands: ["down", "up"], label: "move" },
|
|
36
|
+
{ commands: ["span"], label: "span" },
|
|
37
|
+
{ text: "drag selects" },
|
|
38
|
+
{ commands: ["comment"], label: "comment" },
|
|
39
|
+
{ commands: ["suggest"], label: "suggest" },
|
|
40
|
+
{ commands: ["cut"], label: "cut" },
|
|
41
|
+
{ commands: ["edit"], label: "edit" },
|
|
42
|
+
{ commands: ["next_annotation", "prev_annotation"], label: "annotations" },
|
|
43
|
+
{ commands: ["submit"], label: "submit" },
|
|
44
|
+
{ commands: ["quit"], label: "quit" },
|
|
45
|
+
],
|
|
46
|
+
card: [
|
|
47
|
+
{ text: "card" },
|
|
48
|
+
{ commands: ["edit"], label: "edit" },
|
|
49
|
+
{ commands: ["cut"], label: "Cut" },
|
|
50
|
+
{ commands: ["next_annotation", "prev_annotation"], label: "cards" },
|
|
51
|
+
{ commands: ["deselect"], label: "deselect" },
|
|
52
|
+
{ commands: ["submit"], label: "submit" },
|
|
53
|
+
],
|
|
54
|
+
span: [
|
|
55
|
+
{ text: "span" },
|
|
56
|
+
{ commands: ["span_grow", "span_shrink"], label: "grow/shrink" },
|
|
57
|
+
{ commands: ["span_slide_forward", "span_slide_back"], label: "slide" },
|
|
58
|
+
{ commands: ["span_to_end"], label: "end" },
|
|
59
|
+
{ commands: ["span_comment"], label: "comment" },
|
|
60
|
+
{ commands: ["span_suggest"], label: "suggest" },
|
|
61
|
+
{ commands: ["span_cancel"], label: "" },
|
|
62
|
+
],
|
|
63
|
+
compose: [
|
|
64
|
+
{ text: "typing" },
|
|
65
|
+
{ commands: ["save_compose"], label: "save" },
|
|
66
|
+
{ commands: ["cancel_overlay"], label: "cancel" },
|
|
67
|
+
],
|
|
68
|
+
submit: [
|
|
69
|
+
{ commands: ["cycle_verdict_left", "cycle_verdict_right"], label: "verdict", labelFirst: true },
|
|
70
|
+
{ commands: ["submit_verdict"], label: "submit" },
|
|
71
|
+
{ commands: ["cancel_submit"], label: "cancel" },
|
|
72
|
+
],
|
|
73
|
+
walk: [
|
|
74
|
+
{ text: "walk" },
|
|
75
|
+
{ commands: ["walk_next"], label: "next (marks viewed)" },
|
|
76
|
+
{ commands: ["walk_prev"], label: "back" },
|
|
77
|
+
{ commands: ["walk_leave"], label: "leave walk" },
|
|
78
|
+
{ commands: ["quit"], label: "quit" },
|
|
79
|
+
],
|
|
80
|
+
"read-only": [
|
|
81
|
+
{ text: "observer - read-only" },
|
|
82
|
+
{ commands: ["down", "up"], label: "move" },
|
|
83
|
+
{ commands: ["next_annotation", "prev_annotation"], label: "annotations" },
|
|
84
|
+
{ commands: ["quit"], label: "quit" },
|
|
85
|
+
],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Status-line glyphs for named keys. */
|
|
89
|
+
const KEY_GLYPHS: Record<string, string> = {
|
|
90
|
+
return: "⏎",
|
|
91
|
+
enter: "⏎",
|
|
92
|
+
escape: "esc",
|
|
93
|
+
left: "←",
|
|
94
|
+
right: "→",
|
|
95
|
+
backspace: "⌫",
|
|
96
|
+
down: "↓",
|
|
97
|
+
up: "↑",
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function makeKeymapEvent(name: string, shift = false): KeymapEvent {
|
|
101
|
+
return {
|
|
102
|
+
name,
|
|
103
|
+
ctrl: false,
|
|
104
|
+
shift,
|
|
105
|
+
meta: false,
|
|
106
|
+
preventDefault: () => {},
|
|
107
|
+
stopPropagation: () => {},
|
|
108
|
+
propagationStopped: false,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
class HeadlessKeymapHost implements KeymapHost<object, KeymapEvent> {
|
|
113
|
+
readonly metadata = {
|
|
114
|
+
platform: "unknown" as const,
|
|
115
|
+
primaryModifier: "unknown" as const,
|
|
116
|
+
modifiers: {
|
|
117
|
+
ctrl: "supported" as const,
|
|
118
|
+
shift: "supported" as const,
|
|
119
|
+
meta: "supported" as const,
|
|
120
|
+
super: "unknown" as const,
|
|
121
|
+
hyper: "unknown" as const,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
readonly rootTarget = {};
|
|
125
|
+
readonly isDestroyed = false;
|
|
126
|
+
private readonly pressListeners = new Set<(event: KeymapEvent) => void>();
|
|
127
|
+
|
|
128
|
+
getFocusedTarget(): object | null {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
getParentTarget(): object | null {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
isTargetDestroyed(): boolean {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
onKeyPress(listener: (event: KeymapEvent) => void): () => void {
|
|
138
|
+
this.pressListeners.add(listener);
|
|
139
|
+
return () => this.pressListeners.delete(listener);
|
|
140
|
+
}
|
|
141
|
+
onKeyRelease(): () => void {
|
|
142
|
+
return () => {};
|
|
143
|
+
}
|
|
144
|
+
onFocusChange(): () => void {
|
|
145
|
+
return () => {};
|
|
146
|
+
}
|
|
147
|
+
onTargetDestroy(): () => void {
|
|
148
|
+
return () => {};
|
|
149
|
+
}
|
|
150
|
+
createCommandEvent(): KeymapEvent {
|
|
151
|
+
return makeKeymapEvent("");
|
|
152
|
+
}
|
|
153
|
+
emitKeyPress(event: KeymapEvent): void {
|
|
154
|
+
for (const listener of this.pressListeners) listener(event);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Terminal key events name shifted letters either "g"+shift or "G"; the
|
|
160
|
+
* keymap's canonical form is lowercase + the shift modifier. Non-letter
|
|
161
|
+
* single characters ("$", "0") already encode their shift in the character.
|
|
162
|
+
*/
|
|
163
|
+
function normalizedEvent(key: ResolvableKey): { name: string; shift: boolean } {
|
|
164
|
+
if (key.name.length === 1) {
|
|
165
|
+
if (/[a-zA-Z]/.test(key.name)) {
|
|
166
|
+
return { name: key.name.toLowerCase(), shift: key.shift || key.name !== key.name.toLowerCase() };
|
|
167
|
+
}
|
|
168
|
+
return { name: key.name, shift: false };
|
|
169
|
+
}
|
|
170
|
+
return { name: key.name, shift: key.shift };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Config combos write shifted letters as uppercase; the parser wants shift+. */
|
|
174
|
+
function bindingKeyFor(combo: string): string {
|
|
175
|
+
if (combo.length === 1 && /[A-Z]/.test(combo)) return `shift+${combo.toLowerCase()}`;
|
|
176
|
+
return combo;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The grammar actions live in the base layer; these live in mode layers. */
|
|
180
|
+
const SPAN_COMMANDS: [key: string, command: string][] = [
|
|
181
|
+
["l", "span_grow"],
|
|
182
|
+
["h", "span_shrink"],
|
|
183
|
+
["w", "span_slide_forward"],
|
|
184
|
+
["b", "span_slide_back"],
|
|
185
|
+
["$", "span_to_end"],
|
|
186
|
+
["0", "span_to_start"],
|
|
187
|
+
["c", "span_comment"],
|
|
188
|
+
["s", "span_suggest"],
|
|
189
|
+
["escape", "span_cancel"],
|
|
190
|
+
];
|
|
191
|
+
|
|
192
|
+
export class KeyBindings {
|
|
193
|
+
private readonly host = new HeadlessKeymapHost();
|
|
194
|
+
private readonly keymap = new Keymap<object, KeymapEvent>(this.host);
|
|
195
|
+
private context: KeyLayerContext = { overlay: "none", spanMode: false };
|
|
196
|
+
private resolvedCommand: string | undefined;
|
|
197
|
+
private unregisterGrammarLayer: (() => void) | null = null;
|
|
198
|
+
|
|
199
|
+
constructor(keys: Record<string, string[]>) {
|
|
200
|
+
// the standard binding-string parser and event matcher
|
|
201
|
+
registerDefaultKeys(this.keymap);
|
|
202
|
+
// binding/layer `when` fields register runtime matchers, so getActiveKeys
|
|
203
|
+
// and dispatch only see the layer that owns the current mode
|
|
204
|
+
this.keymap.registerLayerFields({
|
|
205
|
+
when: (value, fieldContext) => {
|
|
206
|
+
if (typeof value === "function") fieldContext.activeWhen(value as () => boolean);
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
this.registerModeLayers();
|
|
210
|
+
this.setKeys(keys);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Rebuild the grammar layer from a (re)loaded config keymap. */
|
|
214
|
+
setKeys(keys: Record<string, string[]>): void {
|
|
215
|
+
this.unregisterGrammarLayer?.();
|
|
216
|
+
const bindings: { key: string; cmd: string }[] = [];
|
|
217
|
+
const commandNames = new Set<string>();
|
|
218
|
+
for (const [action, combos] of Object.entries(keys)) {
|
|
219
|
+
commandNames.add(action);
|
|
220
|
+
for (const combo of combos) bindings.push({ key: bindingKeyFor(combo), cmd: action });
|
|
221
|
+
}
|
|
222
|
+
// deselect is a hardwired escape in the plan grammar; hints show it
|
|
223
|
+
commandNames.add("deselect");
|
|
224
|
+
bindings.push({ key: "escape", cmd: "deselect" });
|
|
225
|
+
this.unregisterGrammarLayer = this.keymap.registerLayer({
|
|
226
|
+
priority: 0,
|
|
227
|
+
when: () => this.context.overlay === "none" && !this.context.spanMode,
|
|
228
|
+
bindings,
|
|
229
|
+
commands: [...commandNames].map((name) => ({
|
|
230
|
+
name,
|
|
231
|
+
run: () => {
|
|
232
|
+
this.resolvedCommand = name;
|
|
233
|
+
return true;
|
|
234
|
+
},
|
|
235
|
+
})),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private registerModeLayers(): void {
|
|
240
|
+
const record = (name: string) => ({
|
|
241
|
+
name,
|
|
242
|
+
run: () => {
|
|
243
|
+
this.resolvedCommand = name;
|
|
244
|
+
return true;
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
this.keymap.registerLayer({
|
|
248
|
+
priority: 10,
|
|
249
|
+
when: () => this.context.overlay === "none" && this.context.spanMode,
|
|
250
|
+
bindings: SPAN_COMMANDS.map(([key, command]) => ({ key, cmd: command })),
|
|
251
|
+
commands: SPAN_COMMANDS.map(([, command]) => record(command)),
|
|
252
|
+
});
|
|
253
|
+
// the walk wizard: prev/next stepping, leave, and submit from the end card
|
|
254
|
+
this.keymap.registerLayer({
|
|
255
|
+
priority: 20,
|
|
256
|
+
when: () => this.context.overlay === "walk",
|
|
257
|
+
bindings: [
|
|
258
|
+
{ key: "]", cmd: "walk_next" },
|
|
259
|
+
{ key: "[", cmd: "walk_prev" },
|
|
260
|
+
{ key: "escape", cmd: "walk_leave" },
|
|
261
|
+
{ key: "return", cmd: "walk_submit" },
|
|
262
|
+
{ key: "enter", cmd: "walk_submit" },
|
|
263
|
+
{ key: "q", cmd: "quit" },
|
|
264
|
+
],
|
|
265
|
+
commands: [record("walk_next"), record("walk_prev"), record("walk_leave"), record("walk_submit"), record("quit")],
|
|
266
|
+
});
|
|
267
|
+
this.keymap.registerLayer({
|
|
268
|
+
priority: 20,
|
|
269
|
+
when: () => this.context.overlay === "compose",
|
|
270
|
+
bindings: [
|
|
271
|
+
{ key: "return", cmd: "save_compose" },
|
|
272
|
+
{ key: "enter", cmd: "save_compose" },
|
|
273
|
+
{ key: "escape", cmd: "cancel_overlay" },
|
|
274
|
+
],
|
|
275
|
+
commands: [record("save_compose"), record("cancel_overlay")],
|
|
276
|
+
});
|
|
277
|
+
this.keymap.registerLayer({
|
|
278
|
+
priority: 20,
|
|
279
|
+
when: () => this.context.overlay === "submit",
|
|
280
|
+
bindings: [
|
|
281
|
+
{ key: "return", cmd: "submit_verdict" },
|
|
282
|
+
{ key: "enter", cmd: "submit_verdict" },
|
|
283
|
+
{ key: "left", cmd: "cycle_verdict_left" },
|
|
284
|
+
{ key: "right", cmd: "cycle_verdict_right" },
|
|
285
|
+
{ key: "escape", cmd: "cancel_submit" },
|
|
286
|
+
],
|
|
287
|
+
commands: [
|
|
288
|
+
record("submit_verdict"),
|
|
289
|
+
record("cycle_verdict_left"),
|
|
290
|
+
record("cycle_verdict_right"),
|
|
291
|
+
record("cancel_submit"),
|
|
292
|
+
],
|
|
293
|
+
});
|
|
294
|
+
this.keymap.registerLayer({
|
|
295
|
+
priority: 20,
|
|
296
|
+
when: () => this.context.overlay === "completion-prompt" || this.context.overlay === "completion-counting",
|
|
297
|
+
bindings: [
|
|
298
|
+
{ key: "return", cmd: "finish_review" },
|
|
299
|
+
{ key: "enter", cmd: "finish_review" },
|
|
300
|
+
{ key: "q", cmd: "finish_review" },
|
|
301
|
+
{ key: "a", cmd: "opt_in_auto_close" },
|
|
302
|
+
{ key: "escape", cmd: "dismiss_completion" },
|
|
303
|
+
],
|
|
304
|
+
commands: [record("finish_review"), record("opt_in_auto_close"), record("dismiss_completion")],
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
setContext(context: KeyLayerContext): void {
|
|
309
|
+
this.context = context;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Resolve a key event to its command name through the keymap's dispatch.
|
|
314
|
+
* In the base grammar layer the command IS the config action name, so the
|
|
315
|
+
* result feeds straight into the intent reducer.
|
|
316
|
+
*/
|
|
317
|
+
resolveAction(key: ResolvableKey): string | undefined {
|
|
318
|
+
const normalized = normalizedEvent(key);
|
|
319
|
+
this.resolvedCommand = undefined;
|
|
320
|
+
this.host.emitKeyPress(makeKeymapEvent(normalized.name, normalized.shift));
|
|
321
|
+
return this.resolvedCommand;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** First active key display for a command, mapped through the glyph table. */
|
|
325
|
+
private keyDisplayFor(command: string): string | null {
|
|
326
|
+
for (const activeKey of this.keymap.getActiveKeys({ includeBindings: true })) {
|
|
327
|
+
const bindingCommand =
|
|
328
|
+
typeof activeKey.command === "string" ? activeKey.command : activeKey.bindings?.[0]?.command;
|
|
329
|
+
if (bindingCommand !== command) continue;
|
|
330
|
+
return KEY_GLYPHS[activeKey.stroke.name] ?? activeKey.display;
|
|
331
|
+
}
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** The status-line hint for a view state, generated from the active keys. */
|
|
336
|
+
statusHint(mode: HintMode): string {
|
|
337
|
+
const fragments: string[] = [];
|
|
338
|
+
for (const entry of HINT_TEMPLATES[mode]) {
|
|
339
|
+
if ("text" in entry) {
|
|
340
|
+
fragments.push(entry.text);
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
const displays = entry.commands
|
|
344
|
+
.map((command) => this.keyDisplayFor(command))
|
|
345
|
+
.filter((display): display is string => display !== null);
|
|
346
|
+
if (!displays.length) continue;
|
|
347
|
+
const keyPart = displays.join("/");
|
|
348
|
+
if (!entry.label) fragments.push(keyPart);
|
|
349
|
+
else fragments.push(entry.labelFirst ? `${entry.label} ${keyPart}` : `${keyPart} ${entry.label}`);
|
|
350
|
+
}
|
|
351
|
+
return fragments.join(" · ");
|
|
352
|
+
}
|
|
353
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The keyboard grammar as data: every mode x key produces a known intent
|
|
3
|
+
* list, the read-only rule is one gate, and plan and diff reviews share one
|
|
4
|
+
* path for annotation navigation, deletion, and submit.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { DEFAULT_KEYS } from "./config";
|
|
9
|
+
import { reduceKey, type Intent, type KeyState } from "./keymap";
|
|
10
|
+
|
|
11
|
+
function state(patch: Partial<KeyState> = {}): KeyState {
|
|
12
|
+
return {
|
|
13
|
+
keys: DEFAULT_KEYS,
|
|
14
|
+
readOnly: false,
|
|
15
|
+
overlay: "none",
|
|
16
|
+
view: "plan",
|
|
17
|
+
spanMode: false,
|
|
18
|
+
resolved: false,
|
|
19
|
+
hasInboxItems: true,
|
|
20
|
+
annotationCount: 1,
|
|
21
|
+
hasFocusedAnnotation: true,
|
|
22
|
+
cursorAnnotatable: true,
|
|
23
|
+
walkAtEnd: false,
|
|
24
|
+
...patch,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function key(name: string, shift = false): { name: string; shift: boolean } {
|
|
29
|
+
return { name, shift };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("compose overlay", () => {
|
|
33
|
+
const keyState = state({ overlay: "compose" });
|
|
34
|
+
const table: [string, Intent[]][] = [
|
|
35
|
+
["escape", [{ type: "closeOverlay" }]],
|
|
36
|
+
["return", [{ type: "saveCompose" }]],
|
|
37
|
+
["enter", [{ type: "saveCompose" }]],
|
|
38
|
+
["left", []],
|
|
39
|
+
["right", []],
|
|
40
|
+
["j", []],
|
|
41
|
+
["q", []],
|
|
42
|
+
];
|
|
43
|
+
for (const [name, expected] of table) {
|
|
44
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
45
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("submit overlay", () => {
|
|
51
|
+
const keyState = state({ overlay: "submit" });
|
|
52
|
+
const table: [string, Intent[]][] = [
|
|
53
|
+
["escape", [{ type: "closeOverlay" }]],
|
|
54
|
+
["return", [{ type: "submitVerdict" }]],
|
|
55
|
+
["enter", [{ type: "submitVerdict" }]],
|
|
56
|
+
["left", [{ type: "cycleVerdict", dir: -1 }]],
|
|
57
|
+
["right", [{ type: "cycleVerdict", dir: 1 }]],
|
|
58
|
+
["j", []],
|
|
59
|
+
["q", []],
|
|
60
|
+
];
|
|
61
|
+
for (const [name, expected] of table) {
|
|
62
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
63
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("completion overlay", () => {
|
|
69
|
+
const prompt = state({ overlay: "completion-prompt" });
|
|
70
|
+
const counting = state({ overlay: "completion-counting" });
|
|
71
|
+
const table: [KeyState, string, Intent[]][] = [
|
|
72
|
+
[prompt, "return", [{ type: "finishReview" }]],
|
|
73
|
+
[prompt, "enter", [{ type: "finishReview" }]],
|
|
74
|
+
[prompt, "q", [{ type: "finishReview" }]],
|
|
75
|
+
[prompt, "a", [{ type: "optInAutoClose" }]],
|
|
76
|
+
[prompt, "escape", [{ type: "dismissCompletion" }]],
|
|
77
|
+
[prompt, "j", []],
|
|
78
|
+
[counting, "return", [{ type: "finishReview" }]],
|
|
79
|
+
[counting, "q", [{ type: "finishReview" }]],
|
|
80
|
+
[counting, "a", [{ type: "optInAutoClose" }]],
|
|
81
|
+
[counting, "escape", [{ type: "dismissCompletion" }]],
|
|
82
|
+
];
|
|
83
|
+
for (const [overlayState, name, expected] of table) {
|
|
84
|
+
test(`${overlayState.overlay} ${name} -> ${JSON.stringify(expected)}`, () => {
|
|
85
|
+
expect(reduceKey(overlayState, key(name))).toEqual(expected);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("inbox mode", () => {
|
|
91
|
+
const keyState = state({ view: "inbox" });
|
|
92
|
+
const table: [string, Intent[]][] = [
|
|
93
|
+
["j", [{ type: "inboxMove", to: "down" }]],
|
|
94
|
+
["down", [{ type: "inboxMove", to: "down" }]],
|
|
95
|
+
["k", [{ type: "inboxMove", to: "up" }]],
|
|
96
|
+
["up", [{ type: "inboxMove", to: "up" }]],
|
|
97
|
+
["return", [{ type: "openSession" }]],
|
|
98
|
+
["enter", [{ type: "openSession" }]],
|
|
99
|
+
["q", [{ type: "exit" }]],
|
|
100
|
+
["c", []],
|
|
101
|
+
["escape", []],
|
|
102
|
+
];
|
|
103
|
+
for (const [name, expected] of table) {
|
|
104
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
105
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
test("an empty inbox swallows every non-quit key", () => {
|
|
110
|
+
const empty = state({ view: "inbox", hasInboxItems: false });
|
|
111
|
+
for (const name of ["j", "k", "down", "up", "return", "enter"]) {
|
|
112
|
+
expect(reduceKey(empty, key(name))).toEqual([]);
|
|
113
|
+
}
|
|
114
|
+
expect(reduceKey(empty, key("q"))).toEqual([{ type: "exit" }]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("plan normal mode", () => {
|
|
119
|
+
const keyState = state();
|
|
120
|
+
const table: [string, boolean, Intent[]][] = [
|
|
121
|
+
["j", false, [{ type: "move", to: "down" }]],
|
|
122
|
+
["down", false, [{ type: "move", to: "down" }]],
|
|
123
|
+
["k", false, [{ type: "move", to: "up" }]],
|
|
124
|
+
["up", false, [{ type: "move", to: "up" }]],
|
|
125
|
+
["g", false, [{ type: "move", to: "top" }]],
|
|
126
|
+
["g", true, [{ type: "move", to: "bottom" }]],
|
|
127
|
+
["v", false, [{ type: "startSpan" }]],
|
|
128
|
+
["c", false, [{ type: "openCompose", kind: "comment", from: "cursor" }]],
|
|
129
|
+
["s", false, [{ type: "openCompose", kind: "suggestion", from: "cursor" }]],
|
|
130
|
+
// the default state has a selected card, so Cut and edit act on the card
|
|
131
|
+
["x", false, [{ type: "removeAnnotation" }]],
|
|
132
|
+
["e", false, [{ type: "editCard" }]],
|
|
133
|
+
["n", false, [{ type: "nextAnnotation" }]],
|
|
134
|
+
["p", false, [{ type: "prevAnnotation" }]],
|
|
135
|
+
["backspace", false, [{ type: "removeAnnotation" }]],
|
|
136
|
+
["return", false, [{ type: "openSubmit" }]],
|
|
137
|
+
["q", false, [{ type: "exit" }]],
|
|
138
|
+
["escape", false, [{ type: "deselect" }]],
|
|
139
|
+
["z", false, []],
|
|
140
|
+
];
|
|
141
|
+
for (const [name, shift, expected] of table) {
|
|
142
|
+
test(`${shift ? "shift+" : ""}${name} -> ${JSON.stringify(expected)}`, () => {
|
|
143
|
+
expect(reduceKey(keyState, key(name, shift))).toEqual(expected);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
test("resolved sessions guard the mutating verbs", () => {
|
|
148
|
+
const resolvedState = state({ resolved: true });
|
|
149
|
+
for (const name of ["c", "s", "x", "e"]) {
|
|
150
|
+
expect(reduceKey(resolvedState, key(name))).toEqual([{ type: "status", message: "review submitted - read-only" }]);
|
|
151
|
+
}
|
|
152
|
+
expect(reduceKey(resolvedState, key("return"))).toEqual([]);
|
|
153
|
+
expect(reduceKey(resolvedState, key("backspace"))).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("cut text cannot host a comment or a span", () => {
|
|
157
|
+
const cut = state({ cursorAnnotatable: false });
|
|
158
|
+
expect(reduceKey(cut, key("c"))).toEqual([{ type: "status", message: "text is cut - restore it first" }]);
|
|
159
|
+
expect(reduceKey(cut, key("s"))).toEqual([{ type: "status", message: "text is cut - restore it first" }]);
|
|
160
|
+
expect(reduceKey(cut, key("v"))).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("annotation navigation without annotations reports, delete without focus is silent", () => {
|
|
164
|
+
const none = state({ annotationCount: 0, hasFocusedAnnotation: false });
|
|
165
|
+
expect(reduceKey(none, key("n"))).toEqual([{ type: "status", message: "no annotations" }]);
|
|
166
|
+
expect(reduceKey(none, key("p"))).toEqual([{ type: "status", message: "no annotations" }]);
|
|
167
|
+
expect(reduceKey(none, key("backspace"))).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("without a selected card x cuts the block and e opens the editor", () => {
|
|
171
|
+
const unfocused = state({ hasFocusedAnnotation: false });
|
|
172
|
+
expect(reduceKey(unfocused, key("x"))).toEqual([{ type: "cut" }]);
|
|
173
|
+
expect(reduceKey(unfocused, key("e"))).toEqual([{ type: "edit" }]);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("span mode", () => {
|
|
178
|
+
const keyState = state({ spanMode: true });
|
|
179
|
+
const table: [string, Intent[]][] = [
|
|
180
|
+
["l", [{ type: "spanKey", name: "l" }]],
|
|
181
|
+
["h", [{ type: "spanKey", name: "h" }]],
|
|
182
|
+
["w", [{ type: "spanKey", name: "w" }]],
|
|
183
|
+
["b", [{ type: "spanKey", name: "b" }]],
|
|
184
|
+
["$", [{ type: "spanKey", name: "$" }]],
|
|
185
|
+
["0", [{ type: "spanKey", name: "0" }]],
|
|
186
|
+
["c", [{ type: "openCompose", kind: "comment", from: "span" }]],
|
|
187
|
+
["s", [{ type: "openCompose", kind: "suggestion", from: "span" }]],
|
|
188
|
+
["escape", [{ type: "closeOverlay" }]],
|
|
189
|
+
["q", [{ type: "exit" }]],
|
|
190
|
+
["j", []],
|
|
191
|
+
];
|
|
192
|
+
for (const [name, expected] of table) {
|
|
193
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
194
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("diff mode", () => {
|
|
200
|
+
const keyState = state({ view: "diff" });
|
|
201
|
+
const table: [string, Intent[]][] = [
|
|
202
|
+
["j", [{ type: "move", to: "down" }]],
|
|
203
|
+
["k", [{ type: "move", to: "up" }]],
|
|
204
|
+
["g", [{ type: "move", to: "top" }]],
|
|
205
|
+
["c", [{ type: "openCompose", kind: "comment", from: "cursor" }]],
|
|
206
|
+
["n", [{ type: "nextAnnotation" }]],
|
|
207
|
+
["p", [{ type: "prevAnnotation" }]],
|
|
208
|
+
["backspace", [{ type: "removeAnnotation" }]],
|
|
209
|
+
["return", [{ type: "openSubmit" }]],
|
|
210
|
+
["v", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
|
|
211
|
+
["x", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
|
|
212
|
+
["e", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
|
|
213
|
+
["s", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
|
|
214
|
+
["q", [{ type: "exit" }]],
|
|
215
|
+
];
|
|
216
|
+
for (const [name, expected] of table) {
|
|
217
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
218
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
test("comment guards: resolved first, then non-code rows", () => {
|
|
223
|
+
expect(reduceKey(state({ view: "diff", resolved: true }), key("c"))).toEqual([
|
|
224
|
+
{ type: "status", message: "review submitted - read-only" },
|
|
225
|
+
]);
|
|
226
|
+
expect(reduceKey(state({ view: "diff", cursorAnnotatable: false }), key("c"))).toEqual([
|
|
227
|
+
{ type: "status", message: "move to a code line to comment" },
|
|
228
|
+
]);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe("guided walk", () => {
|
|
233
|
+
test("w enters the walk in diff view; resolved and read-only sessions answer", () => {
|
|
234
|
+
expect(reduceKey(state({ view: "diff" }), key("w"))).toEqual([{ type: "walkStart" }]);
|
|
235
|
+
expect(reduceKey(state({ view: "diff", resolved: true }), key("w"))).toEqual([
|
|
236
|
+
{ type: "status", message: "review submitted - read-only" },
|
|
237
|
+
]);
|
|
238
|
+
expect(reduceKey(state({ view: "diff", readOnly: true }), key("w"))).toEqual([
|
|
239
|
+
{ type: "status", message: "observer - read-only" },
|
|
240
|
+
]);
|
|
241
|
+
expect(reduceKey(state(), key("w"))).toEqual([{ type: "status", message: "the guided walk is a diff-review mode" }]);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const walking = state({ view: "diff", overlay: "walk" });
|
|
245
|
+
const table: [string, Intent[]][] = [
|
|
246
|
+
["]", [{ type: "walkForward" }]],
|
|
247
|
+
["[", [{ type: "walkBack" }]],
|
|
248
|
+
["escape", [{ type: "walkLeave" }]],
|
|
249
|
+
["q", [{ type: "exit" }]],
|
|
250
|
+
// mid-walk return does nothing: submit lives on the end card only
|
|
251
|
+
["return", []],
|
|
252
|
+
["j", []],
|
|
253
|
+
["c", []],
|
|
254
|
+
];
|
|
255
|
+
for (const [name, expected] of table) {
|
|
256
|
+
test(`walking ${name} -> ${JSON.stringify(expected)}`, () => {
|
|
257
|
+
expect(reduceKey(walking, key(name))).toEqual(expected);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
test("the end card's return leaves the walk and opens the submit confirm", () => {
|
|
262
|
+
const atEnd = state({ view: "diff", overlay: "walk", walkAtEnd: true });
|
|
263
|
+
expect(reduceKey(atEnd, key("return"))).toEqual([{ type: "walkLeave" }, { type: "openSubmit" }]);
|
|
264
|
+
expect(reduceKey(atEnd, key("enter"))).toEqual([{ type: "walkLeave" }, { type: "openSubmit" }]);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe("duplicated-branch collapse", () => {
|
|
269
|
+
test("plan and diff produce the same intents for annotation nav, delete, and submit", () => {
|
|
270
|
+
for (const patch of [
|
|
271
|
+
{},
|
|
272
|
+
{ annotationCount: 0, hasFocusedAnnotation: false },
|
|
273
|
+
{ resolved: true },
|
|
274
|
+
] as Partial<KeyState>[]) {
|
|
275
|
+
for (const name of ["n", "p", "backspace", "return"]) {
|
|
276
|
+
const plan = reduceKey(state(patch), key(name));
|
|
277
|
+
const diff = reduceKey(state({ ...patch, view: "diff" }), key(name));
|
|
278
|
+
expect(diff).toEqual(plan);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("read-only filter", () => {
|
|
285
|
+
test("every mutating key answers observer - read-only, in plan and diff", () => {
|
|
286
|
+
for (const view of ["plan", "diff"] as const) {
|
|
287
|
+
const keyState = state({ view, readOnly: true });
|
|
288
|
+
for (const name of ["c", "s", "x", "e", "backspace", "return", "enter"]) {
|
|
289
|
+
expect(reduceKey(keyState, key(name))).toEqual([{ type: "status", message: "observer - read-only" }]);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("navigation and annotation focus still work for observers", () => {
|
|
295
|
+
const keyState = state({ readOnly: true });
|
|
296
|
+
expect(reduceKey(keyState, key("j"))).toEqual([{ type: "move", to: "down" }]);
|
|
297
|
+
expect(reduceKey(keyState, key("n"))).toEqual([{ type: "nextAnnotation" }]);
|
|
298
|
+
expect(reduceKey(keyState, key("q"))).toEqual([{ type: "exit" }]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("span-mode c/s are gated by key name even under a rebound keymap", () => {
|
|
302
|
+
const rebound = { ...DEFAULT_KEYS, comment: ["m"], suggest: ["t"] };
|
|
303
|
+
const keyState = state({ spanMode: true, readOnly: true, keys: rebound });
|
|
304
|
+
expect(reduceKey(keyState, key("c"))).toEqual([{ type: "status", message: "observer - read-only" }]);
|
|
305
|
+
expect(reduceKey(keyState, key("s"))).toEqual([{ type: "status", message: "observer - read-only" }]);
|
|
306
|
+
});
|
|
307
|
+
});
|