@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.
Files changed (44) hide show
  1. package/package.json +5 -5
  2. package/src/App.plan-review.test.tsx +1 -1
  3. package/src/App.readonly.test.tsx +3 -3
  4. package/src/App.rendering.test.tsx +16 -16
  5. package/src/App.test.tsx +1 -1
  6. package/src/App.tsx +193 -139
  7. package/src/App.walk.test.tsx +208 -0
  8. package/src/components/AnnotationCard.tsx +4 -4
  9. package/src/components/ComposeBar.tsx +2 -2
  10. package/src/components/ConfirmCard.stories.tsx +15 -0
  11. package/src/components/ConfirmCard.tsx +8 -1
  12. package/src/components/DiffSheet.tsx +8 -8
  13. package/src/components/PlanSheet.stories.tsx +3 -3
  14. package/src/components/PlanSheet.tsx +31 -31
  15. package/src/components/WalkWizard.stories.tsx +89 -0
  16. package/src/components/WalkWizard.tsx +146 -0
  17. package/src/components/__snapshots__/stories.test.tsx.snap +130 -0
  18. package/src/components/stories.test.tsx +1 -1
  19. package/src/components/story-fixtures.ts +1 -1
  20. package/src/components/story.ts +1 -1
  21. package/src/components/syntax-highlight.ts +1 -1
  22. package/src/components/theme-context.tsx +1 -1
  23. package/src/components/truncate-text.ts +6 -0
  24. package/src/config.test.ts +29 -29
  25. package/src/config.ts +13 -12
  26. package/src/editor.ts +1 -1
  27. package/src/herdr.ts +1 -1
  28. package/src/key-bindings.test.ts +18 -1
  29. package/src/key-bindings.ts +23 -2
  30. package/src/keymap.test.ts +138 -101
  31. package/src/keymap.ts +81 -56
  32. package/src/run.ts +3 -3
  33. package/src/serve.test.ts +6 -6
  34. package/src/serve.ts +7 -7
  35. package/src/session-controller.ts +140 -66
  36. package/src/theme.ts +20 -1
  37. package/src/view-diff.test.ts +8 -8
  38. package/src/view-diff.ts +22 -22
  39. package/src/{view.test.ts → view-plan.test.ts} +48 -48
  40. package/src/view-plan.ts +362 -0
  41. package/src/walk.test.ts +110 -0
  42. package/src/walk.ts +65 -0
  43. package/src/components/format.ts +0 -6
  44. package/src/view.ts +0 -341
@@ -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 s = state({ overlay: "compose" });
33
+ const keyState = state({ overlay: "compose" });
33
34
  const table: [string, Intent[]][] = [
34
- ["escape", [{ t: "closeOverlay" }]],
35
- ["return", [{ t: "saveCompose" }]],
36
- ["enter", [{ t: "saveCompose" }]],
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(s, key(name))).toEqual(expected);
45
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
45
46
  });
46
47
  }
47
48
  });
48
49
 
49
50
  describe("submit overlay", () => {
50
- const s = state({ overlay: "submit" });
51
+ const keyState = state({ overlay: "submit" });
51
52
  const table: [string, Intent[]][] = [
52
- ["escape", [{ t: "closeOverlay" }]],
53
- ["return", [{ t: "submitVerdict" }]],
54
- ["enter", [{ t: "submitVerdict" }]],
55
- ["left", [{ t: "cycleVerdict", dir: -1 }]],
56
- ["right", [{ t: "cycleVerdict", dir: 1 }]],
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(s, key(name))).toEqual(expected);
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", [{ t: "finishReview" }]],
72
- [prompt, "enter", [{ t: "finishReview" }]],
73
- [prompt, "q", [{ t: "finishReview" }]],
74
- [prompt, "a", [{ t: "optInAutoClose" }]],
75
- [prompt, "escape", [{ t: "dismissCompletion" }]],
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", [{ t: "finishReview" }]],
78
- [counting, "q", [{ t: "finishReview" }]],
79
- [counting, "a", [{ t: "optInAutoClose" }]],
80
- [counting, "escape", [{ t: "dismissCompletion" }]],
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 [s, name, expected] of table) {
83
- test(`${s.overlay} ${name} -> ${JSON.stringify(expected)}`, () => {
84
- expect(reduceKey(s, key(name))).toEqual(expected);
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 s = state({ view: "inbox" });
91
+ const keyState = state({ view: "inbox" });
91
92
  const table: [string, Intent[]][] = [
92
- ["j", [{ t: "inboxMove", to: "down" }]],
93
- ["down", [{ t: "inboxMove", to: "down" }]],
94
- ["k", [{ t: "inboxMove", to: "up" }]],
95
- ["up", [{ t: "inboxMove", to: "up" }]],
96
- ["return", [{ t: "openSession" }]],
97
- ["enter", [{ t: "openSession" }]],
98
- ["q", [{ t: "exit" }]],
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(s, key(name))).toEqual(expected);
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([{ t: "exit" }]);
114
+ expect(reduceKey(empty, key("q"))).toEqual([{ type: "exit" }]);
114
115
  });
115
116
  });
