@cueloop/client 0.1.0-alpha.3 → 0.1.0-alpha.31

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 (105) hide show
  1. package/package.json +17 -11
  2. package/src/App.autoclose.test.tsx +133 -0
  3. package/src/App.diff.test.tsx +27 -20
  4. package/src/App.export.test.tsx +19 -28
  5. package/src/App.plan-review.test.tsx +507 -0
  6. package/src/App.readonly.test.tsx +26 -27
  7. package/src/App.rendering.test.tsx +119 -0
  8. package/src/App.review-panel.test.tsx +123 -0
  9. package/src/App.submit-confirm.test.tsx +216 -0
  10. package/src/App.test.tsx +83 -64
  11. package/src/App.tsx +0 -0
  12. package/src/App.walk.test.tsx +265 -0
  13. package/src/attribution.test.ts +31 -0
  14. package/src/attribution.ts +32 -0
  15. package/src/clipboard.test.ts +23 -0
  16. package/src/clipboard.ts +26 -0
  17. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  18. package/src/components/AnnotationCard.stories.tsx +75 -0
  19. package/src/components/AnnotationCard.tsx +208 -0
  20. package/src/components/Breadcrumb.stories.tsx +19 -0
  21. package/src/components/Breadcrumb.tsx +38 -0
  22. package/src/components/CodeBlock.stories.tsx +25 -0
  23. package/src/components/CodeBlock.tsx +68 -0
  24. package/src/components/CompletionOverlay.stories.tsx +23 -0
  25. package/src/components/CompletionOverlay.tsx +49 -0
  26. package/src/components/ComposeBar.stories.tsx +18 -0
  27. package/src/components/ComposeBar.tsx +54 -0
  28. package/src/components/ConfirmCard.stories.tsx +37 -0
  29. package/src/components/ConfirmCard.tsx +117 -0
  30. package/src/components/ConfirmDialog.stories.tsx +21 -0
  31. package/src/components/ConfirmDialog.tsx +56 -0
  32. package/src/components/DiffSheet.stories.tsx +44 -0
  33. package/src/components/DiffSheet.tsx +243 -0
  34. package/src/components/InboxList.stories.tsx +18 -0
  35. package/src/components/InboxList.tsx +55 -0
  36. package/src/components/PlanSheet.stories.tsx +81 -0
  37. package/src/components/PlanSheet.tsx +329 -0
  38. package/src/components/PromptDialog.stories.tsx +36 -0
  39. package/src/components/PromptDialog.tsx +62 -0
  40. package/src/components/ReviewPanel.stories.tsx +87 -0
  41. package/src/components/ReviewPanel.tsx +116 -0
  42. package/src/components/ReviewRail.stories.tsx +107 -0
  43. package/src/components/ReviewRail.tsx +180 -0
  44. package/src/components/SettingsDialog.stories.tsx +63 -0
  45. package/src/components/SettingsDialog.tsx +132 -0
  46. package/src/components/SettingsRows.stories.tsx +18 -0
  47. package/src/components/SettingsRows.tsx +92 -0
  48. package/src/components/WalkWizard.stories.tsx +89 -0
  49. package/src/components/WalkWizard.tsx +146 -0
  50. package/src/components/__snapshots__/stories.test.tsx.snap +1519 -0
  51. package/src/components/primitives/Button.stories.tsx +32 -0
  52. package/src/components/primitives/Button.tsx +44 -0
  53. package/src/components/primitives/Card.stories.tsx +25 -0
  54. package/src/components/primitives/Card.tsx +59 -0
  55. package/src/components/primitives/Dialog.stories.tsx +24 -0
  56. package/src/components/primitives/Dialog.tsx +54 -0
  57. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  58. package/src/components/primitives/StatusBar.tsx +22 -0
  59. package/src/components/primitives/Tabs.stories.tsx +29 -0
  60. package/src/components/primitives/Tabs.tsx +97 -0
  61. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  62. package/src/components/primitives/Toolbar.tsx +14 -0
  63. package/src/components/primitives/frame.test.ts +12 -0
  64. package/src/components/primitives/frame.ts +14 -0
  65. package/src/components/stories-app.tsx +95 -0
  66. package/src/components/stories.test.tsx +60 -0
  67. package/src/components/story-fixtures.ts +90 -0
  68. package/src/components/story.ts +66 -0
  69. package/src/components/syntax-highlight.ts +57 -0
  70. package/src/components/theme-context.stories.tsx +41 -0
  71. package/src/components/theme-context.tsx +30 -0
  72. package/src/components/truncate-text.ts +6 -0
  73. package/src/config.test.ts +196 -30
  74. package/src/config.ts +128 -16
  75. package/src/editor.test.ts +134 -0
  76. package/src/editor.ts +101 -13
  77. package/src/herdr.test.ts +41 -0
  78. package/src/herdr.ts +21 -0
  79. package/src/index.ts +2 -0
  80. package/src/integrations.test.ts +41 -0
  81. package/src/integrations.ts +28 -0
  82. package/src/intent-dispatch.test.ts +308 -0
  83. package/src/intent-dispatch.ts +295 -0
  84. package/src/key-bindings.test.ts +142 -0
  85. package/src/key-bindings.ts +363 -0
  86. package/src/keymap.test.ts +477 -0
  87. package/src/keymap.ts +272 -0
  88. package/src/review-panel.test.ts +111 -0
  89. package/src/review-panel.ts +76 -0
  90. package/src/run.ts +9 -5
  91. package/src/serve.test.ts +13 -6
  92. package/src/serve.ts +7 -7
  93. package/src/session-controller.share.test.ts +248 -0
  94. package/src/session-controller.ts +607 -0
  95. package/src/share.ts +80 -0
  96. package/src/test-support.ts +87 -0
  97. package/src/theme.ts +26 -3
  98. package/src/view-diff.test.ts +19 -8
  99. package/src/view-diff.ts +22 -22
  100. package/src/view-plan.test.ts +260 -0
  101. package/src/view-plan.ts +365 -0
  102. package/src/walk.test.ts +116 -0
  103. package/src/walk.ts +65 -0
  104. package/src/view.test.ts +0 -157
  105. package/src/view.ts +0 -0
