@cueloop/client 0.1.0-alpha.4 → 0.1.0-alpha.41

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 (138) hide show
  1. package/native/build.sh +56 -0
  2. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  3. package/native/src/shim.c +107 -0
  4. package/package.json +34 -24
  5. package/src/App.autoclose.test.tsx +137 -0
  6. package/src/App.diff.test.tsx +158 -22
  7. package/src/App.export.test.tsx +44 -37
  8. package/src/App.plan-review.test.tsx +632 -0
  9. package/src/App.readonly.test.tsx +60 -36
  10. package/src/App.rendering.test.tsx +132 -0
  11. package/src/App.review-panel.test.tsx +136 -0
  12. package/src/App.submit-confirm.test.tsx +220 -0
  13. package/src/App.test.tsx +128 -69
  14. package/src/App.toast.test.tsx +75 -0
  15. package/src/App.tsx +0 -0
  16. package/src/App.walk.test.tsx +288 -0
  17. package/src/attribution.test.ts +31 -0
  18. package/src/attribution.ts +38 -0
  19. package/src/clipboard.test.ts +23 -0
  20. package/src/clipboard.ts +30 -0
  21. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  22. package/src/components/AnnotationCard.stories.tsx +94 -0
  23. package/src/components/AnnotationCard.tsx +228 -0
  24. package/src/components/Breadcrumb.stories.tsx +19 -0
  25. package/src/components/Breadcrumb.tsx +44 -0
  26. package/src/components/CodeBlock.stories.tsx +42 -0
  27. package/src/components/CodeBlock.tsx +83 -0
  28. package/src/components/CompletionOverlay.stories.tsx +23 -0
  29. package/src/components/CompletionOverlay.tsx +59 -0
  30. package/src/components/ConfirmCard.stories.tsx +56 -0
  31. package/src/components/ConfirmCard.tsx +135 -0
  32. package/src/components/ConfirmDialog.stories.tsx +21 -0
  33. package/src/components/ConfirmDialog.tsx +64 -0
  34. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  35. package/src/components/DiffSheet.stories.tsx +78 -0
  36. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  37. package/src/components/DiffSheet.tsx +355 -0
  38. package/src/components/InboxList.stories.tsx +31 -0
  39. package/src/components/InboxList.tsx +62 -0
  40. package/src/components/KeybindsDialog.stories.tsx +31 -0
  41. package/src/components/KeybindsDialog.tsx +58 -0
  42. package/src/components/MarkerPopover.stories.tsx +58 -0
  43. package/src/components/MarkerPopover.tsx +102 -0
  44. package/src/components/MenuBar.stories.tsx +37 -0
  45. package/src/components/MenuBar.tsx +74 -0
  46. package/src/components/PlanSheet.stories.tsx +113 -0
  47. package/src/components/PlanSheet.tsx +389 -0
  48. package/src/components/PromptDialog.stories.tsx +36 -0
  49. package/src/components/PromptDialog.tsx +78 -0
  50. package/src/components/ReviewPanel.stories.tsx +86 -0
  51. package/src/components/ReviewPanel.tsx +101 -0
  52. package/src/components/ReviewRail.stories.tsx +220 -0
  53. package/src/components/ReviewRail.tsx +324 -0
  54. package/src/components/SettingsDialog.stories.tsx +111 -0
  55. package/src/components/SettingsDialog.tsx +141 -0
  56. package/src/components/SettingsRows.stories.tsx +18 -0
  57. package/src/components/SettingsRows.tsx +108 -0
  58. package/src/components/Toast.stories.tsx +12 -0
  59. package/src/components/Toast.tsx +44 -0
  60. package/src/components/WalkWizard.stories.tsx +100 -0
  61. package/src/components/WalkWizard.tsx +152 -0
  62. package/src/components/__snapshots__/stories.test.tsx.snap +1829 -0
  63. package/src/components/agent-launcher.stories.tsx +26 -0
  64. package/src/components/agent-launcher.test.tsx +21 -0
  65. package/src/components/agent-launcher.tsx +180 -0
  66. package/src/components/diff-sheet-layout.test.ts +127 -0
  67. package/src/components/diff-sheet-layout.ts +167 -0
  68. package/src/components/primitives/Button.stories.tsx +32 -0
  69. package/src/components/primitives/Button.tsx +48 -0
  70. package/src/components/primitives/Card.stories.tsx +25 -0
  71. package/src/components/primitives/Card.tsx +59 -0
  72. package/src/components/primitives/Dialog.stories.tsx +24 -0
  73. package/src/components/primitives/Dialog.tsx +64 -0
  74. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  75. package/src/components/primitives/StatusBar.tsx +22 -0
  76. package/src/components/primitives/Tabs.stories.tsx +29 -0
  77. package/src/components/primitives/Tabs.tsx +98 -0
  78. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  79. package/src/components/primitives/Toolbar.tsx +14 -0
  80. package/src/components/primitives/frame.test.ts +12 -0
  81. package/src/components/primitives/frame.ts +14 -0
  82. package/src/components/quick-actions-editor.stories.tsx +54 -0
  83. package/src/components/quick-actions-editor.tsx +136 -0
  84. package/src/components/stories-app.tsx +123 -0
  85. package/src/components/stories.test.tsx +62 -0
  86. package/src/components/story-fixtures.ts +98 -0
  87. package/src/components/story.ts +69 -0
  88. package/src/components/syntax-highlight.ts +92 -0
  89. package/src/components/terminal-pane.test.tsx +40 -0
  90. package/src/components/terminal-pane.ts +174 -0
  91. package/src/components/theme-context.stories.tsx +46 -0
  92. package/src/components/theme-context.tsx +36 -0
  93. package/src/components/truncate-text.ts +6 -0
  94. package/src/config.test.ts +430 -31
  95. package/src/config.ts +304 -26
  96. package/src/diff-hunk-curate.test.ts +315 -0
  97. package/src/diff-hunk-curate.ts +233 -0
  98. package/src/diff-intraline.test.ts +164 -0
  99. package/src/diff-intraline.ts +132 -0
  100. package/src/diff-syntax.test.ts +96 -0
  101. package/src/diff-syntax.ts +154 -0
  102. package/src/editor.test.ts +143 -0
  103. package/src/editor.ts +123 -9
  104. package/src/ghostty-terminal.test.ts +45 -0
  105. package/src/ghostty-terminal.ts +168 -0
  106. package/src/herdr.test.ts +41 -0
  107. package/src/herdr.ts +27 -0
  108. package/src/index.ts +10 -0
  109. package/src/integrations.test.ts +41 -0
  110. package/src/integrations.ts +30 -0
  111. package/src/intent-dispatch.test.ts +492 -0
  112. package/src/intent-dispatch.ts +428 -0
  113. package/src/key-bindings.test.ts +152 -0
  114. package/src/key-bindings.ts +424 -0
  115. package/src/keymap.test.ts +622 -0
  116. package/src/keymap.ts +339 -0
  117. package/src/review-panel.test.ts +113 -0
  118. package/src/review-panel.ts +83 -0
  119. package/src/run.ts +9 -5
  120. package/src/serve.test.ts +24 -9
  121. package/src/serve.ts +7 -7
  122. package/src/session-controller.curate.test.ts +253 -0
  123. package/src/session-controller.share.test.ts +309 -0
  124. package/src/session-controller.ts +900 -0
  125. package/src/share.ts +113 -0
  126. package/src/test-support.test.ts +55 -0
  127. package/src/test-support.ts +187 -0
  128. package/src/theme-presets.ts +168 -0
  129. package/src/theme.ts +36 -15
  130. package/src/version.ts +5 -0
  131. package/src/view-diff.test.ts +19 -8
  132. package/src/view-diff.ts +34 -23
  133. package/src/view-plan.test.ts +294 -0
  134. package/src/view-plan.ts +408 -0
  135. package/src/walk.test.ts +121 -0
  136. package/src/walk.ts +70 -0
  137. package/src/view.test.ts +0 -157
  138. package/src/view.ts +0 -0