116
117
 
117
118
  describe("plan normal mode", () => {
118
- const s = state();
119
+ const keyState = state();
119
120
  const table: [string, boolean, Intent[]][] = [
120
- ["j", false, [{ t: "move", to: "down" }]],
121
- ["down", false, [{ t: "move", to: "down" }]],
122
- ["k", false, [{ t: "move", to: "up" }]],
123
- ["up", false, [{ t: "move", to: "up" }]],
124
- ["g", false, [{ t: "move", to: "top" }]],
125
- ["g", true, [{ t: "move", to: "bottom" }]],
126
- ["v", false, [{ t: "startSpan" }]],
127
- ["c", false, [{ t: "openCompose", kind: "comment", from: "cursor" }]],
128
- ["s", false, [{ t: "openCompose", kind: "suggestion", from: "cursor" }]],
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, [{ t: "removeAnnotation" }]],
131
- ["e", false, [{ t: "editCard" }]],
132
- ["n", false, [{ t: "nextAnn" }]],
133
- ["p", false, [{ t: "prevAnn" }]],
134
- ["backspace", false, [{ t: "removeAnnotation" }]],
135
- ["return", false, [{ t: "openSubmit" }]],
136
- ["q", false, [{ t: "exit" }]],
137
- ["escape", false, [{ t: "deselect" }]],
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(s, key(name, shift))).toEqual(expected);
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 r = state({ resolved: true });
148
+ const resolvedState = state({ resolved: true });
148
149
  for (const name of ["c", "s", "x", "e"]) {
149
- expect(reduceKey(r, key(name))).toEqual([{ t: "status", msg: "review submitted - read-only" }]);
150
+ expect(reduceKey(resolvedState, key(name))).toEqual([{ type: "status", message: "review submitted - read-only" }]);
150
151
  }
151
- expect(reduceKey(r, key("return"))).toEqual([]);
152
- expect(reduceKey(r, key("backspace"))).toEqual([]);
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([{ t: "status", msg: "text is cut - restore it first" }]);
158
- expect(reduceKey(cut, key("s"))).toEqual([{ t: "status", msg: "text is cut - restore it first" }]);
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([{ t: "status", msg: "no annotations" }]);
165
- expect(reduceKey(none, key("p"))).toEqual([{ t: "status", msg: "no annotations" }]);
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([{ t: "cut" }]);
172
- expect(reduceKey(unfocused, key("e"))).toEqual([{ t: "edit" }]);
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 s = state({ spanMode: true });
178
+ const keyState = state({ spanMode: true });
178
179
  const table: [string, Intent[]][] = [
179
- ["l", [{ t: "spanKey", name: "l" }]],
180
- ["h", [{ t: "spanKey", name: "h" }]],
181
- ["w", [{ t: "spanKey", name: "w" }]],
182
- ["b", [{ t: "spanKey", name: "b" }]],
183
- ["$", [{ t: "spanKey", name: "$" }]],
184
- ["0", [{ t: "spanKey", name: "0" }]],
185
- ["c", [{ t: "openCompose", kind: "comment", from: "span" }]],
186
- ["s", [{ t: "openCompose", kind: "suggestion", from: "span" }]],
187
- ["escape", [{ t: "closeOverlay" }]],
188
- ["q", [{ t: "exit" }]],
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(s, key(name))).toEqual(expected);
194
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
194
195
  });
195
196
  }
196
197
  });
197
198
 
198
199
  describe("diff mode", () => {
199
- const s = state({ view: "diff" });
200
+ const keyState = state({ view: "diff" });
200
201
  const table: [string, Intent[]][] = [
201
- ["j", [{ t: "move", to: "down" }]],
202
- ["k", [{ t: "move", to: "up" }]],
203
- ["g", [{ t: "move", to: "top" }]],
204
- ["c", [{ t: "openCompose", kind: "comment", from: "cursor" }]],
205
- ["n", [{ t: "nextAnn" }]],
206
- ["p", [{ t: "prevAnn" }]],
207
- ["backspace", [{ t: "removeAnnotation" }]],
208
- ["return", [{ t: "openSubmit" }]],
209
- ["v", [{ t: "status", msg: "plan-only verb - diff review uses c on a line" }]],
210
- ["x", [{ t: "status", msg: "plan-only verb - diff review uses c on a line" }]],
211
- ["e", [{ t: "status", msg: "plan-only verb - diff review uses c on a line" }]],
212
- ["s", [{ t: "status", msg: "plan-only verb - diff review uses c on a line" }]],
213
- ["q", [{ t: "exit" }]],
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(s, key(name))).toEqual(expected);
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
- { t: "status", msg: "review submitted - read-only" },
224
+ { type: "status", message: "review submitted - read-only" },
224
225
  ]);
