@cueloop/client 0.1.0-alpha.15 → 0.1.0-alpha.17
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 +5 -5
- package/src/App.plan-review.test.tsx +1 -1
- package/src/App.readonly.test.tsx +3 -3
- package/src/App.rendering.test.tsx +16 -16
- package/src/App.test.tsx +1 -1
- package/src/App.tsx +193 -139
- package/src/App.walk.test.tsx +208 -0
- package/src/components/AnnotationCard.tsx +4 -4
- package/src/components/ComposeBar.tsx +2 -2
- package/src/components/ConfirmCard.stories.tsx +15 -0
- package/src/components/ConfirmCard.tsx +8 -1
- package/src/components/DiffSheet.tsx +8 -8
- package/src/components/PlanSheet.stories.tsx +3 -3
- package/src/components/PlanSheet.tsx +31 -31
- package/src/components/WalkWizard.stories.tsx +89 -0
- package/src/components/WalkWizard.tsx +146 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +130 -0
- package/src/components/stories.test.tsx +1 -1
- package/src/components/story-fixtures.ts +1 -1
- package/src/components/story.ts +1 -1
- package/src/components/syntax-highlight.ts +1 -1
- package/src/components/theme-context.tsx +1 -1
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +29 -29
- package/src/config.ts +13 -12
- package/src/editor.ts +1 -1
- package/src/herdr.ts +1 -1
- package/src/key-bindings.test.ts +18 -1
- package/src/key-bindings.ts +23 -2
- package/src/keymap.test.ts +138 -101
- package/src/keymap.ts +81 -56
- package/src/run.ts +3 -3
- package/src/serve.test.ts +6 -6
- package/src/serve.ts +7 -7
- package/src/session-controller.ts +140 -66
- package/src/theme.ts +20 -1
- package/src/view-diff.test.ts +8 -8
- package/src/view-diff.ts +22 -22
- package/src/{view.test.ts → view-plan.test.ts} +48 -48
- package/src/view-plan.ts +362 -0
- package/src/walk.test.ts +110 -0
- package/src/walk.ts +65 -0
- package/src/components/format.ts +0 -6
- package/src/view.ts +0 -341
package/src/keymap.test.ts
CHANGED
|
@@ -20,6 +20,7 @@ function state(patch: Partial<KeyState> = {}): KeyState {
|
|
|
20
20
|
annotationCount: 1,
|
|
21
21
|
hasFocusedAnnotation: true,
|
|
22
22
|
cursorAnnotatable: true,
|
|
23
|
+
walkAtEnd: false,
|
|
23
24
|
...patch,
|
|
24
25
|
};
|
|
25
26
|
}
|
|
@@ -29,11 +30,11 @@ function key(name: string, shift = false): { name: string; shift: boolean } {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
describe("compose overlay", () => {
|
|
32
|
-
const
|
|
33
|
+
const keyState = state({ overlay: "compose" });
|
|
33
34
|
const table: [string, Intent[]][] = [
|
|
34
|
-
["escape", [{
|
|
35
|
-
["return", [{
|
|
36
|
-
["enter", [{
|
|
35
|
+
["escape", [{ type: "closeOverlay" }]],
|
|
36
|
+
["return", [{ type: "saveCompose" }]],
|
|
37
|
+
["enter", [{ type: "saveCompose" }]],
|
|
37
38
|
["left", []],
|
|
38
39
|
["right", []],
|
|
39
40
|
["j", []],
|
|
@@ -41,25 +42,25 @@ describe("compose overlay", () => {
|
|
|
41
42
|
];
|
|
42
43
|
for (const [name, expected] of table) {
|
|
43
44
|
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
44
|
-
expect(reduceKey(
|
|
45
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
45
46
|
});
|
|
46
47
|
}
|
|
47
48
|
});
|
|
48
49
|
|
|
49
50
|
describe("submit overlay", () => {
|
|
50
|
-
const
|
|
51
|
+
const keyState = state({ overlay: "submit" });
|
|
51
52
|
const table: [string, Intent[]][] = [
|
|
52
|
-
["escape", [{
|
|
53
|
-
["return", [{
|
|
54
|
-
["enter", [{
|
|
55
|
-
["left", [{
|
|
56
|
-
["right", [{
|
|
53
|
+
["escape", [{ type: "closeOverlay" }]],
|
|
54
|
+
["return", [{ type: "submitVerdict" }]],
|
|
55
|
+
["enter", [{ type: "submitVerdict" }]],
|
|
56
|
+
["left", [{ type: "cycleVerdict", dir: -1 }]],
|
|
57
|
+
["right", [{ type: "cycleVerdict", dir: 1 }]],
|
|
57
58
|
["j", []],
|
|
58
59
|
["q", []],
|
|
59
60
|
];
|
|
60
61
|
for (const [name, expected] of table) {
|
|
61
62
|
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
62
|
-
expect(reduceKey(
|
|
63
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
63
64
|
});
|
|
64
65
|
}
|
|
65
66
|
});
|
|
@@ -68,40 +69,40 @@ describe("completion overlay", () => {
|
|
|
68
69
|
const prompt = state({ overlay: "completion-prompt" });
|
|
69
70
|
const counting = state({ overlay: "completion-counting" });
|
|
70
71
|
const table: [KeyState, string, Intent[]][] = [
|
|
71
|
-
[prompt, "return", [{
|
|
72
|
-
[prompt, "enter", [{
|
|
73
|
-
[prompt, "q", [{
|
|
74
|
-
[prompt, "a", [{
|
|
75
|
-
[prompt, "escape", [{
|
|
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" }]],
|
|
76
77
|
[prompt, "j", []],
|
|
77
|
-
[counting, "return", [{
|
|
78
|
-
[counting, "q", [{
|
|
79
|
-
[counting, "a", [{
|
|
80
|
-
[counting, "escape", [{
|
|
78
|
+
[counting, "return", [{ type: "finishReview" }]],
|
|
79
|
+
[counting, "q", [{ type: "finishReview" }]],
|
|
80
|
+
[counting, "a", [{ type: "optInAutoClose" }]],
|
|
81
|
+
[counting, "escape", [{ type: "dismissCompletion" }]],
|
|
81
82
|
];
|
|
82
|
-
for (const [
|
|
83
|
-
test(`${
|
|
84
|
-
expect(reduceKey(
|
|
83
|
+
for (const [overlayState, name, expected] of table) {
|
|
84
|
+
test(`${overlayState.overlay} ${name} -> ${JSON.stringify(expected)}`, () => {
|
|
85
|
+
expect(reduceKey(overlayState, key(name))).toEqual(expected);
|
|
85
86
|
});
|
|
86
87
|
}
|
|
87
88
|
});
|
|
88
89
|
|
|
89
90
|
describe("inbox mode", () => {
|
|
90
|
-
const
|
|
91
|
+
const keyState = state({ view: "inbox" });
|
|
91
92
|
const table: [string, Intent[]][] = [
|
|
92
|
-
["j", [{
|
|
93
|
-
["down", [{
|
|
94
|
-
["k", [{
|
|
95
|
-
["up", [{
|
|
96
|
-
["return", [{
|
|
97
|
-
["enter", [{
|
|
98
|
-
["q", [{
|
|
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" }]],
|
|
99
100
|
["c", []],
|
|
100
101
|
["escape", []],
|
|
101
102
|
];
|
|
102
103
|
for (const [name, expected] of table) {
|
|
103
104
|
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
104
|
-
expect(reduceKey(
|
|
105
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
105
106
|
});
|
|
106
107
|
}
|
|
107
108
|
|
|
@@ -110,124 +111,160 @@ describe("inbox mode", () => {
|
|
|
110
111
|
for (const name of ["j", "k", "down", "up", "return", "enter"]) {
|
|
111
112
|
expect(reduceKey(empty, key(name))).toEqual([]);
|
|
112
113
|
}
|
|
113
|
-
expect(reduceKey(empty, key("q"))).toEqual([{
|
|
114
|
+
expect(reduceKey(empty, key("q"))).toEqual([{ type: "exit" }]);
|
|
114
115
|
});
|
|
115
116
|
});
|
|
116
117
|
|
|
117
118
|
describe("plan normal mode", () => {
|
|
118
|
-
const
|
|
119
|
+
const keyState = state();
|
|
119
120
|
const table: [string, boolean, Intent[]][] = [
|
|
120
|
-
["j", false, [{
|
|
121
|
-
["down", false, [{
|
|
122
|
-
["k", false, [{
|
|
123
|
-
["up", false, [{
|
|
124
|
-
["g", false, [{
|
|
125
|
-
["g", true, [{
|
|
126
|
-
["v", false, [{
|
|
127
|
-
["c", false, [{
|
|
128
|
-
["s", false, [{
|
|
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" }]],
|
|
129
130
|
// the default state has a selected card, so Cut and edit act on the card
|
|
130
|
-
["x", false, [{
|
|
131
|
-
["e", false, [{
|
|
132
|
-
["n", false, [{
|
|
133
|
-
["p", false, [{
|
|
134
|
-
["backspace", false, [{
|
|
135
|
-
["return", false, [{
|
|
136
|
-
["q", false, [{
|
|
137
|
-
["escape", false, [{
|
|
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" }]],
|
|
138
139
|
["z", false, []],
|
|
139
140
|
];
|
|
140
141
|
for (const [name, shift, expected] of table) {
|
|
141
142
|
test(`${shift ? "shift+" : ""}${name} -> ${JSON.stringify(expected)}`, () => {
|
|
142
|
-
expect(reduceKey(
|
|
143
|
+
expect(reduceKey(keyState, key(name, shift))).toEqual(expected);
|
|
143
144
|
});
|
|
144
145
|
}
|
|
145
146
|
|
|
146
147
|
test("resolved sessions guard the mutating verbs", () => {
|
|
147
|
-
const
|
|
148
|
+
const resolvedState = state({ resolved: true });
|
|
148
149
|
for (const name of ["c", "s", "x", "e"]) {
|
|
149
|
-
expect(reduceKey(
|
|
150
|
+
expect(reduceKey(resolvedState, key(name))).toEqual([{ type: "status", message: "review submitted - read-only" }]);
|
|
150
151
|
}
|
|
151
|
-
expect(reduceKey(
|
|
152
|
-
expect(reduceKey(
|
|
152
|
+
expect(reduceKey(resolvedState, key("return"))).toEqual([]);
|
|
153
|
+
expect(reduceKey(resolvedState, key("backspace"))).toEqual([]);
|
|
153
154
|
});
|
|
154
155
|
|
|
155
156
|
test("cut text cannot host a comment or a span", () => {
|
|
156
157
|
const cut = state({ cursorAnnotatable: false });
|
|
157
|
-
expect(reduceKey(cut, key("c"))).toEqual([{
|
|
158
|
-
expect(reduceKey(cut, key("s"))).toEqual([{
|
|
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" }]);
|
|
159
160
|
expect(reduceKey(cut, key("v"))).toEqual([]);
|
|
160
161
|
});
|
|
161
162
|
|
|
162
163
|
test("annotation navigation without annotations reports, delete without focus is silent", () => {
|
|
163
164
|
const none = state({ annotationCount: 0, hasFocusedAnnotation: false });
|
|
164
|
-
expect(reduceKey(none, key("n"))).toEqual([{
|
|
165
|
-
expect(reduceKey(none, key("p"))).toEqual([{
|
|
165
|
+
expect(reduceKey(none, key("n"))).toEqual([{ type: "status", message: "no annotations" }]);
|
|
166
|
+
expect(reduceKey(none, key("p"))).toEqual([{ type: "status", message: "no annotations" }]);
|
|
166
167
|
expect(reduceKey(none, key("backspace"))).toEqual([]);
|
|
167
168
|
});
|
|
168
169
|
|
|
169
170
|
test("without a selected card x cuts the block and e opens the editor", () => {
|
|
170
171
|
const unfocused = state({ hasFocusedAnnotation: false });
|
|
171
|
-
expect(reduceKey(unfocused, key("x"))).toEqual([{
|
|
172
|
-
expect(reduceKey(unfocused, key("e"))).toEqual([{
|
|
172
|
+
expect(reduceKey(unfocused, key("x"))).toEqual([{ type: "cut" }]);
|
|
173
|
+
expect(reduceKey(unfocused, key("e"))).toEqual([{ type: "edit" }]);
|
|
173
174
|
});
|
|
174
175
|
});
|
|
175
176
|
|
|
176
177
|
describe("span mode", () => {
|
|
177
|
-
const
|
|
178
|
+
const keyState = state({ spanMode: true });
|
|
178
179
|
const table: [string, Intent[]][] = [
|
|
179
|
-
["l", [{
|
|
180
|
-
["h", [{
|
|
181
|
-
["w", [{
|
|
182
|
-
["b", [{
|
|
183
|
-
["$", [{
|
|
184
|
-
["0", [{
|
|
185
|
-
["c", [{
|
|
186
|
-
["s", [{
|
|
187
|
-
["escape", [{
|
|
188
|
-
["q", [{
|
|
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" }]],
|
|
189
190
|
["j", []],
|
|
190
191
|
];
|
|
191
192
|
for (const [name, expected] of table) {
|
|
192
193
|
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
193
|
-
expect(reduceKey(
|
|
194
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
194
195
|
});
|
|
195
196
|
}
|
|
196
197
|
});
|
|
197
198
|
|
|
198
199
|
describe("diff mode", () => {
|
|
199
|
-
const
|
|
200
|
+
const keyState = state({ view: "diff" });
|
|
200
201
|
const table: [string, Intent[]][] = [
|
|
201
|
-
["j", [{
|
|
202
|
-
["k", [{
|
|
203
|
-
["g", [{
|
|
204
|
-
["c", [{
|
|
205
|
-
["n", [{
|
|
206
|
-
["p", [{
|
|
207
|
-
["backspace", [{
|
|
208
|
-
["return", [{
|
|
209
|
-
["v", [{
|
|
210
|
-
["x", [{
|
|
211
|
-
["e", [{
|
|
212
|
-
["s", [{
|
|
213
|
-
["q", [{
|
|
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" }]],
|
|
214
215
|
];
|
|
215
216
|
for (const [name, expected] of table) {
|
|
216
217
|
test(`${name} -> ${JSON.stringify(expected)}`, () => {
|
|
217
|
-
expect(reduceKey(
|
|
218
|
+
expect(reduceKey(keyState, key(name))).toEqual(expected);
|
|
218
219
|
});
|
|
219
220
|
}
|
|
220
221
|
|
|
221
222
|
test("comment guards: resolved first, then non-code rows", () => {
|
|
222
223
|
expect(reduceKey(state({ view: "diff", resolved: true }), key("c"))).toEqual([
|
|
223
|
-
{
|
|
224
|
+
{ type: "status", message: "review submitted - read-only" },
|
|
224
225
|
]);
|
|
225
226
|
expect(reduceKey(state({ view: "diff", cursorAnnotatable: false }), key("c"))).toEqual([
|
|
226
|
-
{
|
|
227
|
+
{ type: "status", message: "move to a code line to comment" },
|
|
227
228
|
]);
|
|
228
229
|
});
|
|
229
230
|
});
|
|
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
|
+
|
|
231
268
|
describe("duplicated-branch collapse", () => {
|
|
232
269
|
test("plan and diff produce the same intents for annotation nav, delete, and submit", () => {
|
|
233
270
|
for (const patch of [
|
|
@@ -247,24 +284,24 @@ describe("duplicated-branch collapse", () => {
|
|
|
247
284
|
describe("read-only filter", () => {
|
|
248
285
|
test("every mutating key answers observer - read-only, in plan and diff", () => {
|
|
249
286
|
for (const view of ["plan", "diff"] as const) {
|
|
250
|
-
const
|
|
287
|
+
const keyState = state({ view, readOnly: true });
|
|
251
288
|
for (const name of ["c", "s", "x", "e", "backspace", "return", "enter"]) {
|
|
252
|
-
expect(reduceKey(
|
|
289
|
+
expect(reduceKey(keyState, key(name))).toEqual([{ type: "status", message: "observer - read-only" }]);
|
|
253
290
|
}
|
|
254
291
|
}
|
|
255
292
|
});
|
|
256
293
|
|
|
257
294
|
test("navigation and annotation focus still work for observers", () => {
|
|
258
|
-
const
|
|
259
|
-
expect(reduceKey(
|
|
260
|
-
expect(reduceKey(
|
|
261
|
-
expect(reduceKey(
|
|
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" }]);
|
|
262
299
|
});
|
|
263
300
|
|
|
264
301
|
test("span-mode c/s are gated by key name even under a rebound keymap", () => {
|
|
265
302
|
const rebound = { ...DEFAULT_KEYS, comment: ["m"], suggest: ["t"] };
|
|
266
|
-
const
|
|
267
|
-
expect(reduceKey(
|
|
268
|
-
expect(reduceKey(
|
|
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" }]);
|
|
269
306
|
});
|
|
270
307
|
});
|
package/src/keymap.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The keyboard grammar as a pure reducer
|
|
2
|
+
* The keyboard grammar as a pure reducer: view state in, intents out.
|
|
3
3
|
* App builds a KeyState per key event, calls reduceKey, and dispatches the
|
|
4
4
|
* intents to controller verbs and view-state setters - no branch bodies live
|
|
5
5
|
* in the key handler. Diff and plan reviews share one path for annotation
|
|
@@ -9,29 +9,33 @@
|
|
|
9
9
|
import { actionFor } from "./config";
|
|
10
10
|
|
|
11
11
|
export type Intent =
|
|
12
|
-
| {
|
|
13
|
-
| {
|
|
14
|
-
| {
|
|
15
|
-
| {
|
|
16
|
-
| {
|
|
17
|
-
| {
|
|
18
|
-
| {
|
|
19
|
-
| {
|
|
20
|
-
| {
|
|
21
|
-
| {
|
|
22
|
-
| {
|
|
23
|
-
| {
|
|
24
|
-
| {
|
|
25
|
-
| {
|
|
26
|
-
| {
|
|
27
|
-
| {
|
|
28
|
-
| {
|
|
29
|
-
| {
|
|
30
|
-
| {
|
|
31
|
-
| {
|
|
32
|
-
| {
|
|
33
|
-
| {
|
|
34
|
-
| {
|
|
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"; dir: -1 | 1 }
|
|
36
|
+
| { type: "finishReview" }
|
|
37
|
+
| { type: "optInAutoClose" }
|
|
38
|
+
| { type: "dismissCompletion" };
|
|
35
39
|
|
|
36
40
|
export interface KeyInput {
|
|
37
41
|
name: string;
|
|
@@ -43,10 +47,12 @@ export interface KeyState {
|
|
|
43
47
|
keys: Record<string, string[]>;
|
|
44
48
|
readOnly: boolean;
|
|
45
49
|
/** Layer that owns keys before the grammar runs. */
|
|
46
|
-
overlay: "none" | "compose" | "submit" | "completion-prompt" | "completion-counting";
|
|
50
|
+
overlay: "none" | "walk" | "compose" | "submit" | "completion-prompt" | "completion-counting";
|
|
47
51
|
view: "inbox" | "plan" | "diff";
|
|
48
52
|
/** Plan-only span selection sub-mode. */
|
|
49
53
|
spanMode: boolean;
|
|
54
|
+
/** The walk cursor sits on the end card - return offers the submit action. */
|
|
55
|
+
walkAtEnd: boolean;
|
|
50
56
|
resolved: boolean;
|
|
51
57
|
hasInboxItems: boolean;
|
|
52
58
|
annotationCount: number;
|
|
@@ -56,12 +62,12 @@ export interface KeyState {
|
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
/** Verbs that write session state; an observer never reaches their handlers. */
|
|
59
|
-
const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit"]);
|
|
65
|
+
const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit", "walk"]);
|
|
60
66
|
|
|
61
67
|
const SPAN_KEYS = ["l", "h", "w", "b", "$", "0"];
|
|
62
68
|
|
|
63
|
-
function status(
|
|
64
|
-
return [{
|
|
69
|
+
function status(message: string): Intent[] {
|
|
70
|
+
return [{ type: "status", message }];
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
/**
|
|
@@ -75,23 +81,36 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
|
|
|
75
81
|
// compose/submit overlays own the keys via focused inputs; only escape,
|
|
76
82
|
// return, and verdict arrows route through the grammar
|
|
77
83
|
if (state.overlay === "compose" || state.overlay === "submit") {
|
|
78
|
-
if (name === "escape") return [{
|
|
84
|
+
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
79
85
|
if (name === "return" || name === "enter") {
|
|
80
|
-
return [state.overlay === "compose" ? {
|
|
86
|
+
return [state.overlay === "compose" ? { type: "saveCompose" } : { type: "submitVerdict" }];
|
|
81
87
|
}
|
|
82
88
|
if (state.overlay === "submit" && (name === "left" || name === "right")) {
|
|
83
|
-
return [{
|
|
89
|
+
return [{ type: "cycleVerdict", dir: name === "left" ? -1 : 1 }];
|
|
84
90
|
}
|
|
85
91
|
return [];
|
|
86
92
|
}
|
|
87
93
|
if (state.overlay === "completion-prompt" || state.overlay === "completion-counting") {
|
|
88
|
-
if (name === "return" || name === "enter" || name === "q") return [{
|
|
89
|
-
if (name === "a") return [{
|
|
90
|
-
if (name === "escape") return [{
|
|
94
|
+
if (name === "return" || name === "enter" || name === "q") return [{ type: "finishReview" }];
|
|
95
|
+
if (name === "a") return [{ type: "optInAutoClose" }];
|
|
96
|
+
if (name === "escape") return [{ type: "dismissCompletion" }];
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
// the walk wizard owns its keys while active: ] advances (marking the
|
|
100
|
+
// current file viewed), [ steps back, escape leaves keeping progress, and
|
|
101
|
+
// return on the end card hands over to the submit confirm
|
|
102
|
+
if (state.overlay === "walk") {
|
|
103
|
+
if (name === "]") return [{ type: "walkForward" }];
|
|
104
|
+
if (name === "[") return [{ type: "walkBack" }];
|
|
105
|
+
if (name === "escape") return [{ type: "walkLeave" }];
|
|
106
|
+
if ((name === "return" || name === "enter") && state.walkAtEnd) {
|
|
107
|
+
return [{ type: "walkLeave" }, { type: "openSubmit" }];
|
|
108
|
+
}
|
|
109
|
+
if (name === "q") return [{ type: "exit" }];
|
|
91
110
|
return [];
|
|
92
111
|
}
|
|
93
112
|
const action = resolvedAction ?? actionFor(state.keys, name, key.shift);
|
|
94
|
-
if (action === "quit") return [{
|
|
113
|
+
if (action === "quit") return [{ type: "exit" }];
|
|
95
114
|
// the ONE read-only rule: any mutating attempt answers instead of acting
|
|
96
115
|
// (span-mode c/s are hardwired keys, so they gate by name as well)
|
|
97
116
|
const mutating = MUTATING_ACTIONS.has(action ?? "") || (state.spanMode && (name === "c" || name === "s"));
|
|
@@ -105,19 +124,24 @@ export function reduceKey(state: KeyState, key: KeyInput, resolvedAction?: strin
|
|
|
105
124
|
|
|
106
125
|
function inboxGrammar(state: KeyState, name: string): Intent[] {
|
|
107
126
|
if (!state.hasInboxItems) return [];
|
|
108
|
-
if (name === "j" || name === "down") return [{
|
|
109
|
-
if (name === "k" || name === "up") return [{
|
|
110
|
-
if (name === "return" || name === "enter") return [{
|
|
127
|
+
if (name === "j" || name === "down") return [{ type: "inboxMove", to: "down" }];
|
|
128
|
+
if (name === "k" || name === "up") return [{ type: "inboxMove", to: "up" }];
|
|
129
|
+
if (name === "return" || name === "enter") return [{ type: "openSession" }];
|
|
111
130
|
return [];
|
|
112
131
|
}
|
|
113
132
|
|
|
114
133
|
function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
|
|
115
|
-
const
|
|
116
|
-
if (
|
|
134
|
+
const navigation = navigationIntent(action);
|
|
135
|
+
if (navigation) return navigation;
|
|
136
|
+
if (action === "walk") {
|
|
137
|
+
// marking viewed writes the session record, so a resolved review answers
|
|
138
|
+
if (state.resolved) return status("review submitted - read-only");
|
|
139
|
+
return [{ type: "walkStart" }];
|
|
140
|
+
}
|
|
117
141
|
if (action === "comment") {
|
|
118
142
|
if (state.resolved) return status("review submitted - read-only");
|
|
119
143
|
if (!state.cursorAnnotatable) return status("move to a code line to comment");
|
|
120
|
-
return [{
|
|
144
|
+
return [{ type: "openCompose", kind: "comment", from: "cursor" }];
|
|
121
145
|
}
|
|
122
146
|
const shared = annotationCluster(state, action);
|
|
123
147
|
if (shared) return shared;
|
|
@@ -128,38 +152,39 @@ function diffGrammar(state: KeyState, action: string | undefined): Intent[] {
|
|
|
128
152
|
}
|
|
129
153
|
|
|
130
154
|
function spanGrammar(name: string): Intent[] {
|
|
131
|
-
if (name === "escape") return [{
|
|
132
|
-
if (SPAN_KEYS.includes(name)) return [{
|
|
155
|
+
if (name === "escape") return [{ type: "closeOverlay" }];
|
|
156
|
+
if (SPAN_KEYS.includes(name)) return [{ type: "spanKey", name }];
|
|
133
157
|
if (name === "c" || name === "s") {
|
|
134
|
-
return [{
|
|
158
|
+
return [{ type: "openCompose", kind: name === "s" ? "suggestion" : "comment", from: "span" }];
|
|
135
159
|
}
|
|
136
160
|
return [];
|
|
137
161
|
}
|
|
138
162
|
|
|
139
163
|
function planGrammar(state: KeyState, action: string | undefined, name: string): Intent[] {
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
142
|
-
if (
|
|
143
|
-
if (
|
|
164
|
+
const navigation = navigationIntent(action);
|
|
165
|
+
if (navigation) return navigation;
|
|
166
|
+
if (action === "walk") return status("the guided walk is a diff-review mode");
|
|
167
|
+
if (name === "escape") return [{ type: "deselect" }];
|
|
168
|
+
if (action === "span") return state.cursorAnnotatable ? [{ type: "startSpan" }] : [];
|
|
144
169
|
if (action === "comment" || action === "suggest") {
|
|
145
170
|
if (state.resolved) return status("review submitted - read-only");
|
|
146
171
|
if (!state.cursorAnnotatable) return status("text is cut - restore it first");
|
|
147
|
-
return [{
|
|
172
|
+
return [{ type: "openCompose", kind: action === "suggest" ? "suggestion" : "comment", from: "cursor" }];
|
|
148
173
|
}
|
|
149
174
|
if (action === "cut" || action === "edit") {
|
|
150
175
|
if (state.resolved) return status("review submitted - read-only");
|
|
151
176
|
// the document selects, the rail edits: with a card selected, Cut deletes
|
|
152
177
|
// the annotation and edit rewrites the card body in place
|
|
153
|
-
if (state.hasFocusedAnnotation) return [action === "cut" ? {
|
|
154
|
-
return [{
|
|
178
|
+
if (state.hasFocusedAnnotation) return [action === "cut" ? { type: "removeAnnotation" } : { type: "editCard" }];
|
|
179
|
+
return [{ type: action }];
|
|
155
180
|
}
|
|
156
181
|
return annotationCluster(state, action) ?? [];
|
|
157
182
|
}
|
|
158
183
|
|
|
159
184
|
/** Cursor movement is the same intent everywhere; views clamp their own bounds. */
|
|
160
|
-
function
|
|
185
|
+
function navigationIntent(action: string | undefined): Intent[] | null {
|
|
161
186
|
if (action === "down" || action === "up" || action === "top" || action === "bottom") {
|
|
162
|
-
return [{
|
|
187
|
+
return [{ type: "move", to: action }];
|
|
163
188
|
}
|
|
164
189
|
return null;
|
|
165
190
|
}
|
|
@@ -168,12 +193,12 @@ function navIntent(action: string | undefined): Intent[] | null {
|
|
|
168
193
|
function annotationCluster(state: KeyState, action: string | undefined): Intent[] | null {
|
|
169
194
|
if (action === "next_annotation" || action === "prev_annotation") {
|
|
170
195
|
if (!state.annotationCount) return status("no annotations");
|
|
171
|
-
return [{
|
|
196
|
+
return [{ type: action === "next_annotation" ? "nextAnnotation" : "prevAnnotation" }];
|
|
172
197
|
}
|
|
173
198
|
if (action === "delete_annotation") {
|
|
174
199
|
if (state.resolved || !state.hasFocusedAnnotation) return [];
|
|
175
|
-
return [{
|
|
200
|
+
return [{ type: "removeAnnotation" }];
|
|
176
201
|
}
|
|
177
|
-
if (action === "submit") return state.resolved ? [] : [{
|
|
202
|
+
if (action === "submit") return state.resolved ? [] : [{ type: "openSubmit" }];
|
|
178
203
|
return null;
|
|
179
204
|
}
|
package/src/run.ts
CHANGED
|
@@ -10,15 +10,15 @@ export interface RunClientOptions {
|
|
|
10
10
|
home?: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
export async function runClient(
|
|
13
|
+
export async function runClient(options: RunClientOptions): Promise<number> {
|
|
14
14
|
// mouse movement reporting makes multiplexers forward drags to the app,
|
|
15
15
|
// so the renderer's native selection is the drag driver
|
|
16
16
|
const renderer = await createCliRenderer({ enableMouseMovement: true });
|
|
17
17
|
return new Promise<number>((resolve) => {
|
|
18
18
|
createRoot(renderer).render(
|
|
19
19
|
React.createElement(App, {
|
|
20
|
-
home:
|
|
21
|
-
sessionId:
|
|
20
|
+
home: options.home,
|
|
21
|
+
sessionId: options.sessionId,
|
|
22
22
|
onExit: (code: number) => {
|
|
23
23
|
renderer.destroy();
|
|
24
24
|
resolve(code);
|