@@ -0,0 +1,622 @@
1
+ /** The keyboard grammar as data: every mode x key maps to a known intent list, gated once by the read-only rule. */
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import { DEFAULT_KEYS } from "./config";
5
+ import { reduceKey, type Intent, type KeyState } from "./keymap";
6
+
7
+ function state(patch: Partial<KeyState> = {}): KeyState {
8
+ return {
9
+ keys: DEFAULT_KEYS,
10
+ readOnly: false,
11
+ overlay: "none",
12
+ view: "plan",
13
+ spanMode: false,
14
+ resolved: false,
15
+ hasInboxItems: true,
16
+ annotationCount: 1,
17
+ hasFocusedAnnotation: true,
18
+ cursorAnnotatable: true,
19
+ walkAtEnd: false,
20
+ ...patch,
21
+ };
22
+ }
23
+
24
+ function key(
25
+ name: string,
26
+ shift = false,
27
+ meta = false,
28
+ ): { name: string; shift: boolean; meta: boolean } {
29
+ return { name, shift, meta };
30
+ }
31
+
32
+ describe("compose overlay", () => {
33
+ // Arrange
34
+ const keyState = state({ overlay: "compose" });
35
+ const table: [string, Intent[]][] = [
36
+ ["escape", [{ type: "closeOverlay" }]],
37
+ ["return", [{ type: "saveCompose" }]],
38
+ ["enter", [{ type: "saveCompose" }]],
39
+ ["left", []],
40
+ ["right", []],
41
+ ["j", []],
42
+ ["q", []],
43
+ ];
44
+ for (const [name, expected] of table) {
45
+ test(`${name} -> ${JSON.stringify(expected)}`, () => {
46
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
47
+ });
48
+ }
49
+
50
+ // ⌥/Alt+⏎ (meta) and shift+⏎ insert a newline in the composer, so the
51
+ // grammar must not save on them; only a bare ⏎ saves.
52
+ test("option/alt+return (meta) does not save - the textarea inserts a newline", () => {
53
+ expect(reduceKey(keyState, key("return", false, true))).toEqual([]);
54
+ });
55
+ test("shift+return does not save - the textarea inserts a newline", () => {
56
+ expect(reduceKey(keyState, key("return", true, false))).toEqual([]);
57
+ });
58
+ });
59
+
60
+ describe("submit overlay", () => {
61
+ // Arrange
62
+ const keyState = state({ overlay: "submit" });
63
+ const table: [string, Intent[]][] = [
64
+ ["escape", [{ type: "closeOverlay" }]],
65
+ ["return", [{ type: "submitVerdict" }]],
66
+ ["enter", [{ type: "submitVerdict" }]],
67
+ ["left", [{ type: "cycleVerdict", direction: -1 }]],
68
+ ["right", [{ type: "cycleVerdict", direction: 1 }]],
69
+ ["j", []],
70
+ ["q", []],
71
+ ];
72
+ for (const [name, expected] of table) {
73
+ test(`${name} -> ${JSON.stringify(expected)}`, () => {
74
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
75
+ });
76
+ }
77
+ });
78
+
79
+ describe("completion overlay", () => {
80
+ // Arrange
81
+ const prompt = state({ overlay: "completion-prompt" });
82
+ const counting = state({ overlay: "completion-counting" });
83
+ const table: [KeyState, string, Intent[]][] = [
84
+ [prompt, "return", [{ type: "finishReview" }]],
85
+ [prompt, "enter", [{ type: "finishReview" }]],
86
+ [prompt, "q", [{ type: "finishReview" }]],
87
+ [prompt, "a", [{ type: "optInAutoClose" }]],
88
+ [prompt, "escape", [{ type: "dismissCompletion" }]],
89
+ [prompt, "j", []],
90
+ [counting, "return", [{ type: "finishReview" }]],
91
+ [counting, "q", [{ type: "finishReview" }]],
92
+ [counting, "a", [{ type: "optInAutoClose" }]],
93
+ [counting, "escape", [{ type: "dismissCompletion" }]],
94
+ ];
95
+ for (const [overlayState, name, expected] of table) {
96
+ test(`${overlayState.overlay} ${name} -> ${JSON.stringify(expected)}`, () => {
97
+ expect(reduceKey(overlayState, key(name))).toEqual(expected);
98
+ });
99
+ }
100
+ });
101
+
102
+ describe("inbox mode", () => {
103
+ // Arrange
104
+ const keyState = state({ view: "inbox" });
105
+ const table: [string, Intent[]][] = [
106
+ ["j", [{ type: "inboxMove", to: "down" }]],
107
+ ["down", [{ type: "inboxMove", to: "down" }]],
108
+ ["k", [{ type: "inboxMove", to: "up" }]],
109
+ ["up", [{ type: "inboxMove", to: "up" }]],
110
+ ["return", [{ type: "openSession" }]],
111
+ ["enter", [{ type: "openSession" }]],
112
+ ["q", [{ type: "exit" }]],
113
+ ["c", []],
114
+ ["escape", []],
115
+ ];
116
+ for (const [name, expected] of table) {
117
+ test(`${name} -> ${JSON.stringify(expected)}`, () => {
118
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
119
+ });
120
+ }
121
+
122
+ test("an empty inbox swallows every non-quit key", () => {
123
+ // Arrange
124
+ const empty = state({ view: "inbox", hasInboxItems: false });
125
+
126
+ // Assert
127
+ for (const name of ["j", "k", "down", "up", "return", "enter"]) {
128
+ expect(reduceKey(empty, key(name))).toEqual([]);
129
+ }
130
+ expect(reduceKey(empty, key("q"))).toEqual([{ type: "exit" }]);
131
+ });
132
+ });
133
+
134
+ describe("collaborator (share) capabilities", () => {
135
+ test("can annotate: comment opens the composer", () => {
136
+ // Arrange
137
+ const collab = state({ canEditPlan: false, canSubmitVerdict: false });
138
+
139
+ // Act / Assert
140
+ expect(reduceKey(collab, key("c"), "comment")).toEqual([
141
+ { type: "openCompose", kind: "comment", from: "cursor" },
142
+ ]);
143
+ });
144
+
145
+ test("can edit their own card: edit with a focused annotation rewrites it", () => {
146
+ // Arrange
147
+ const collab = state({ canEditPlan: false, hasFocusedAnnotation: true });
148
+
149
+ // Act / Assert
150
+ expect(reduceKey(collab, key("e"), "edit")).toEqual([{ type: "editCard" }]);
151
+ });
152
+
153
+ test("cannot edit the plan: cut/edit with no card is silent (no nag, no button)", () => {
154
+ // Arrange
155
+ const collab = state({ canEditPlan: false, hasFocusedAnnotation: false });
156
+
157
+ // Act / Assert
158
+ expect(reduceKey(collab, key("e"), "edit")).toEqual([]);
159
+ expect(reduceKey(collab, key("c"), "cut")).toEqual([]);
160
+ });
161
+
162
+ test("cannot submit a verdict: there is no agent on a share", () => {
163
+ // Arrange
164
+ const collab = state({ canSubmitVerdict: false });
165
+
166
+ // Act / Assert
167
+ expect(reduceKey(collab, key("x"), "submit")).toEqual([
168
+ { type: "status", message: "shared view - your notes save as you go; q to leave" },
169
+ ]);
170
+ });
171
+
172
+ test("cannot re-share: only the owner shares", () => {
173
+ // Arrange
174
+ const collab = state({ canShare: false });
175
+
176
+ // Act / Assert
177
+ expect(reduceKey(collab, key("S", true), "share")).toEqual([
178
+ { type: "status", message: "only the plan owner can share" },
179
+ ]);
180
+ });
181
+ });
182
+
183
+ describe("share", () => {
184
+ test("the owner's share key publishes", () => {
185
+ // Act / Assert
186
+ expect(reduceKey(state(), key("S", true), "share")).toEqual([{ type: "share" }]);
187
+ });
188
+
189
+ test("an observer cannot share", () => {
190
+ // Act / Assert
191
+ expect(reduceKey(state({ readOnly: true }), key("S", true), "share")).toEqual([
192
+ { type: "status", message: "observer - read-only" },
193
+ ]);
194
+ });
195
+ });
196
+
197
+ describe("plan normal mode", () => {
198
+ // Arrange
199
+ const keyState = state();
200
+ const table: [string, boolean, Intent[]][] = [
201
+ ["j", false, [{ type: "move", to: "down" }]],
202
+ ["down", false, [{ type: "move", to: "down" }]],
203
+ ["k", false, [{ type: "move", to: "up" }]],
204
+ ["up", false, [{ type: "move", to: "up" }]],
205
+ ["g", false, [{ type: "move", to: "top" }]],
206
+ ["g", true, [{ type: "move", to: "bottom" }]],
207
+ ["v", false, [{ type: "startSpan" }]],
208
+ ["c", false, [{ type: "openCompose", kind: "comment", from: "cursor" }]],
209
+ // the default state has a selected card, so Cut and edit act on the card
210
+ ["x", false, [{ type: "removeAnnotation" }]],
211
+ ["e", false, [{ type: "editCard" }]],
212
+ ["n", false, [{ type: "nextAnnotation" }]],
213
+ ["p", false, [{ type: "prevAnnotation" }]],
214
+ ["backspace", false, [{ type: "removeAnnotation" }]],
215
+ ["return", false, [{ type: "openSubmit" }]],
216
+ ["q", false, [{ type: "exit" }]],
217
+ ["escape", false, [{ type: "deselect" }]],
218
+ ["z", false, []],
219
+ ];
220
+ for (const [name, shift, expected] of table) {
221
+ test(`${shift ? "shift+" : ""}${name} -> ${JSON.stringify(expected)}`, () => {
222
+ expect(reduceKey(keyState, key(name, shift))).toEqual(expected);
223
+ });
224
+ }
225
+
226
+ test("resolved sessions guard the mutating verbs", () => {
227
+ // Arrange
228
+ const resolvedState = state({ resolved: true });
229
+
230
+ // Assert
231
+ for (const name of ["c", "x", "e"]) {
232
+ expect(reduceKey(resolvedState, key(name))).toEqual([
233
+ { type: "status", message: "review submitted - read-only" },
234
+ ]);
235
+ }
236
+ expect(reduceKey(resolvedState, key("return"))).toEqual([]);
237
+ expect(reduceKey(resolvedState, key("backspace"))).toEqual([]);
238
+ });
239
+
240
+ test("u restores a rail removal (a cut block); a share viewer's is silent", () => {
241
+ // Assert - the undo intent leaves the target to the dispatcher
242
+ expect(reduceKey(state(), key("u"))).toEqual([{ type: "restoreCuration" }]);
243
+ expect(reduceKey(state({ resolved: true }), key("u"))).toEqual([
244
+ { type: "status", message: "review submitted - read-only" },
245
+ ]);
246
+ expect(reduceKey(state({ canEditPlan: false }), key("u"))).toEqual([]);
247
+ expect(reduceKey(state({ readOnly: true }), key("u"))).toEqual([
248
+ { type: "status", message: "observer - read-only" },
249
+ ]);
250
+ });
251
+
252
+ test("cut text cannot host a comment or a span", () => {
253
+ // Arrange
254
+ const cut = state({ cursorAnnotatable: false });
255
+
256
+ // Assert
257
+ expect(reduceKey(cut, key("c"))).toEqual([
258
+ { type: "status", message: "text is cut - restore it first" },
259
+ ]);
260
+ expect(reduceKey(cut, key("v"))).toEqual([]);
261
+ });
262
+
263
+ test("annotation navigation without annotations reports, delete without focus is silent", () => {
264
+ // Arrange
265
+ const none = state({ annotationCount: 0, hasFocusedAnnotation: false });
266
+
267
+ // Assert
268
+ expect(reduceKey(none, key("n"))).toEqual([{ type: "status", message: "no annotations" }]);
269
+ expect(reduceKey(none, key("p"))).toEqual([{ type: "status", message: "no annotations" }]);
270
+ expect(reduceKey(none, key("backspace"))).toEqual([]);
271
+ });
272
+
273
+ test("without a selected card x cuts the block and e opens the editor", () => {
274
+ // Arrange
275
+ const unfocused = state({ hasFocusedAnnotation: false });
276
+
277
+ // Assert
278
+ expect(reduceKey(unfocused, key("x"))).toEqual([{ type: "cut" }]);
279
+ expect(reduceKey(unfocused, key("e"))).toEqual([{ type: "edit" }]);
280
+ });
281
+ });
282
+
283
+ describe("span mode", () => {
284
+ // Arrange
285
+ const keyState = state({ spanMode: true });
286
+ const table: [string, Intent[]][] = [
287
+ ["l", [{ type: "spanKey", name: "l" }]],
288
+ ["h", [{ type: "spanKey", name: "h" }]],
289
+ ["w", [{ type: "spanKey", name: "w" }]],
290
+ ["b", [{ type: "spanKey", name: "b" }]],
291
+ ["$", [{ type: "spanKey", name: "$" }]],
292
+ ["0", [{ type: "spanKey", name: "0" }]],
293
+ ["c", [{ type: "openCompose", kind: "comment", from: "span" }]],
294
+ ["x", [{ type: "spanCut" }]],
295
+ ["a", [{ type: "openSpanActions" }]],
296
+ ["escape", [{ type: "closeOverlay" }]],
297
+ ["q", [{ type: "exit" }]],
298
+ ["j", []],
299
+ ];
300
+ for (const [name, expected] of table) {
301
+ test(`${name} -> ${JSON.stringify(expected)}`, () => {
302
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
303
+ });
304
+ }
305
+
306
+ test("an observer's span-mode mutations answer read-only", () => {
307
+ // Arrange
308
+ const observer = state({ spanMode: true, readOnly: true });
309
+
310
+ // Assert
311
+ expect(reduceKey(observer, key("x"))).toEqual([
312
+ { type: "status", message: "observer - read-only" },
313
+ ]);
314
+ expect(reduceKey(observer, key("a"))).toEqual([
315
+ { type: "status", message: "observer - read-only" },
316
+ ]);
317
+ expect(reduceKey(observer, key("c"))).toEqual([
318
+ { type: "status", message: "observer - read-only" },
319
+ ]);
320
+ });
321
+
322
+ test("span-mode cut is owner-only, like the normal plan cut", () => {
323
+ // Arrange
324
+ const collaborator = state({ spanMode: true, canEditPlan: false });
325
+
326
+ // Assert
327
+ expect(reduceKey(collaborator, key("x"))).toEqual([]);
328
+ });
329
+
330
+ test("all span-mode mutations are blocked once the review is resolved", () => {
331
+ // Arrange
332
+ const resolved = state({ spanMode: true, resolved: true });
333
+ const readOnlyStatus: Intent[] = [{ type: "status", message: "review submitted - read-only" }];
334
+
335
+ // Assert - comment, cut, and quick-actions all answer read-only
336
+ expect(reduceKey(resolved, key("x"))).toEqual(readOnlyStatus);
337
+ expect(reduceKey(resolved, key("c"))).toEqual(readOnlyStatus);
338
+ expect(reduceKey(resolved, key("a"))).toEqual(readOnlyStatus);
339
+ });
340
+
341
+ test("span-mode cut gates for an observer even when cut is rebound off x", () => {
342
+ // Arrange - the span sub-grammar hardwires x, so the read-only gate must catch it by name
343
+ const rebound = { ...DEFAULT_KEYS, cut: ["d"] };
344
+ const observer = state({ spanMode: true, readOnly: true, keys: rebound });
345
+
346
+ // Assert
347
+ expect(reduceKey(observer, key("x"))).toEqual([
348
+ { type: "status", message: "observer - read-only" },
349
+ ]);
350
+ });
351
+ });
352
+
353
+ describe("span actions overlay", () => {
354
+ // Arrange
355
+ const keyState = state({ overlay: "spanActions" });
356
+ const table: [string, Intent[]][] = [
357
+ ["j", [{ type: "moveSpanAction", direction: 1 }]],
358
+ ["down", [{ type: "moveSpanAction", direction: 1 }]],
359
+ ["k", [{ type: "moveSpanAction", direction: -1 }]],
360
+ ["up", [{ type: "moveSpanAction", direction: -1 }]],
361
+ ["return", [{ type: "pickSpanAction" }]],
362
+ ["enter", [{ type: "pickSpanAction" }]],
363
+ ["escape", [{ type: "closeSpanActions" }]],
364
+ ["c", []],
365
+ ["q", []],
366
+ ];
367
+ for (const [name, expected] of table) {
368
+ test(`${name} -> ${JSON.stringify(expected)}`, () => {
369
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
370
+ });
371
+ }
372
+ });
373
+
374
+ describe("diff mode", () => {
375
+ // Arrange
376
+ const keyState = state({ view: "diff" });
377
+ const table: [string, Intent[]][] = [
378
+ ["j", [{ type: "move", to: "down" }]],
379
+ ["k", [{ type: "move", to: "up" }]],
380
+ ["g", [{ type: "move", to: "top" }]],
381
+ ["c", [{ type: "openCompose", kind: "comment", from: "cursor" }]],
382
+ ["n", [{ type: "nextAnnotation" }]],
383
+ ["p", [{ type: "prevAnnotation" }]],
384
+ ["backspace", [{ type: "removeAnnotation" }]],
385
+ ["return", [{ type: "openSubmit" }]],
386
+ ["v", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
387
+ ["x", [{ type: "rejectChange" }]],
388
+ ["e", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
389
+ ["q", [{ type: "exit" }]],
390
+ ];
391
+ for (const [name, expected] of table) {
392
+ test(`${name} -> ${JSON.stringify(expected)}`, () => {
393
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
394
+ });
395
+ }
396
+
397
+ test("shift+x rejects the whole hunk under the cursor", () => {
398
+ // Assert
399
+ expect(reduceKey(keyState, key("x", true))).toEqual([{ type: "rejectHunk" }]);
400
+ });
401
+
402
+ test("curation is owner-only and locked once resolved", () => {
403
+ // Assert - a resolved diff answers before curating
404
+ expect(reduceKey(state({ view: "diff", resolved: true }), key("x"))).toEqual([
405
+ { type: "status", message: "review submitted - read-only" },
406
+ ]);
407
+ // a collaborator (canEditPlan false) cannot curate hunks
408
+ expect(reduceKey(state({ view: "diff", canEditPlan: false }), key("x"))).toEqual([
409
+ { type: "status", message: "only the diff owner can curate hunks" },
410
+ ]);
411
+ // an observer's mutating attempt is stopped by the read-only gate
412
+ expect(reduceKey(state({ view: "diff", readOnly: true }), key("x"))).toEqual([
413
+ { type: "status", message: "observer - read-only" },
414
+ ]);
415
+ });
416
+
417
+ test("u restores a curated-out item, owner-only and locked once resolved", () => {
418
+ // Assert - the undo intent leaves the target to the dispatcher
419
+ expect(reduceKey(keyState, key("u"))).toEqual([{ type: "restoreCuration" }]);
420
+ expect(reduceKey(state({ view: "diff", resolved: true }), key("u"))).toEqual([
421
+ { type: "status", message: "review submitted - read-only" },
422
+ ]);
423
+ expect(reduceKey(state({ view: "diff", canEditPlan: false }), key("u"))).toEqual([
424
+ { type: "status", message: "only the diff owner can curate hunks" },
425
+ ]);
426
+ expect(reduceKey(state({ view: "diff", readOnly: true }), key("u"))).toEqual([
427
+ { type: "status", message: "observer - read-only" },
428
+ ]);
429
+ });
430
+
431
+ test("comment guards: resolved first, then non-code rows", () => {
432
+ // Assert
433
+ expect(reduceKey(state({ view: "diff", resolved: true }), key("c"))).toEqual([
434
+ { type: "status", message: "review submitted - read-only" },
435
+ ]);
436
+ expect(reduceKey(state({ view: "diff", cursorAnnotatable: false }), key("c"))).toEqual([
437
+ { type: "status", message: "move to a code line to comment" },
438
+ ]);
439
+ });
440
+ });
441
+
442
+ describe("guided walk", () => {
443
+ test("w enters the walk in diff view; resolved and read-only sessions answer", () => {
444
+ // Assert
445
+ expect(reduceKey(state({ view: "diff" }), key("w"))).toEqual([{ type: "walkStart" }]);
446
+ expect(reduceKey(state({ view: "diff", resolved: true }), key("w"))).toEqual([
447
+ { type: "status", message: "review submitted - read-only" },
448
+ ]);
449
+ expect(reduceKey(state({ view: "diff", readOnly: true }), key("w"))).toEqual([
450
+ { type: "status", message: "observer - read-only" },
451
+ ]);
452
+ expect(reduceKey(state(), key("w"))).toEqual([
453
+ { type: "status", message: "the guided walk is a diff-review mode" },
454
+ ]);
455
+ });
456
+
457
+ // Arrange
458
+ const walking = state({ view: "diff", overlay: "walk" });
459
+ const table: [string, Intent[]][] = [
460
+ ["]", [{ type: "walkForward" }]],
461
+ ["[", [{ type: "walkBack" }]],
462
+ ["escape", [{ type: "walkLeave" }]],
463
+ ["q", [{ type: "exit" }]],
464
+ // mid-walk return does nothing: submit lives on the end card only
465
+ ["return", []],
466
+ ["j", []],
467
+ ["c", []],
468
+ ];
469
+ for (const [name, expected] of table) {
470
+ test(`walking ${name} -> ${JSON.stringify(expected)}`, () => {
471
+ expect(reduceKey(walking, key(name))).toEqual(expected);
472
+ });
473
+ }
474
+
475
+ test("the end card's return leaves the walk and opens the submit confirm", () => {
476
+ // Arrange
477
+ const atEnd = state({ view: "diff", overlay: "walk", walkAtEnd: true });
478
+
479
+ // Assert
480
+ expect(reduceKey(atEnd, key("return"))).toEqual([
481
+ { type: "walkLeave" },
482
+ { type: "openSubmit" },
483
+ ]);
484
+ expect(reduceKey(atEnd, key("enter"))).toEqual([{ type: "walkLeave" }, { type: "openSubmit" }]);
485
+ });
486
+ });
487
+
488
+ describe("review panel controls", () => {
489
+ const table: [string, Intent[]][] = [
490
+ ["b", [{ type: "cycleReviewPanel" }]],
491
+ ["]", [{ type: "resizeReviewPanel", direction: 1 }]],
492
+ ["[", [{ type: "resizeReviewPanel", direction: -1 }]],
493
+ ];
494
+ for (const view of ["plan", "diff"] as const) {
495
+ for (const [name, expected] of table) {
496
+ test(`${view} ${name} -> ${JSON.stringify(expected)}`, () => {
497
+ expect(reduceKey(state({ view }), key(name))).toEqual(expected);
498
+ });
499
+ }
500
+ }
501
+
502
+ test("observers may still collapse and resize the panel (it is view state, not a mutation)", () => {
503
+ // Arrange
504
+ const observer = state({ readOnly: true });
505
+
506
+ // Assert
507
+ expect(reduceKey(observer, key("b"))).toEqual([{ type: "cycleReviewPanel" }]);
508
+ expect(reduceKey(observer, key("]"))).toEqual([{ type: "resizeReviewPanel", direction: 1 }]);
509
+ });
510
+
511
+ test("span mode still owns b as a span verb, not a panel cycle", () => {
512
+ // Assert
513
+ expect(reduceKey(state({ spanMode: true }), key("b"))).toEqual([
514
+ { type: "spanKey", name: "b" },
515
+ ]);
516
+ });
517
+
518
+ test("the walk overlay keeps the brackets for stepping", () => {
519
+ // Arrange
520
+ const walking = state({ view: "diff", overlay: "walk" });
521
+
522
+ // Assert
523
+ expect(reduceKey(walking, key("]"))).toEqual([{ type: "walkForward" }]);
524
+ expect(reduceKey(walking, key("["))).toEqual([{ type: "walkBack" }]);
525
+ });
526
+ });
527
+
528
+ describe("duplicated-branch collapse", () => {
529
+ test("plan and diff produce the same intents for annotation nav, delete, and submit", () => {
530
+ for (const patch of [
531
+ {},
532
+ { annotationCount: 0, hasFocusedAnnotation: false },
533
+ { resolved: true },
534
+ ] as Partial<KeyState>[]) {
535
+ for (const name of ["n", "p", "backspace", "return"]) {
536
+ const plan = reduceKey(state(patch), key(name));
537
+ const diff = reduceKey(state({ ...patch, view: "diff" }), key(name));
538
+ expect(diff).toEqual(plan);
539
+ }
540
+ }
541
+ });
542
+ });
543
+
544
+ describe("read-only filter", () => {
545
+ test("every mutating key answers observer - read-only, in plan and diff", () => {
546
+ for (const view of ["plan", "diff"] as const) {
547
+ const keyState = state({ view, readOnly: true });
548
+ for (const name of ["c", "x", "e", "backspace", "return", "enter"]) {
549
+ expect(reduceKey(keyState, key(name))).toEqual([
550
+ { type: "status", message: "observer - read-only" },
551
+ ]);
552
+ }
553
+ }
554
+ });
555
+
556
+ test("navigation and annotation focus still work for observers", () => {
557
+ // Arrange
558
+ const keyState = state({ readOnly: true });
559
+
560
+ // Assert
561
+ expect(reduceKey(keyState, key("j"))).toEqual([{ type: "move", to: "down" }]);
562
+ expect(reduceKey(keyState, key("n"))).toEqual([{ type: "nextAnnotation" }]);
563
+ expect(reduceKey(keyState, key("q"))).toEqual([{ type: "exit" }]);
564
+ });
565
+
566
+ test("span-mode c is gated by key name even under a rebound keymap", () => {
567
+ // Arrange
568
+ const rebound = { ...DEFAULT_KEYS, comment: ["m"] };
569
+ const keyState = state({ spanMode: true, readOnly: true, keys: rebound });
570
+
571
+ // Assert
572
+ expect(reduceKey(keyState, key("c"))).toEqual([
573
+ { type: "status", message: "observer - read-only" },
574
+ ]);
575
+ });
576
+ });
577
+
578
+ describe("inbox delete", () => {
579
+ test("d on a session requests delete", () => {
580
+ // Arrange / Act / Assert
581
+ expect(reduceKey(state({ view: "inbox" }), key("d"))).toEqual([
582
+ { type: "requestDeleteSession" },
583
+ ]);
584
+ });
585
+ });
586
+
587
+ describe("confirm overlay", () => {
588
+ // Arrange
589
+ const keyState = state({ overlay: "confirm" });
590
+ const table: [string, Intent[]][] = [
591
+ ["return", [{ type: "confirmDialog" }]],
592
+ ["enter", [{ type: "confirmDialog" }]],
593
+ ["escape", [{ type: "closeOverlay" }]],
594
+ ["j", []],
595
+ ["d", []],
596
+ ];
597
+ for (const [name, expected] of table) {
598
+ test(`${name} -> ${JSON.stringify(expected)}`, () => {
599
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
600
+ });
601
+ }
602
+ });
603
+
604
+ describe("rename grammar", () => {
605
+ test("r on a focused note opens rename", () => {
606
+ // Arrange / Act / Assert
607
+ expect(reduceKey(state({ hasFocusedAnnotation: true }), key("r"))).toEqual([
608
+ { type: "openRename" },
609
+ ]);
610
+ });
611
+
612
+ test("prompt overlay routes ⏎ to confirm and esc to cancel", () => {
613
+ // Arrange / Act / Assert
614
+ expect(reduceKey(state({ overlay: "prompt" }), key("return"))).toEqual([
615
+ { type: "confirmDialog" },
616
+ ]);
617
+ expect(reduceKey(state({ overlay: "prompt" }), key("escape"))).toEqual([
618
+ { type: "closeOverlay" },
619
+ ]);
620
+ expect(reduceKey(state({ overlay: "prompt" }), key("a"))).toEqual([]);
621
+ });
622
+ });