225
226
  expect(reduceKey(state({ view: "diff", cursorAnnotatable: false }), key("c"))).toEqual([
226
- { t: "status", msg: "move to a code line to comment" },
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 s = state({ view, readOnly: true });
287
+ const keyState = state({ view, readOnly: true });
251
288
  for (const name of ["c", "s", "x", "e", "backspace", "return", "enter"]) {
252
- expect(reduceKey(s, key(name))).toEqual([{ t: "status", msg: "observer - read-only" }]);
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 s = state({ readOnly: true });
259
- expect(reduceKey(s, key("j"))).toEqual([{ t: "move", to: "down" }]);
260
- expect(reduceKey(s, key("n"))).toEqual([{ t: "nextAnn" }]);
261
- expect(reduceKey(s, key("q"))).toEqual([{ t: "exit" }]);
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 s = state({ spanMode: true, readOnly: true, keys: rebound });
267
- expect(reduceKey(s, key("c"))).toEqual([{ t: "status", msg: "observer - read-only" }]);
268
- expect(reduceKey(s, key("s"))).toEqual([{ t: "status", msg: "observer - read-only" }]);
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 (#70): view state in, intents out.
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
- | { t: "exit" }
13
- | { t: "status"; msg: string }
14
- | { t: "move"; to: "down" | "up" | "top" | "bottom" }
15
- | { t: "inboxMove"; to: "down" | "up" }
16
- | { t: "openSession" }
17
- | { t: "startSpan" }
18
- | { t: "spanKey"; name: string }
19
- | { t: "openCompose"; kind: "comment" | "suggestion"; from: "cursor" | "span" }
20
- | { t: "openSubmit" }
21
- | { t: "cut" }
22
- | { t: "edit" }
23
- | { t: "editCard" }
24
- | { t: "nextAnn" }
25
- | { t: "prevAnn" }
26
- | { t: "removeAnnotation" }
27
- | { t: "deselect" }
28
- | { t: "closeOverlay" }
29
- | { t: "saveCompose" }
30
- | { t: "submitVerdict" }
31
- | { t: "cycleVerdict"; dir: -1 | 1 }
32
- | { t: "finishReview" }
33
- | { t: "optInAutoClose" }
34
- | { t: "dismissCompletion" };
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(msg: string): Intent[] {
64
- return [{ t: "status", msg }];
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 [{ t: "closeOverlay" }];
84
+ if (name === "escape") return [{ type: "closeOverlay" }];
79
85
  if (name === "return" || name === "enter") {
80
- return [state.overlay === "compose" ? { t: "saveCompose" } : { t: "submitVerdict" }];
86
+ return [state.overlay === "compose" ? { type: "saveCompose" } : { type: "submitVerdict" }];
81
87
  }
82
88
  if (state.overlay === "submit" && (name === "left" || name === "right")) {
83
- return [{ t: "cycleVerdict", dir: name === "left" ? -1 : 1 }];
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 [{ t: "finishReview" }];
89
- if (name === "a") return [{ t: "optInAutoClose" }];
90
- if (name === "escape") return [{ t: "dismissCompletion" }];
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 [{ t: "exit" }];
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 [{ t: "inboxMove", to: "down" }];
109
- if (name === "k" || name === "up") return [{ t: "inboxMove", to: "up" }];
110
- if (name === "return" || name === "enter") return [{ t: "openSession" }];
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 nav = navIntent(action);
116
- if (nav) return nav;
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 [{ t: "openCompose", kind: "comment", from: "cursor" }];
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 [{ t: "closeOverlay" }];
132
- if (SPAN_KEYS.includes(name)) return [{ t: "spanKey", name }];
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 [{ t: "openCompose", kind: name === "s" ? "suggestion" : "comment", from: "span" }];
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 nav = navIntent(action);
141
- if (nav) return nav;
142
- if (name === "escape") return [{ t: "deselect" }];
143
- if (action === "span") return state.cursorAnnotatable ? [{ t: "startSpan" }] : [];
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 [{ t: "openCompose", kind: action === "suggest" ? "suggestion" : "comment", from: "cursor" }];
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" ? { t: "removeAnnotation" } : { t: "editCard" }];
154
- return [{ t: action }];
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 navIntent(action: string | undefined): Intent[] | null {
185
+ function navigationIntent(action: string | undefined): Intent[] | null {
161
186
  if (action === "down" || action === "up" || action === "top" || action === "bottom") {
162
- return [{ t: "move", to: action }];
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 [{ t: action === "next_annotation" ? "nextAnn" : "prevAnn" }];
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 [{ t: "removeAnnotation" }];
200
+ return [{ type: "removeAnnotation" }];
176
201
  }
177
- if (action === "submit") return state.resolved ? [] : [{ t: "openSubmit" }];
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(opts: RunClientOptions): Promise<number> {
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: opts.home,
21
- sessionId: opts.sessionId,
20
+ home: options.home,
21
+ sessionId: options.sessionId,
22
22
  onExit: (code: number) => {
23
23
  renderer.destroy();
24
24
  resolve(code);