@cueloop/client 0.1.0-alpha.9 → 0.1.0-alpha.90

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