@cueloop/client 0.1.0-alpha.2 → 0.1.0-alpha.21
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 +293 -0
- package/src/App.readonly.test.tsx +6 -21
- package/src/App.rendering.test.tsx +104 -0
- package/src/App.review-panel.test.tsx +97 -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.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +75 -0
- package/src/components/AnnotationCard.tsx +189 -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/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +116 -0
- package/src/components/ReviewRail.stories.tsx +103 -0
- package/src/components/ReviewRail.tsx +171 -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 +1459 -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 +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +53 -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/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/stories-app.tsx +95 -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 +105 -30
- package/src/config.ts +91 -16
- 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 +347 -0
- package/src/keymap.ts +229 -0
- package/src/review-panel.test.ts +111 -0
- package/src/review-panel.ts +76 -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 +512 -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,347 @@
|
|
|
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, meta = false): { name: string; shift: boolean; meta: boolean } {
|
|
29
|
+
return { name, shift, meta };
|
|
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
|
+
// ⌥/Alt+⏎ (meta) and shift+⏎ insert a newline in the composer, so the
|
|
50
|
+
// grammar must not save on them; only a bare ⏎ saves.
|
|
51
|
+
test("option/alt+return (meta) does not save - the textarea inserts a newline", () => {
|
|
52
|
+
expect(reduceKey(keyState, key("return", false, true))).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
test("shift+return does not save - the textarea inserts a newline", () => {
|
|
55
|
+
expect(reduceKey(keyState, key("return", true, false))).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("submit overlay", () => {
|
|
60
|
+
const keyState = state({ overlay: "submit" });
|
|
61
|
+
const table: [string, Intent[]][] = [
|
|
62
|
+
["escape", [{ type: "closeOverlay" }]],
|
|
63
|
+
["return", [{ type: "submitVerdict" }]],
|
|
64
|
+
["enter", [{ type: "submitVerdict" }]],
|
|
65
|
+
["left", [{ type: "cycleVerdict", direction: -1 }]],
|
|
66
|
+
["right", [{ type: "cycleVerdict", direction: 1 }]],
|
|
67
|
+
["j", []],
|
|
68
|
+
["q", []],
|
|
69
|
+
];
|
|
70
|
+
for (const [name, expected] of table) {
|
|
71
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
72
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe("completion overlay", () => {
|
|
78
|
+
const prompt = state({ overlay: "completion-prompt" });
|
|
79
|
+
const counting = state({ overlay: "completion-counting" });
|
|
80
|
+
const table: [KeyState, string, Intent[]][] = [
|
|
81
|
+
[prompt, "return", [{ type: "finishReview" }]],
|
|
82
|
+
[prompt, "enter", [{ type: "finishReview" }]],
|
|
83
|
+
[prompt, "q", [{ type: "finishReview" }]],
|
|
84
|
+
[prompt, "a", [{ type: "optInAutoClose" }]],
|
|
85
|
+
[prompt, "escape", [{ type: "dismissCompletion" }]],
|
|
86
|
+
[prompt, "j", []],
|
|
87
|
+
[counting, "return", [{ type: "finishReview" }]],
|
|
88
|
+
[counting, "q", [{ type: "finishReview" }]],
|
|
89
|
+
[counting, "a", [{ type: "optInAutoClose" }]],
|
|
90
|
+
[counting, "escape", [{ type: "dismissCompletion" }]],
|
|
91
|
+
];
|
|
92
|
+
for (const [overlayState, name, expected] of table) {
|
|
93
|
+
test(`${overlayState.overlay} ${name} -> ${JSON.stringify(expected)}`, () => {
|
|
94
|
+
expect(reduceKey(overlayState, key(name))).toEqual(expected);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("inbox mode", () => {
|
|
100
|
+
const keyState = state({ view: "inbox" });
|
|
101
|
+
const table: [string, Intent[]][] = [
|
|
102
|
+
["j", [{ type: "inboxMove", to: "down" }]],
|
|
103
|
+
["down", [{ type: "inboxMove", to: "down" }]],
|
|
104
|
+
["k", [{ type: "inboxMove", to: "up" }]],
|
|
105
|
+
["up", [{ type: "inboxMove", to: "up" }]],
|
|
106
|
+
["return", [{ type: "openSession" }]],
|
|
107
|
+
["enter", [{ type: "openSession" }]],
|
|
108
|
+
["q", [{ type: "exit" }]],
|
|
109
|
+
["c", []],
|
|
110
|
+
["escape", []],
|
|
111
|
+
];
|
|
112
|
+
for (const [name, expected] of table) {
|
|
113
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
114
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
test("an empty inbox swallows every non-quit key", () => {
|
|
119
|
+
const empty = state({ view: "inbox", hasInboxItems: false });
|
|
120
|
+
for (const name of ["j", "k", "down", "up", "return", "enter"]) {
|
|
121
|
+
expect(reduceKey(empty, key(name))).toEqual([]);
|
|
122
|
+
}
|
|
123
|
+
expect(reduceKey(empty, key("q"))).toEqual([{ type: "exit" }]);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("plan normal mode", () => {
|
|
128
|
+
const keyState = state();
|
|
129
|
+
const table: [string, boolean, Intent[]][] = [
|
|
130
|
+
["j", false, [{ type: "move", to: "down" }]],
|
|
131
|
+
["down", false, [{ type: "move", to: "down" }]],
|
|
132
|
+
["k", false, [{ type: "move", to: "up" }]],
|
|
133
|
+
["up", false, [{ type: "move", to: "up" }]],
|
|
134
|
+
["g", false, [{ type: "move", to: "top" }]],
|
|
135
|
+
["g", true, [{ type: "move", to: "bottom" }]],
|
|
136
|
+
["v", false, [{ type: "startSpan" }]],
|
|
137
|
+
["c", false, [{ type: "openCompose", kind: "comment", from: "cursor" }]],
|
|
138
|
+
["s", false, [{ type: "openCompose", kind: "suggestion", from: "cursor" }]],
|
|
139
|
+
// the default state has a selected card, so Cut and edit act on the card
|
|
140
|
+
["x", false, [{ type: "removeAnnotation" }]],
|
|
141
|
+
["e", false, [{ type: "editCard" }]],
|
|
142
|
+
["n", false, [{ type: "nextAnnotation" }]],
|
|
143
|
+
["p", false, [{ type: "prevAnnotation" }]],
|
|
144
|
+
["backspace", false, [{ type: "removeAnnotation" }]],
|
|
145
|
+
["return", false, [{ type: "openSubmit" }]],
|
|
146
|
+
["q", false, [{ type: "exit" }]],
|
|
147
|
+
["escape", false, [{ type: "deselect" }]],
|
|
148
|
+
["z", false, []],
|
|
149
|
+
];
|
|
150
|
+
for (const [name, shift, expected] of table) {
|
|
151
|
+
test(`${shift ? "shift+" : ""}${name} -> ${JSON.stringify(expected)}`, () => {
|
|
152
|
+
expect(reduceKey(keyState, key(name, shift))).toEqual(expected);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
test("resolved sessions guard the mutating verbs", () => {
|
|
157
|
+
const resolvedState = state({ resolved: true });
|
|
158
|
+
for (const name of ["c", "s", "x", "e"]) {
|
|
159
|
+
expect(reduceKey(resolvedState, key(name))).toEqual([{ type: "status", message: "review submitted - read-only" }]);
|
|
160
|
+
}
|
|
161
|
+
expect(reduceKey(resolvedState, key("return"))).toEqual([]);
|
|
162
|
+
expect(reduceKey(resolvedState, key("backspace"))).toEqual([]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("cut text cannot host a comment or a span", () => {
|
|
166
|
+
const cut = state({ cursorAnnotatable: false });
|
|
167
|
+
expect(reduceKey(cut, key("c"))).toEqual([{ type: "status", message: "text is cut - restore it first" }]);
|
|
168
|
+
expect(reduceKey(cut, key("s"))).toEqual([{ type: "status", message: "text is cut - restore it first" }]);
|
|
169
|
+
expect(reduceKey(cut, key("v"))).toEqual([]);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("annotation navigation without annotations reports, delete without focus is silent", () => {
|
|
173
|
+
const none = state({ annotationCount: 0, hasFocusedAnnotation: false });
|
|
174
|
+
expect(reduceKey(none, key("n"))).toEqual([{ type: "status", message: "no annotations" }]);
|
|
175
|
+
expect(reduceKey(none, key("p"))).toEqual([{ type: "status", message: "no annotations" }]);
|
|
176
|
+
expect(reduceKey(none, key("backspace"))).toEqual([]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("without a selected card x cuts the block and e opens the editor", () => {
|
|
180
|
+
const unfocused = state({ hasFocusedAnnotation: false });
|
|
181
|
+
expect(reduceKey(unfocused, key("x"))).toEqual([{ type: "cut" }]);
|
|
182
|
+
expect(reduceKey(unfocused, key("e"))).toEqual([{ type: "edit" }]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe("span mode", () => {
|
|
187
|
+
const keyState = state({ spanMode: true });
|
|
188
|
+
const table: [string, Intent[]][] = [
|
|
189
|
+
["l", [{ type: "spanKey", name: "l" }]],
|
|
190
|
+
["h", [{ type: "spanKey", name: "h" }]],
|
|
191
|
+
["w", [{ type: "spanKey", name: "w" }]],
|
|
192
|
+
["b", [{ type: "spanKey", name: "b" }]],
|
|
193
|
+
["$", [{ type: "spanKey", name: "$" }]],
|
|
194
|
+
["0", [{ type: "spanKey", name: "0" }]],
|
|
195
|
+
["c", [{ type: "openCompose", kind: "comment", from: "span" }]],
|
|
196
|
+
["s", [{ type: "openCompose", kind: "suggestion", from: "span" }]],
|
|
197
|
+
["escape", [{ type: "closeOverlay" }]],
|
|
198
|
+
["q", [{ type: "exit" }]],
|
|
199
|
+
["j", []],
|
|
200
|
+
];
|
|
201
|
+
for (const [name, expected] of table) {
|
|
202
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
203
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("diff mode", () => {
|
|
209
|
+
const keyState = state({ view: "diff" });
|
|
210
|
+
const table: [string, Intent[]][] = [
|
|
211
|
+
["j", [{ type: "move", to: "down" }]],
|
|
212
|
+
["k", [{ type: "move", to: "up" }]],
|
|
213
|
+
["g", [{ type: "move", to: "top" }]],
|
|
214
|
+
["c", [{ type: "openCompose", kind: "comment", from: "cursor" }]],
|
|
215
|
+
["n", [{ type: "nextAnnotation" }]],
|
|
216
|
+
["p", [{ type: "prevAnnotation" }]],
|
|
217
|
+
["backspace", [{ type: "removeAnnotation" }]],
|
|
218
|
+
["return", [{ type: "openSubmit" }]],
|
|
219
|
+
["v", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
|
|
220
|
+
["x", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
|
|
221
|
+
["e", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
|
|
222
|
+
["s", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
|
|
223
|
+
["q", [{ type: "exit" }]],
|
|
224
|
+
];
|
|
225
|
+
for (const [name, expected] of table) {
|
|
226
|
+
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
227
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
test("comment guards: resolved first, then non-code rows", () => {
|
|
232
|
+
expect(reduceKey(state({ view: "diff", resolved: true }), key("c"))).toEqual([
|
|
233
|
+
{ type: "status", message: "review submitted - read-only" },
|
|
234
|
+
]);
|
|
235
|
+
expect(reduceKey(state({ view: "diff", cursorAnnotatable: false }), key("c"))).toEqual([
|
|
236
|
+
{ type: "status", message: "move to a code line to comment" },
|
|
237
|
+
]);
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
describe("guided walk", () => {
|
|
242
|
+
test("w enters the walk in diff view; resolved and read-only sessions answer", () => {
|
|
243
|
+
expect(reduceKey(state({ view: "diff" }), key("w"))).toEqual([{ type: "walkStart" }]);
|
|
244
|
+
expect(reduceKey(state({ view: "diff", resolved: true }), key("w"))).toEqual([
|
|
245
|
+
{ type: "status", message: "review submitted - read-only" },
|
|
246
|
+
]);
|
|
247
|
+
expect(reduceKey(state({ view: "diff", readOnly: true }), key("w"))).toEqual([
|
|
248
|
+
{ type: "status", message: "observer - read-only" },
|
|
249
|
+
]);
|
|
250
|
+
expect(reduceKey(state(), key("w"))).toEqual([{ type: "status", message: "the guided walk is a diff-review mode" }]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const walking = state({ view: "diff", overlay: "walk" });
|
|
254
|
+
const table: [string, Intent[]][] = [
|
|
255
|
+
["]", [{ type: "walkForward" }]],
|
|
256
|
+
["[", [{ type: "walkBack" }]],
|
|
257
|
+
["escape", [{ type: "walkLeave" }]],
|
|
258
|
+
["q", [{ type: "exit" }]],
|
|
259
|
+
// mid-walk return does nothing: submit lives on the end card only
|
|
260
|
+
["return", []],
|
|
261
|
+
["j", []],
|
|
262
|
+
["c", []],
|
|
263
|
+
];
|
|
264
|
+
for (const [name, expected] of table) {
|
|
265
|
+
test(`walking ${name} -> ${JSON.stringify(expected)}`, () => {
|
|
266
|
+
expect(reduceKey(walking, key(name))).toEqual(expected);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
test("the end card's return leaves the walk and opens the submit confirm", () => {
|
|
271
|
+
const atEnd = state({ view: "diff", overlay: "walk", walkAtEnd: true });
|
|
272
|
+
expect(reduceKey(atEnd, key("return"))).toEqual([{ type: "walkLeave" }, { type: "openSubmit" }]);
|
|
273
|
+
expect(reduceKey(atEnd, key("enter"))).toEqual([{ type: "walkLeave" }, { type: "openSubmit" }]);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("review panel controls", () => {
|
|
278
|
+
const table: [string, Intent[]][] = [
|
|
279
|
+
["b", [{ type: "cycleReviewPanel" }]],
|
|
280
|
+
["]", [{ type: "resizeReviewPanel", direction: 1 }]],
|
|
281
|
+
["[", [{ type: "resizeReviewPanel", direction: -1 }]],
|
|
282
|
+
];
|
|
283
|
+
for (const view of ["plan", "diff"] as const) {
|
|
284
|
+
for (const [name, expected] of table) {
|
|
285
|
+
test(`${view} ${name} -> ${JSON.stringify(expected)}`, () => {
|
|
286
|
+
expect(reduceKey(state({ view }), key(name))).toEqual(expected);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
test("observers may still collapse and resize the panel (it is view state, not a mutation)", () => {
|
|
292
|
+
const observer = state({ readOnly: true });
|
|
293
|
+
expect(reduceKey(observer, key("b"))).toEqual([{ type: "cycleReviewPanel" }]);
|
|
294
|
+
expect(reduceKey(observer, key("]"))).toEqual([{ type: "resizeReviewPanel", direction: 1 }]);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test("span mode still owns b as a span verb, not a panel cycle", () => {
|
|
298
|
+
expect(reduceKey(state({ spanMode: true }), key("b"))).toEqual([{ type: "spanKey", name: "b" }]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("the walk overlay keeps the brackets for stepping", () => {
|
|
302
|
+
const walking = state({ view: "diff", overlay: "walk" });
|
|
303
|
+
expect(reduceKey(walking, key("]"))).toEqual([{ type: "walkForward" }]);
|
|
304
|
+
expect(reduceKey(walking, key("["))).toEqual([{ type: "walkBack" }]);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
describe("duplicated-branch collapse", () => {
|
|
309
|
+
test("plan and diff produce the same intents for annotation nav, delete, and submit", () => {
|
|
310
|
+
for (const patch of [
|
|
311
|
+
{},
|
|
312
|
+
{ annotationCount: 0, hasFocusedAnnotation: false },
|
|
313
|
+
{ resolved: true },
|
|
314
|
+
] as Partial<KeyState>[]) {
|
|
315
|
+
for (const name of ["n", "p", "backspace", "return"]) {
|
|
316
|
+
const plan = reduceKey(state(patch), key(name));
|
|
317
|
+
const diff = reduceKey(state({ ...patch, view: "diff" }), key(name));
|
|
318
|
+
expect(diff).toEqual(plan);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe("read-only filter", () => {
|
|
325
|
+
test("every mutating key answers observer - read-only, in plan and diff", () => {
|
|
326
|
+
for (const view of ["plan", "diff"] as const) {
|
|
327
|
+
const keyState = state({ view, readOnly: true });
|
|
328
|
+
for (const name of ["c", "s", "x", "e", "backspace", "return", "enter"]) {
|
|
329
|
+
expect(reduceKey(keyState, key(name))).toEqual([{ type: "status", message: "observer - read-only" }]);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("navigation and annotation focus still work for observers", () => {
|
|
335
|
+
const keyState = state({ readOnly: true });
|
|
336
|
+
expect(reduceKey(keyState, key("j"))).toEqual([{ type: "move", to: "down" }]);
|
|
337
|
+
expect(reduceKey(keyState, key("n"))).toEqual([{ type: "nextAnnotation" }]);
|
|
338
|
+
expect(reduceKey(keyState, key("q"))).toEqual([{ type: "exit" }]);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("span-mode c/s are gated by key name even under a rebound keymap", () => {
|
|
342
|
+
const rebound = { ...DEFAULT_KEYS, comment: ["m"], suggest: ["t"] };
|
|
343
|
+
const keyState = state({ spanMode: true, readOnly: true, keys: rebound });
|
|
344
|
+
expect(reduceKey(keyState, key("c"))).toEqual([{ type: "status", message: "observer - read-only" }]);
|
|
345
|
+
expect(reduceKey(keyState, key("s"))).toEqual([{ type: "status", message: "observer - read-only" }]);
|
|
346
|
+
});
|
|
347
|
+
});
|
package/src/keymap.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The keyboard grammar as a pure reducer: view state in, intents out.
|
|
3
|
+
* App builds a KeyState per key event, calls reduceKey, and dispatches the
|
|
4
|
+
* intents to controller verbs and view-state setters - no branch bodies live
|
|
5
|
+
* in the key handler. Diff and plan reviews share one path for annotation
|
|
6
|
+
* navigation, deletion, and submit; the read-only rule is one gate here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { actionFor } from "./config";
|
|
10
|
+
|
|
11
|
+
export type Intent =
|
|
12
|
+
| { type: "exit" }
|
|
13
|
+
| { type: "status"; message: string }
|
|
14
|
+
| { type: "move"; to: "down" | "up" | "top" | "bottom" }
|
|
15
|
+
| { type: "inboxMove"; to: "down" | "up" }
|
|
16
|
+
| { type: "openSession" }
|
|
17
|
+
| { type: "startSpan" }
|
|
18
|
+
| { type: "spanKey"; name: string }
|
|
19
|
+
| { type: "openCompose"; kind: "comment" | "suggestion"; from: "cursor" | "span" }
|
|
20
|
+
| { type: "openSubmit" }
|
|
21
|
+
| { type: "cut" }
|
|
22
|
+
| { type: "edit" }
|
|
23
|
+
| { type: "editCard" }
|
|
24
|
+
| { type: "nextAnnotation" }
|
|
25
|
+
| { type: "prevAnnotation" }
|
|
26
|
+
| { type: "walkStart" }
|
|
27
|
+
| { type: "walkForward" }
|
|
28
|
+
| { type: "walkBack" }
|
|
29
|
+
| { type: "walkLeave" }
|
|
30
|
+
| { type: "removeAnnotation" }
|
|
31
|
+
| { type: "deselect" }
|
|
32
|
+
| { type: "closeOverlay" }
|
|
33
|
+
| { type: "saveCompose" }
|
|
34
|
+
| { type: "submitVerdict" }
|
|
35
|
+
| { type: "cycleVerdict"; direction: -1 | 1 }
|
|
36
|
+
| { type: "finishReview" }
|
|
37
|
+
| { type: "optInAutoClose" }
|
|
38
|
+
| { type: "dismissCompletion" }
|
|
39
|
+
| { type: "cycleReviewPanel" }
|
|
40
|
+
| { type: "resizeReviewPanel"; direction: -1 | 1 };
|
|
41
|
+
|
|
42
|
+
export interface KeyInput {
|
|
43
|
+
name: string;
|
|
44
|
+
shift: boolean;
|
|
45
|
+
/** Option/Alt arrives as `meta` in this terminal stack (never as a raw alt). */
|
|
46
|
+
meta?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface KeyState {
|
|
50
|
+
/** Loaded keymap (config.ts): action -> key combos. */
|
|
51
|
+
keys: Record<string, string[]>;
|
|
52
|
+
readOnly: boolean;
|
|
53
|
+
/** Layer that owns keys before the grammar runs. */
|
|
54
|
+
overlay: "none" | "walk" | "compose" | "submit" | "completion-prompt" | "completion-counting";
|
|
55
|
+
view: "inbox" | "plan" | "diff";
|
|
56
|
+
/** Plan-only span selection sub-mode. */
|
|
57
|
+
spanMode: boolean;
|
|
58
|
+
/** The walk cursor sits on the end card - return offers the submit action. */
|
|
59
|
+
walkAtEnd: boolean;
|
|
60
|
+
resolved: boolean;
|
|
61
|
+
hasInboxItems: boolean;
|
|
62
|
+
annotationCount: number;
|
|
63
|
+
hasFocusedAnnotation: boolean;
|
|
64
|
+
/** Cursor sits on annotatable text: a work block (plan) or a code row (diff). */
|
|
65
|
+
cursorAnnotatable: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Verbs that write session state; an observer never reaches their handlers. */
|
|
69
|
+
const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit", "walk"]);
|
|
70
|
+
|
|
71
|
+
const SPAN_KEYS = ["l", "h", "w", "b", "$", "0"];
|
|
72
|
+
|
|
73
|
+
function status(message: string): Intent[] {
|
|
74
|
+
return [{ type: "status", message }];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Reduce one key event to intents. `resolvedAction` is the binding-layer
|
|
79
|
+
* resolution (key-bindings.ts over @opentui/keymap) when the caller has one;
|
|
80
|
+
* without it the reducer falls back to the plain reverse lookup, so the
|
|
81
|
+
* grammar stays testable as a pure function.
|
|
82
|
+
*/
|
|
83
|
+
export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: string): Intent[] {
|
|
84
|
+
const name = key.name;
|
|
85
|
+
// compose/submit overlays own the keys via focused inputs; only escape,
|
|
86
|
+
// return, and verdict arrows route through the grammar
|
|
87
|
+
if (state.overlay === "compose" || state.overlay === "submit") {
|
|
88
|
+
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
89
|
+
if (name === "return" || name === "enter") {
|
|
90
|
+
// In the composer, ⌥/Alt+⏎ (meta) and shift+⏎ insert a newline - the
|
|
91
|
+
// focused textarea owns that; only a bare ⏎ saves. The submit overlay
|
|
92
|
+
// keeps its plain ⏎ submit.
|
|
93
|
+
if (state.overlay === "compose") {
|
|
94
|
+
if (key.shift || key.meta) return [];
|
|
95
|
+
return [{ type: "saveCompose" }];
|
|
96
|
+
}
|
|
97
|
+
return [{ type: "submitVerdict" }];
|
|
98
|
+
}
|
|
99
|
+
if (state.overlay === "submit" && (name === "left" || name === "right")) {
|
|
100
|
+
return [{ type: "cycleVerdict", direction: name === "left" ? -1 : 1 }];
|
|
101
|
+
}
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
if (state.overlay === "completion-prompt" || state.overlay === "completion-counting") {
|
|
105
|
+
if (name === "return" || name === "enter" || name === "q") return [{ type: "finishReview" }];
|
|
106
|
+
if (name === "a") return [{ type: "optInAutoClose" }];
|
|
107
|
+
if (name === "escape") return [{ type: "dismissCompletion" }];
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
// the walk wizard owns its keys while active: ] advances (marking the
|
|
111
|
+
// current file viewed), [ steps back, escape leaves keeping progress, and
|
|
112
|
+
// return on the end card hands over to the submit confirm
|
|
113
|
+
if (state.overlay === "walk") {
|
|
114
|
+
if (name === "]") return [{ type: "walkForward" }];
|
|
115
|
+
if (name === "[") return [{ type: "walkBack" }];
|
|
116
|
+
if (name === "escape") return [{ type: "walkLeave" }];
|
|
117
|
+
if ((name === "return" || name === "enter") && state.walkAtEnd) {
|
|
118
|
+
return [{ type: "walkLeave" }, { type: "openSubmit" }];
|
|
119
|
+
}
|
|
120
|
+
if (name === "q") return [{ type: "exit" }];
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
const action = resolvedAction ?? actionFor(state.keys, name, key.shift);
|
|
124
|
+
if (action === "quit") return [{ type: "exit" }];
|
|
125
|
+
// the ONE read-only rule: any mutating attempt answers instead of acting
|
|
126
|
+
// (span-mode c/s are hardwired keys, so they gate by name as well)
|
|
127
|
+
const mutating = MUTATING_ACTIONS.has(action ?? "") || (state.spanMode && (name === "c" || name === "s"));
|
|
128
|
+
if (state.readOnly && mutating) return status("observer - read-only");
|
|
129
|
+
|
|
130
|
+
if (state.view === "inbox") return inboxGrammar(state, name);
|
|
131
|
+
// span mode owns its single-letter keys (b slides the span back) before the
|
|
132
|
+
// review-panel controls claim them
|
|
133
|
+
if (state.spanMode) return spanGrammar(name);
|
|
134
|
+
// the review panel rides both plan and diff reviews; collapsing and resizing
|
|
135
|
+
// are view state, so the read-only gate above lets them through
|
|
136
|
+
const reviewPanel = reviewPanelGrammar(action);
|
|
137
|
+
if (reviewPanel) return reviewPanel;
|
|
138
|
+
if (state.view === "diff") return diffGrammar(state, action);
|
|
139
|
+
return planGrammar(state, action, name);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The review-panel controls: cycle the mode, widen and narrow the rail. */
|
|
143
|
+
function reviewPanelGrammar(action: string | undefined): Intent[] | null {
|
|
144
|
+
if (action === "review_cycle") return [{ type: "cycleReviewPanel" }];
|
|
145
|
+
if (action === "review_wider") return [{ type: "resizeReviewPanel", direction: 1 }];
|
|
146
|
+
if (action === "review_narrower") return [{ type: "resizeReviewPanel", direction: -1 }];
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function inboxGrammar(state: KeyState, name: string): Intent[] {
|
|
151
|
+
if (!state.hasInboxItems) return [];
|
|
152
|
+
if (name === "j" || name === "down") return [{ type: "inboxMove", to: "down" }];
|
|
153
|
+
if (name === "k" || name === "up") return [{ type: "inboxMove", to: "up" }];
|
|
154
|
+
if (name === "return" || name === "enter") return [{ type: "openSession" }];
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
|
|
159
|
+
const navigation = navigationIntent(action);
|
|
160
|
+
if (navigation) return navigation;
|
|
161
|
+
if (action === "walk") {
|
|
162
|
+
// marking viewed writes the session record, so a resolved review answers
|
|
163
|
+
if (state.resolved) return status("review submitted - read-only");
|
|
164
|
+
return [{ type: "walkStart" }];
|
|
165
|
+
}
|
|
166
|
+
if (action === "comment") {
|
|
167
|
+
if (state.resolved) return status("review submitted - read-only");
|
|
168
|
+
if (!state.cursorAnnotatable) return status("move to a code line to comment");
|
|
169
|
+
return [{ type: "openCompose", kind: "comment", from: "cursor" }];
|
|
170
|
+
}
|
|
171
|
+
const shared = annotationCluster(state, action);
|
|
172
|
+
if (shared) return shared;
|
|
173
|
+
if (action === "span" || action === "cut" || action === "edit" || action === "suggest") {
|
|
174
|
+
return status("plan-only verb - diff review uses c on a line");
|
|
175
|
+
}
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function spanGrammar(name: string): Intent[] {
|
|
180
|
+
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
181
|
+
if (SPAN_KEYS.includes(name)) return [{ type: "spanKey", name }];
|
|
182
|
+
if (name === "c" || name === "s") {
|
|
183
|
+
return [{ type: "openCompose", kind: name === "s" ? "suggestion" : "comment", from: "span" }];
|
|
184
|
+
}
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function planGrammar(state: KeyState, action: string | undefined, name: string): Intent[] {
|
|
189
|
+
const navigation = navigationIntent(action);
|
|
190
|
+
if (navigation) return navigation;
|
|
191
|
+
if (action === "walk") return status("the guided walk is a diff-review mode");
|
|
192
|
+
if (name === "escape") return [{ type: "deselect" }];
|
|
193
|
+
if (action === "span") return state.cursorAnnotatable ? [{ type: "startSpan" }] : [];
|
|
194
|
+
if (action === "comment" || action === "suggest") {
|
|
195
|
+
if (state.resolved) return status("review submitted - read-only");
|
|
196
|
+
if (!state.cursorAnnotatable) return status("text is cut - restore it first");
|
|
197
|
+
return [{ type: "openCompose", kind: action === "suggest" ? "suggestion" : "comment", from: "cursor" }];
|
|
198
|
+
}
|
|
199
|
+
if (action === "cut" || action === "edit") {
|
|
200
|
+
if (state.resolved) return status("review submitted - read-only");
|
|
201
|
+
// the document selects, the rail edits: with a card selected, Cut deletes
|
|
202
|
+
// the annotation and edit rewrites the card body in place
|
|
203
|
+
if (state.hasFocusedAnnotation) return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
|
|
204
|
+
return [{ type: action }];
|
|
205
|
+
}
|
|
206
|
+
return annotationCluster(state, action) ?? [];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Cursor movement is the same intent everywhere; views clamp their own bounds. */
|
|
210
|
+
function navigationIntent(action: string | undefined): Intent[] | null {
|
|
211
|
+
if (action === "down" || action === "up" || action === "top" || action === "bottom") {
|
|
212
|
+
return [{ type: "move", to: action }];
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Annotation navigation, deletion, and submit: one path for plan and diff. */
|
|
218
|
+
function annotationCluster(state: KeyState, action: string | undefined): Intent[] | null {
|
|
219
|
+
if (action === "next_annotation" || action === "prev_annotation") {
|
|
220
|
+
if (!state.annotationCount) return status("no annotations");
|
|
221
|
+
return [{ type: action === "next_annotation" ? "nextAnnotation" : "prevAnnotation" }];
|
|
222
|
+
}
|
|
223
|
+
if (action === "delete_annotation") {
|
|
224
|
+
if (state.resolved || !state.hasFocusedAnnotation) return [];
|
|
225
|
+
return [{ type: "removeAnnotation" }];
|
|
226
|
+
}
|
|
227
|
+
if (action === "submit") return state.resolved ? [] : [{ type: "openSubmit" }];
|
|
228
|
+
return null;
|
|
229
|
+
}
|