@@ -0,0 +1,477 @@
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(name: string, shift = false, meta = false): { name: string; shift: boolean; meta: boolean } {
25
+ return { name, shift, meta };
26
+ }
27
+
28
+ describe("compose overlay", () => {
29
+ // Arrange
30
+ const keyState = state({ overlay: "compose" });
31
+ const table: [string, Intent[]][] = [
32
+ ["escape", [{ type: "closeOverlay" }]],
33
+ ["return", [{ type: "saveCompose" }]],
34
+ ["enter", [{ type: "saveCompose" }]],
35
+ ["left", []],
36
+ ["right", []],
37
+ ["j", []],
38
+ ["q", []],
39
+ ];
40
+ for (const [name, expected] of table) {
41
+ test(`${name} -> ${JSON.stringify(expected)}`, () => {
42
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
43
+ });
44
+ }
45
+
46
+ // ⌥/Alt+⏎ (meta) and shift+⏎ insert a newline in the composer, so the
47
+ // grammar must not save on them; only a bare ⏎ saves.
48
+ test("option/alt+return (meta) does not save - the textarea inserts a newline", () => {
49
+ expect(reduceKey(keyState, key("return", false, true))).toEqual([]);
50
+ });
51
+ test("shift+return does not save - the textarea inserts a newline", () => {
52
+ expect(reduceKey(keyState, key("return", true, false))).toEqual([]);
53
+ });
54
+ });
55
+
56
+ describe("submit overlay", () => {
57
+ // Arrange
58
+ const keyState = state({ overlay: "submit" });
59
+ const table: [string, Intent[]][] = [
60
+ ["escape", [{ type: "closeOverlay" }]],
61
+ ["return", [{ type: "submitVerdict" }]],
62
+ ["enter", [{ type: "submitVerdict" }]],
63
+ ["left", [{ type: "cycleVerdict", direction: -1 }]],
64
+ ["right", [{ type: "cycleVerdict", direction: 1 }]],
65
+ ["j", []],
66
+ ["q", []],
67
+ ];
68
+ for (const [name, expected] of table) {
69
+ test(`${name} -> ${JSON.stringify(expected)}`, () => {
70
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
71
+ });
72
+ }
73
+ });
74
+
75
+ describe("completion overlay", () => {
76
+ // Arrange
77
+ const prompt = state({ overlay: "completion-prompt" });
78
+ const counting = state({ overlay: "completion-counting" });
79
+ const table: [KeyState, string, Intent[]][] = [
80
+ [prompt, "return", [{ type: "finishReview" }]],
81
+ [prompt, "enter", [{ type: "finishReview" }]],
82
+ [prompt, "q", [{ type: "finishReview" }]],
83
+ [prompt, "a", [{ type: "optInAutoClose" }]],
84
+ [prompt, "escape", [{ type: "dismissCompletion" }]],
85
+ [prompt, "j", []],
86
+ [counting, "return", [{ type: "finishReview" }]],
87
+ [counting, "q", [{ type: "finishReview" }]],
88
+ [counting, "a", [{ type: "optInAutoClose" }]],
89
+ [counting, "escape", [{ type: "dismissCompletion" }]],
90
+ ];
91
+ for (const [overlayState, name, expected] of table) {
92
+ test(`${overlayState.overlay} ${name} -> ${JSON.stringify(expected)}`, () => {
93
+ expect(reduceKey(overlayState, key(name))).toEqual(expected);
94
+ });
95
+ }
96
+ });
97
+
98
+ describe("inbox mode", () => {
99
+ // Arrange
100
+ const keyState = state({ view: "inbox" });
101
+ const table: [string, Intent[]][] = [
102
+ ["j", [{ type: "inboxMove", to: "down" }]],
103
+ ["down", [{ type: "inboxMove", to: "down" }]],
104
+ ["k", [{ type: "inboxMove", to: "up" }]],
105
+ ["up", [{ type: "inboxMove", to: "up" }]],
106
+ ["return", [{ type: "openSession" }]],
107
+ ["enter", [{ type: "openSession" }]],
108
+ ["q", [{ type: "exit" }]],
109
+ ["c", []],
110
+ ["escape", []],
111
+ ];
112
+ for (const [name, expected] of table) {
113
+ test(`${name} -> ${JSON.stringify(expected)}`, () => {
114
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
115
+ });
116
+ }
117
+
118
+ test("an empty inbox swallows every non-quit key", () => {
119
+ // Arrange
120
+ const empty = state({ view: "inbox", hasInboxItems: false });
121
+
122
+ // Assert
123
+ for (const name of ["j", "k", "down", "up", "return", "enter"]) {
124
+ expect(reduceKey(empty, key(name))).toEqual([]);
125
+ }
126
+ expect(reduceKey(empty, key("q"))).toEqual([{ type: "exit" }]);
127
+ });
128
+ });
129
+
130
+ describe("collaborator (share) capabilities", () => {
131
+ test("can annotate: comment opens the composer", () => {
132
+ // Arrange
133
+ const collab = state({ canEditPlan: false, canSubmitVerdict: false });
134
+
135
+ // Act / Assert
136
+ expect(reduceKey(collab, key("c"), "comment")).toEqual([{ type: "openCompose", kind: "comment", from: "cursor" }]);
137
+ });
138
+
139
+ test("can edit their own card: edit with a focused annotation rewrites it", () => {
140
+ // Arrange
141
+ const collab = state({ canEditPlan: false, hasFocusedAnnotation: true });
142
+
143
+ // Act / Assert
144
+ expect(reduceKey(collab, key("e"), "edit")).toEqual([{ type: "editCard" }]);
145
+ });
146
+
147
+ test("cannot edit the plan: cut/edit with no card is silent (no nag, no button)", () => {
148
+ // Arrange
149
+ const collab = state({ canEditPlan: false, hasFocusedAnnotation: false });
150
+
151
+ // Act / Assert
152
+ expect(reduceKey(collab, key("e"), "edit")).toEqual([]);
153
+ expect(reduceKey(collab, key("c"), "cut")).toEqual([]);
154
+ });
155
+
156
+ test("cannot submit a verdict: there is no agent on a share", () => {
157
+ // Arrange
158
+ const collab = state({ canSubmitVerdict: false });
159
+
160
+ // Act / Assert
161
+ expect(reduceKey(collab, key("x"), "submit")).toEqual([{ type: "status", message: "shared view - your notes save as you go; q to leave" }]);
162
+ });
163
+
164
+ test("cannot re-share: only the owner shares", () => {
165
+ // Arrange
166
+ const collab = state({ canShare: false });
167
+
168
+ // Act / Assert
169
+ expect(reduceKey(collab, key("S", true), "share")).toEqual([{ type: "status", message: "only the plan owner can share" }]);
170
+ });
171
+ });
172
+
173
+ describe("share", () => {
174
+ test("the owner's share key publishes", () => {
175
+ // Act / Assert
176
+ expect(reduceKey(state(), key("S", true), "share")).toEqual([{ type: "share" }]);
177
+ });
178
+
179
+ test("an observer cannot share", () => {
180
+ // Act / Assert
181
+ expect(reduceKey(state({ readOnly: true }), key("S", true), "share")).toEqual([{ type: "status", message: "observer - read-only" }]);
182
+ });
183
+ });
184
+
185
+ describe("plan normal mode", () => {
186
+ // Arrange
187
+ const keyState = state();
188
+ const table: [string, boolean, Intent[]][] = [
189
+ ["j", false, [{ type: "move", to: "down" }]],
190
+ ["down", false, [{ type: "move", to: "down" }]],
191
+ ["k", false, [{ type: "move", to: "up" }]],
192
+ ["up", false, [{ type: "move", to: "up" }]],
193
+ ["g", false, [{ type: "move", to: "top" }]],
194
+ ["g", true, [{ type: "move", to: "bottom" }]],
195
+ ["v", false, [{ type: "startSpan" }]],
196
+ ["c", false, [{ type: "openCompose", kind: "comment", from: "cursor" }]],
197
+ ["s", false, [{ type: "openCompose", kind: "suggestion", from: "cursor" }]],
198
+ // the default state has a selected card, so Cut and edit act on the card
199
+ ["x", false, [{ type: "removeAnnotation" }]],
200
+ ["e", false, [{ type: "editCard" }]],
201
+ ["n", false, [{ type: "nextAnnotation" }]],
202
+ ["p", false, [{ type: "prevAnnotation" }]],
203
+ ["backspace", false, [{ type: "removeAnnotation" }]],
204
+ ["return", false, [{ type: "openSubmit" }]],
205
+ ["q", false, [{ type: "exit" }]],
206
+ ["escape", false, [{ type: "deselect" }]],
207
+ ["z", false, []],
208
+ ];
209
+ for (const [name, shift, expected] of table) {
210
+ test(`${shift ? "shift+" : ""}${name} -> ${JSON.stringify(expected)}`, () => {
211
+ expect(reduceKey(keyState, key(name, shift))).toEqual(expected);
212
+ });
213
+ }
214
+
215
+ test("resolved sessions guard the mutating verbs", () => {
216
+ // Arrange
217
+ const resolvedState = state({ resolved: true });
218
+
219
+ // Assert
220
+ for (const name of ["c", "s", "x", "e"]) {
221
+ expect(reduceKey(resolvedState, key(name))).toEqual([{ type: "status", message: "review submitted - read-only" }]);
222
+ }
223
+ expect(reduceKey(resolvedState, key("return"))).toEqual([]);
224
+ expect(reduceKey(resolvedState, key("backspace"))).toEqual([]);
225
+ });
226
+
227
+ test("cut text cannot host a comment or a span", () => {
228
+ // Arrange
229
+ const cut = state({ cursorAnnotatable: false });
230
+
231
+ // Assert
232
+ expect(reduceKey(cut, key("c"))).toEqual([{ type: "status", message: "text is cut - restore it first" }]);
233
+ expect(reduceKey(cut, key("s"))).toEqual([{ type: "status", message: "text is cut - restore it first" }]);
234
+ expect(reduceKey(cut, key("v"))).toEqual([]);
235
+ });
236
+
237
+ test("annotation navigation without annotations reports, delete without focus is silent", () => {
238
+ // Arrange
239
+ const none = state({ annotationCount: 0, hasFocusedAnnotation: false });
240
+
241
+ // Assert
242
+ expect(reduceKey(none, key("n"))).toEqual([{ type: "status", message: "no annotations" }]);
243
+ expect(reduceKey(none, key("p"))).toEqual([{ type: "status", message: "no annotations" }]);
244
+ expect(reduceKey(none, key("backspace"))).toEqual([]);
245
+ });
246
+
247
+ test("without a selected card x cuts the block and e opens the editor", () => {
248
+ // Arrange
249
+ const unfocused = state({ hasFocusedAnnotation: false });
250
+
251
+ // Assert
252
+ expect(reduceKey(unfocused, key("x"))).toEqual([{ type: "cut" }]);
253
+ expect(reduceKey(unfocused, key("e"))).toEqual([{ type: "edit" }]);
254
+ });
255
+ });
256
+
257
+ describe("span mode", () => {
258
+ // Arrange
259
+ const keyState = state({ spanMode: true });
260
+ const table: [string, Intent[]][] = [
261
+ ["l", [{ type: "spanKey", name: "l" }]],
262
+ ["h", [{ type: "spanKey", name: "h" }]],
263
+ ["w", [{ type: "spanKey", name: "w" }]],
264
+ ["b", [{ type: "spanKey", name: "b" }]],
265
+ ["$", [{ type: "spanKey", name: "$" }]],
266
+ ["0", [{ type: "spanKey", name: "0" }]],
267
+ ["c", [{ type: "openCompose", kind: "comment", from: "span" }]],
268
+ ["s", [{ type: "openCompose", kind: "suggestion", from: "span" }]],
269
+ ["escape", [{ type: "closeOverlay" }]],
270
+ ["q", [{ type: "exit" }]],
271
+ ["j", []],
272
+ ];
273
+ for (const [name, expected] of table) {
274
+ test(`${name} -> ${JSON.stringify(expected)}`, () => {
275
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
276
+ });
277
+ }
278
+ });
279
+
280
+ describe("diff mode", () => {
281
+ // Arrange
282
+ const keyState = state({ view: "diff" });
283
+ const table: [string, Intent[]][] = [
284
+ ["j", [{ type: "move", to: "down" }]],
285
+ ["k", [{ type: "move", to: "up" }]],
286
+ ["g", [{ type: "move", to: "top" }]],
287
+ ["c", [{ type: "openCompose", kind: "comment", from: "cursor" }]],
288
+ ["n", [{ type: "nextAnnotation" }]],
289
+ ["p", [{ type: "prevAnnotation" }]],
290
+ ["backspace", [{ type: "removeAnnotation" }]],
291
+ ["return", [{ type: "openSubmit" }]],
292
+ ["v", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
293
+ ["x", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
294
+ ["e", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
295
+ ["s", [{ type: "status", message: "plan-only verb - diff review uses c on a line" }]],
296
+ ["q", [{ type: "exit" }]],
297
+ ];
298
+ for (const [name, expected] of table) {
299
+ test(`${name} -> ${JSON.stringify(expected)}`, () => {
300
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
301
+ });
302
+ }
303
+
304
+ test("comment guards: resolved first, then non-code rows", () => {
305
+ // Assert
306
+ expect(reduceKey(state({ view: "diff", resolved: true }), key("c"))).toEqual([
307
+ { type: "status", message: "review submitted - read-only" },
308
+ ]);
309
+ expect(reduceKey(state({ view: "diff", cursorAnnotatable: false }), key("c"))).toEqual([
310
+ { type: "status", message: "move to a code line to comment" },
311
+ ]);
312
+ });
313
+ });
314
+
315
+ describe("guided walk", () => {
316
+ test("w enters the walk in diff view; resolved and read-only sessions answer", () => {
317
+ // Assert
318
+ expect(reduceKey(state({ view: "diff" }), key("w"))).toEqual([{ type: "walkStart" }]);
319
+ expect(reduceKey(state({ view: "diff", resolved: true }), key("w"))).toEqual([
320
+ { type: "status", message: "review submitted - read-only" },
321
+ ]);
322
+ expect(reduceKey(state({ view: "diff", readOnly: true }), key("w"))).toEqual([
323
+ { type: "status", message: "observer - read-only" },
324
+ ]);
325
+ expect(reduceKey(state(), key("w"))).toEqual([{ type: "status", message: "the guided walk is a diff-review mode" }]);
326
+ });
327
+
328
+ // Arrange
329
+ const walking = state({ view: "diff", overlay: "walk" });
330
+ const table: [string, Intent[]][] = [
331
+ ["]", [{ type: "walkForward" }]],
332
+ ["[", [{ type: "walkBack" }]],
333
+ ["escape", [{ type: "walkLeave" }]],
334
+ ["q", [{ type: "exit" }]],
335
+ // mid-walk return does nothing: submit lives on the end card only
336
+ ["return", []],
337
+ ["j", []],
338
+ ["c", []],
339
+ ];
340
+ for (const [name, expected] of table) {
341
+ test(`walking ${name} -> ${JSON.stringify(expected)}`, () => {
342
+ expect(reduceKey(walking, key(name))).toEqual(expected);
343
+ });
344
+ }
345
+
346
+ test("the end card's return leaves the walk and opens the submit confirm", () => {
347
+ // Arrange
348
+ const atEnd = state({ view: "diff", overlay: "walk", walkAtEnd: true });
349
+
350
+ // Assert
351
+ expect(reduceKey(atEnd, key("return"))).toEqual([{ type: "walkLeave" }, { type: "openSubmit" }]);
352
+ expect(reduceKey(atEnd, key("enter"))).toEqual([{ type: "walkLeave" }, { type: "openSubmit" }]);
353
+ });
354
+ });
355
+
356
+ describe("review panel controls", () => {
357
+ const table: [string, Intent[]][] = [
358
+ ["b", [{ type: "cycleReviewPanel" }]],
359
+ ["]", [{ type: "resizeReviewPanel", direction: 1 }]],
360
+ ["[", [{ type: "resizeReviewPanel", direction: -1 }]],
361
+ ];
362
+ for (const view of ["plan", "diff"] as const) {
363
+ for (const [name, expected] of table) {
364
+ test(`${view} ${name} -> ${JSON.stringify(expected)}`, () => {
365
+ expect(reduceKey(state({ view }), key(name))).toEqual(expected);
366
+ });
367
+ }
368
+ }
369
+
370
+ test("observers may still collapse and resize the panel (it is view state, not a mutation)", () => {
371
+ // Arrange
372
+ const observer = state({ readOnly: true });
373
+
374
+ // Assert
375
+ expect(reduceKey(observer, key("b"))).toEqual([{ type: "cycleReviewPanel" }]);
376
+ expect(reduceKey(observer, key("]"))).toEqual([{ type: "resizeReviewPanel", direction: 1 }]);
377
+ });
378
+
379
+ test("span mode still owns b as a span verb, not a panel cycle", () => {
380
+ // Assert
381
+ expect(reduceKey(state({ spanMode: true }), key("b"))).toEqual([{ type: "spanKey", name: "b" }]);
382
+ });
383
+
384
+ test("the walk overlay keeps the brackets for stepping", () => {
385
+ // Arrange
386
+ const walking = state({ view: "diff", overlay: "walk" });
387
+
388
+ // Assert
389
+ expect(reduceKey(walking, key("]"))).toEqual([{ type: "walkForward" }]);
390
+ expect(reduceKey(walking, key("["))).toEqual([{ type: "walkBack" }]);
391
+ });
392
+ });
393
+
394
+ describe("duplicated-branch collapse", () => {
395
+ test("plan and diff produce the same intents for annotation nav, delete, and submit", () => {
396
+ for (const patch of [
397
+ {},
398
+ { annotationCount: 0, hasFocusedAnnotation: false },
399
+ { resolved: true },
400
+ ] as Partial<KeyState>[]) {
401
+ for (const name of ["n", "p", "backspace", "return"]) {
402
+ const plan = reduceKey(state(patch), key(name));
403
+ const diff = reduceKey(state({ ...patch, view: "diff" }), key(name));
404
+ expect(diff).toEqual(plan);
405
+ }
406
+ }
407
+ });
408
+ });
409
+
410
+ describe("read-only filter", () => {
411
+ test("every mutating key answers observer - read-only, in plan and diff", () => {
412
+ for (const view of ["plan", "diff"] as const) {
413
+ const keyState = state({ view, readOnly: true });
414
+ for (const name of ["c", "s", "x", "e", "backspace", "return", "enter"]) {
415
+ expect(reduceKey(keyState, key(name))).toEqual([{ type: "status", message: "observer - read-only" }]);
416
+ }
417
+ }
418
+ });
419
+
420
+ test("navigation and annotation focus still work for observers", () => {
421
+ // Arrange
422
+ const keyState = state({ readOnly: true });
423
+
424
+ // Assert
425
+ expect(reduceKey(keyState, key("j"))).toEqual([{ type: "move", to: "down" }]);
426
+ expect(reduceKey(keyState, key("n"))).toEqual([{ type: "nextAnnotation" }]);
427
+ expect(reduceKey(keyState, key("q"))).toEqual([{ type: "exit" }]);
428
+ });
429
+
430
+ test("span-mode c/s are gated by key name even under a rebound keymap", () => {
431
+ // Arrange
432
+ const rebound = { ...DEFAULT_KEYS, comment: ["m"], suggest: ["t"] };
433
+ const keyState = state({ spanMode: true, readOnly: true, keys: rebound });
434
+
435
+ // Assert
436
+ expect(reduceKey(keyState, key("c"))).toEqual([{ type: "status", message: "observer - read-only" }]);
437
+ expect(reduceKey(keyState, key("s"))).toEqual([{ type: "status", message: "observer - read-only" }]);
438
+ });
439
+ });
440
+
441
+ describe("inbox delete", () => {
442
+ test("d on a session requests delete", () => {
443
+ // Arrange / Act / Assert
444
+ expect(reduceKey(state({ view: "inbox" }), key("d"))).toEqual([{ type: "requestDeleteSession" }]);
445
+ });
446
+ });
447
+
448
+ describe("confirm overlay", () => {
449
+ // Arrange
450
+ const keyState = state({ overlay: "confirm" });
451
+ const table: [string, Intent[]][] = [
452
+ ["return", [{ type: "confirmDialog" }]],
453
+ ["enter", [{ type: "confirmDialog" }]],
454
+ ["escape", [{ type: "closeOverlay" }]],
455
+ ["j", []],
456
+ ["d", []],
457
+ ];
458
+ for (const [name, expected] of table) {
459
+ test(`${name} -> ${JSON.stringify(expected)}`, () => {
460
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
461
+ });
462
+ }
463
+ });
464
+
465
+ describe("rename grammar", () => {
466
+ test("r on a focused note opens rename", () => {
467
+ // Arrange / Act / Assert
468
+ expect(reduceKey(state({ hasFocusedAnnotation: true }), key("r"))).toEqual([{ type: "openRename" }]);
469
+ });
470
+
471
+ test("prompt overlay routes ⏎ to confirm and esc to cancel", () => {
472
+ // Arrange / Act / Assert
473
+ expect(reduceKey(state({ overlay: "prompt" }), key("return"))).toEqual([{ type: "confirmDialog" }]);
474
+ expect(reduceKey(state({ overlay: "prompt" }), key("escape"))).toEqual([{ type: "closeOverlay" }]);
475
+ expect(reduceKey(state({ overlay: "prompt" }), key("a"))).toEqual([]);
476
+ });
477
+ });