@cueloop/client 0.1.0-alpha.6 → 0.1.0-alpha.61

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