@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.71

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 (248) 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 +39 -32
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +76 -50
  10. package/src/App.diff.test.tsx +156 -42
  11. package/src/App.export.test.tsx +58 -40
  12. package/src/App.plan-review.test.tsx +363 -0
  13. package/src/App.prototype-markdown.test.tsx +92 -0
  14. package/src/App.prototype.test.tsx +176 -0
  15. package/src/App.readonly.test.tsx +128 -67
  16. package/src/App.rendering.test.tsx +61 -35
  17. package/src/App.submit-confirm.test.tsx +191 -0
  18. package/src/App.test.tsx +310 -114
  19. package/src/App.toast.test.tsx +89 -0
  20. package/src/App.tsx +1043 -798
  21. package/src/App.walk.test.tsx +298 -0
  22. package/src/App.workbench.test.tsx +495 -0
  23. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  24. package/src/annotation-palette.ts +61 -0
  25. package/src/app-key-state.ts +61 -0
  26. package/src/app-screens.tsx +432 -0
  27. package/src/app-view-model.ts +159 -0
  28. package/src/attribution.test.ts +31 -0
  29. package/src/attribution.ts +42 -0
  30. package/src/clipboard.test.ts +23 -0
  31. package/src/clipboard.ts +33 -0
  32. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  33. package/src/components/AnnotatableFileView.test.tsx +102 -0
  34. package/src/components/AnnotatableFileView.tsx +126 -0
  35. package/src/components/AnnotationCards.stories.tsx +87 -0
  36. package/src/components/AnnotationCards.tsx +303 -0
  37. package/src/components/AppHeader.stories.tsx +50 -0
  38. package/src/components/AppHeader.tsx +83 -0
  39. package/src/components/AppShell.stories.tsx +89 -0
  40. package/src/components/AppShell.tsx +290 -0
  41. package/src/components/AppShell.zoom.test.tsx +135 -0
  42. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  43. package/src/components/BareWorkbenchFileView.tsx +64 -0
  44. package/src/components/Breadcrumb.stories.tsx +19 -0
  45. package/src/components/Breadcrumb.tsx +45 -0
  46. package/src/components/ChangesColumn.stories.tsx +39 -0
  47. package/src/components/ChangesColumn.tsx +171 -0
  48. package/src/components/CodeBlock.stories.tsx +42 -0
  49. package/src/components/CodeBlock.tsx +84 -0
  50. package/src/components/CompletionOverlay.stories.tsx +23 -0
  51. package/src/components/CompletionOverlay.tsx +60 -0
  52. package/src/components/ConfirmCard.stories.tsx +35 -0
  53. package/src/components/ConfirmCard.tsx +128 -0
  54. package/src/components/ConfirmDialog.stories.tsx +21 -0
  55. package/src/components/ConfirmDialog.tsx +66 -0
  56. package/src/components/DiffContentView.scroll.test.tsx +121 -0
  57. package/src/components/DiffContentView.split.test.tsx +140 -0
  58. package/src/components/DiffContentView.stories.tsx +80 -0
  59. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  60. package/src/components/DiffContentView.tsx +859 -0
  61. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  62. package/src/components/DiscussionMarkerRail.tsx +181 -0
  63. package/src/components/EditorGrid.stories.tsx +59 -0
  64. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  65. package/src/components/EditorGrid.tsx +368 -0
  66. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  67. package/src/components/FileContentsView.stories.tsx +16 -0
  68. package/src/components/FileContentsView.test.tsx +29 -0
  69. package/src/components/FileContentsView.tsx +98 -0
  70. package/src/components/KeybindsDialog.stories.tsx +31 -0
  71. package/src/components/KeybindsDialog.tsx +103 -0
  72. package/src/components/MenuBar.stories.tsx +28 -0
  73. package/src/components/MenuBar.tsx +44 -0
  74. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  75. package/src/components/OverlayScrollbar.test.tsx +139 -0
  76. package/src/components/OverlayScrollbar.tsx +129 -0
  77. package/src/components/PaneGrid.stories.tsx +84 -0
  78. package/src/components/PaneGrid.tsx +128 -0
  79. package/src/components/PanelColumn.stories.tsx +25 -0
  80. package/src/components/PanelColumn.tsx +95 -0
  81. package/src/components/ProjectTreeView.stories.tsx +25 -0
  82. package/src/components/ProjectTreeView.test.tsx +32 -0
  83. package/src/components/ProjectTreeView.tsx +83 -0
  84. package/src/components/PromptDialog.stories.tsx +36 -0
  85. package/src/components/PromptDialog.tsx +87 -0
  86. package/src/components/PrototypeContentView.stories.tsx +19 -0
  87. package/src/components/PrototypeContentView.tsx +446 -0
  88. package/src/components/RootOverlay.stories.tsx +44 -0
  89. package/src/components/RootOverlay.tsx +35 -0
  90. package/src/components/ScrollArea.stories.tsx +20 -0
  91. package/src/components/ScrollArea.tsx +44 -0
  92. package/src/components/SettingsDialog.stories.tsx +117 -0
  93. package/src/components/SettingsDialog.tsx +195 -0
  94. package/src/components/SettingsRows.stories.tsx +18 -0
  95. package/src/components/SettingsRows.tsx +112 -0
  96. package/src/components/ShellHeader.stories.tsx +50 -0
  97. package/src/components/ShellHeader.tsx +137 -0
  98. package/src/components/ThreadFooter.stories.tsx +30 -0
  99. package/src/components/ThreadFooter.test.tsx +31 -0
  100. package/src/components/ThreadFooter.tsx +54 -0
  101. package/src/components/ThreadTree.stories.tsx +46 -0
  102. package/src/components/ThreadTree.tsx +219 -0
  103. package/src/components/ThreadView.selection.test.tsx +812 -0
  104. package/src/components/ThreadView.slash.test.tsx +158 -0
  105. package/src/components/ThreadView.stories.tsx +26 -0
  106. package/src/components/ThreadView.tsx +330 -0
  107. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  108. package/src/components/ThreadsSidebar.tsx +71 -0
  109. package/src/components/Toast.stories.tsx +12 -0
  110. package/src/components/Toast.tsx +45 -0
  111. package/src/components/Tooltip.stories.tsx +26 -0
  112. package/src/components/Tooltip.tsx +72 -0
  113. package/src/components/TreePane.stories.tsx +106 -0
  114. package/src/components/TreePane.tsx +157 -0
  115. package/src/components/WalkWizard.stories.tsx +100 -0
  116. package/src/components/WalkWizard.tsx +152 -0
  117. package/src/components/WelcomePlayground.stories.tsx +12 -0
  118. package/src/components/WelcomePlayground.test.tsx +47 -0
  119. package/src/components/WelcomePlayground.tsx +140 -0
  120. package/src/components/__snapshots__/stories.test.tsx.snap +1845 -0
  121. package/src/components/agent-launcher.stories.tsx +26 -0
  122. package/src/components/agent-launcher.test.tsx +50 -0
  123. package/src/components/agent-launcher.tsx +149 -0
  124. package/src/components/diff-content-view-layout.test.ts +198 -0
  125. package/src/components/diff-content-view-layout.ts +84 -0
  126. package/src/components/editor-grid.test.ts +86 -0
  127. package/src/components/editor-grid.ts +138 -0
  128. package/src/components/fade-title.test.ts +49 -0
  129. package/src/components/fade-title.ts +61 -0
  130. package/src/components/file-tree.test.ts +60 -0
  131. package/src/components/file-tree.ts +112 -0
  132. package/src/components/pages/Workspace.stories.tsx +181 -0
  133. package/src/components/primitives/Button.stories.tsx +32 -0
  134. package/src/components/primitives/Button.tsx +52 -0
  135. package/src/components/primitives/Card.stories.tsx +25 -0
  136. package/src/components/primitives/Card.tsx +60 -0
  137. package/src/components/primitives/Dialog.stories.tsx +24 -0
  138. package/src/components/primitives/Dialog.tsx +66 -0
  139. package/src/components/primitives/IconButton.stories.tsx +36 -0
  140. package/src/components/primitives/IconButton.tsx +62 -0
  141. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  142. package/src/components/primitives/StatusBar.tsx +23 -0
  143. package/src/components/primitives/Tabs.stories.tsx +29 -0
  144. package/src/components/primitives/Tabs.tsx +102 -0
  145. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  146. package/src/components/primitives/Toolbar.tsx +14 -0
  147. package/src/components/primitives/Tree.stories.tsx +124 -0
  148. package/src/components/primitives/Tree.tsx +96 -0
  149. package/src/components/primitives/frame.test.ts +12 -0
  150. package/src/components/primitives/frame.ts +14 -0
  151. package/src/components/primitives/icons.ts +61 -0
  152. package/src/components/primitives/tree-model.test.ts +99 -0
  153. package/src/components/primitives/tree-model.ts +137 -0
  154. package/src/components/quick-actions-editor.stories.tsx +54 -0
  155. package/src/components/quick-actions-editor.test.tsx +49 -0
  156. package/src/components/quick-actions-editor.tsx +150 -0
  157. package/src/components/session-tree.test.ts +134 -0
  158. package/src/components/session-tree.ts +108 -0
  159. package/src/components/stories-app.tsx +184 -0
  160. package/src/components/stories.test.tsx +66 -0
  161. package/src/components/story-fixtures.ts +102 -0
  162. package/src/components/story-tree.test.ts +100 -0
  163. package/src/components/story.ts +142 -0
  164. package/src/components/syntax-highlight.ts +110 -0
  165. package/src/components/terminal-pane.test.tsx +40 -0
  166. package/src/components/terminal-pane.ts +199 -0
  167. package/src/components/theme-context.stories.tsx +46 -0
  168. package/src/components/theme-context.tsx +37 -0
  169. package/src/components/truncate-text.ts +7 -0
  170. package/src/config.test.ts +461 -31
  171. package/src/config.ts +389 -57
  172. package/src/diff-fold.test.ts +78 -0
  173. package/src/diff-fold.ts +126 -0
  174. package/src/diff-hunk-curate.test.ts +315 -0
  175. package/src/diff-hunk-curate.ts +153 -0
  176. package/src/diff-intraline.test.ts +165 -0
  177. package/src/diff-intraline.ts +146 -0
  178. package/src/diff-syntax.test.ts +112 -0
  179. package/src/diff-syntax.ts +179 -0
  180. package/src/discussions.ts +79 -0
  181. package/src/editor.test.ts +149 -0
  182. package/src/editor.ts +139 -9
  183. package/src/ghostty-terminal.test.ts +85 -0
  184. package/src/ghostty-terminal.ts +199 -0
  185. package/src/herdr.test.ts +9 -34
  186. package/src/herdr.ts +25 -21
  187. package/src/index.ts +11 -0
  188. package/src/integrations.test.ts +41 -0
  189. package/src/integrations.ts +32 -0
  190. package/src/intent-dispatch.test.ts +698 -0
  191. package/src/intent-dispatch.ts +700 -0
  192. package/src/key-bindings.test.ts +156 -0
  193. package/src/key-bindings.ts +465 -0
  194. package/src/keymap.test.ts +611 -0
  195. package/src/keymap.ts +410 -0
  196. package/src/kitty-image.test.ts +66 -0
  197. package/src/kitty-image.ts +138 -0
  198. package/src/mark-runs.ts +120 -0
  199. package/src/prototype-browser.test.ts +56 -0
  200. package/src/prototype-browser.ts +307 -0
  201. package/src/prototype-pixels.tsx +35 -0
  202. package/src/pty.test.ts +81 -0
  203. package/src/pty.ts +245 -0
  204. package/src/ready-signal.test.ts +85 -0
  205. package/src/ready-signal.ts +52 -0
  206. package/src/run.ts +30 -9
  207. package/src/serve.test.ts +24 -12
  208. package/src/serve.ts +20 -9
  209. package/src/share.ts +215 -0
  210. package/src/skills.test.ts +74 -0
  211. package/src/skills.ts +68 -0
  212. package/src/slash-palette.test.ts +54 -0
  213. package/src/slash-palette.ts +104 -0
  214. package/src/split-diff.test.ts +110 -0
  215. package/src/split-diff.ts +149 -0
  216. package/src/test-support.test.ts +264 -0
  217. package/src/test-support.ts +355 -0
  218. package/src/theme-presets.test.ts +51 -0
  219. package/src/theme-presets.ts +214 -0
  220. package/src/theme.ts +84 -19
  221. package/src/thread-chords.test.ts +170 -0
  222. package/src/thread-chords.ts +169 -0
  223. package/src/thread-controller.curate.test.ts +438 -0
  224. package/src/thread-controller.live-diff.test.ts +199 -0
  225. package/src/thread-controller.share.test.ts +472 -0
  226. package/src/thread-controller.tree.test.ts +255 -0
  227. package/src/thread-controller.ts +1509 -0
  228. package/src/thread-selection.test.ts +210 -0
  229. package/src/thread-selection.ts +243 -0
  230. package/src/tree-view.test.ts +128 -0
  231. package/src/tree-view.ts +148 -0
  232. package/src/use-annotation-surface.tsx +851 -0
  233. package/src/use-changes-workbench.ts +153 -0
  234. package/src/use-frame-measure.ts +38 -0
  235. package/src/use-settings-dialog.tsx +274 -0
  236. package/src/use-terminal-virtualizer.ts +233 -0
  237. package/src/version.ts +5 -0
  238. package/src/view-diff.test.ts +66 -9
  239. package/src/view-diff.ts +231 -23
  240. package/src/view-plan.regression.test.ts +60 -0
  241. package/src/view-plan.test.ts +406 -0
  242. package/src/view-plan.ts +570 -0
  243. package/src/walk.test.ts +121 -0
  244. package/src/walk.ts +74 -0
  245. package/src/syntax.test.ts +0 -41
  246. package/src/syntax.ts +0 -88
  247. package/src/view.test.ts +0 -157
  248. package/src/view.ts +0 -312
@@ -0,0 +1,611 @@
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) {
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
+
41
+ for (const [name, expected] of table) {
42
+ test(`${name} -> ${JSON.stringify(expected)}`, () => {
43
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
44
+ });
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
+ });
55
+ });
56
+
57
+ describe("submit overlay", () => {
58
+ // Arrange
59
+ const keyState = state({ overlay: "submit" });
60
+ const table: [string, Intent[]][] = [
61
+ ["escape", [{ type: "closeOverlay" }]],
62
+ ["return", [{ type: "submitVerdict" }]],
63
+ ["enter", [{ type: "submitVerdict" }]],
64
+ ["left", [{ type: "cycleVerdict", direction: -1 }]],
65
+ ["right", [{ type: "cycleVerdict", direction: 1 }]],
66
+ ["j", []],
67
+ ["q", []],
68
+ ];
69
+
70
+ for (const [name, expected] of table) {
71
+ test(`${name} -> ${JSON.stringify(expected)}`, () => {
72
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
73
+ });
74
+ }
75
+ });
76
+
77
+ describe("completion overlay", () => {
78
+ // Arrange
79
+ const prompt = state({ overlay: "completion-prompt" });
80
+ const counting = state({ overlay: "completion-counting" });
81
+ const table: [KeyState, string, Intent[]][] = [
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" }]],
87
+ [prompt, "j", []],
88
+ [counting, "return", [{ type: "finishReview" }]],
89
+ [counting, "q", [{ type: "finishReview" }]],
90
+ [counting, "a", [{ type: "optInAutoClose" }]],
91
+ [counting, "escape", [{ type: "dismissCompletion" }]],
92
+ ];
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);
97
+ });
98
+ }
99
+ });
100
+
101
+ describe("inbox mode", () => {
102
+ // Arrange
103
+ const keyState = state({ view: "inbox" });
104
+ const table: [string, Intent[]][] = [
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" }]],
112
+ ["c", []],
113
+ ["escape", []],
114
+ ];
115
+
116
+ for (const [name, expected] of table) {
117
+ test(`${name} -> ${JSON.stringify(expected)}`, () => {
118
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
119
+ });
120
+ }
121
+
122
+ test("an empty inbox swallows every non-quit key", () => {
123
+ // Arrange
124
+ const empty = state({ view: "inbox", hasInboxItems: false });
125
+
126
+ // Assert
127
+ for (const name of ["j", "k", "down", "up", "return", "enter"]) {
128
+ expect(reduceKey(empty, key(name))).toEqual([]);
129
+ }
130
+ expect(reduceKey(empty, key("q"))).toEqual([{ type: "exit" }]);
131
+ });
132
+ });
133
+
134
+ describe("collaborator (share) capabilities", () => {
135
+ test("can annotate: comment opens the composer", () => {
136
+ // Arrange
137
+ const collab = state({ canEditPlan: false, canSubmitVerdict: false });
138
+
139
+ // Act / Assert
140
+ expect(reduceKey(collab, key("c"), "comment")).toEqual([
141
+ { type: "openCompose", kind: "comment", from: "cursor" },
142
+ ]);
143
+ });
144
+
145
+ test("can edit their own card: edit with a focused annotation rewrites it", () => {
146
+ // Arrange
147
+ const collab = state({ canEditPlan: false, hasFocusedAnnotation: true });
148
+
149
+ // Act / Assert
150
+ expect(reduceKey(collab, key("e"), "edit")).toEqual([{ type: "editCard" }]);
151
+ });
152
+
153
+ test("cannot edit the plan: cut/edit with no card is silent (no nag, no button)", () => {
154
+ // Arrange
155
+ const collab = state({ canEditPlan: false, hasFocusedAnnotation: false });
156
+
157
+ // Act / Assert
158
+ expect(reduceKey(collab, key("e"), "edit")).toEqual([]);
159
+ expect(reduceKey(collab, key("c"), "cut")).toEqual([]);
160
+ });
161
+
162
+ test("cannot submit a verdict: there is no agent on a share", () => {
163
+ // Arrange
164
+ const collab = state({ canSubmitVerdict: false });
165
+
166
+ // Act / Assert
167
+ expect(reduceKey(collab, key("x"), "submit")).toEqual([
168
+ { type: "status", message: "shared view - your notes save as you go; q to leave" },
169
+ ]);
170
+ });
171
+
172
+ test("cannot re-share: only the owner shares", () => {
173
+ // Arrange
174
+ const collab = state({ canShare: false });
175
+
176
+ // Act / Assert
177
+ expect(reduceKey(collab, key("S", true), "share")).toEqual([
178
+ { type: "status", message: "only the plan owner can share" },
179
+ ]);
180
+ });
181
+ });
182
+
183
+ describe("share", () => {
184
+ test("the owner's share key publishes", () => {
185
+ // Act / Assert
186
+ expect(reduceKey(state(), key("S", true), "share")).toEqual([{ type: "share" }]);
187
+ });
188
+
189
+ test("an observer cannot share", () => {
190
+ // Act / Assert
191
+ expect(reduceKey(state({ readOnly: true }), key("S", true), "share")).toEqual([
192
+ { type: "status", message: "observer - read-only" },
193
+ ]);
194
+ });
195
+ });
196
+
197
+ describe("plan normal mode", () => {
198
+ // Arrange
199
+ const keyState = state();
200
+ const table: [string, boolean, Intent[]][] = [
201
+ ["j", false, [{ type: "move", to: "down" }]],
202
+ ["down", false, [{ type: "move", to: "down" }]],
203
+ ["k", false, [{ type: "move", to: "up" }]],
204
+ ["up", false, [{ type: "move", to: "up" }]],
205
+ ["g", false, [{ type: "move", to: "top" }]],
206
+ ["g", true, [{ type: "move", to: "bottom" }]],
207
+ ["v", false, [{ type: "startSpan" }]],
208
+ ["c", false, [{ type: "openCompose", kind: "comment", from: "cursor" }]],
209
+ // the default state has a selected card, so Cut and edit act on the card
210
+ ["x", false, [{ type: "removeAnnotation" }]],
211
+ ["e", false, [{ type: "editCard" }]],
212
+ ["n", false, [{ type: "nextAnnotation" }]],
213
+ ["p", false, [{ type: "prevAnnotation" }]],
214
+ ["backspace", false, [{ type: "removeAnnotation" }]],
215
+ ["return", false, [{ type: "openSubmit" }]],
216
+ ["q", false, [{ type: "exit" }]],
217
+ ["escape", false, [{ type: "deselect" }]],
218
+ ["z", false, []],
219
+ ];
220
+
221
+ for (const [name, shift, expected] of table) {
222
+ test(`${shift ? "shift+" : ""}${name} -> ${JSON.stringify(expected)}`, () => {
223
+ expect(reduceKey(keyState, key(name, shift))).toEqual(expected);
224
+ });
225
+ }
226
+
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
+ ]);
236
+ }
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
+ ]);
251
+ });
252
+
253
+ test("cut text cannot host a comment or a span", () => {
254
+ // Arrange
255
+ const cut = state({ cursorAnnotatable: false });
256
+
257
+ // Assert
258
+ expect(reduceKey(cut, key("c"))).toEqual([
259
+ { type: "status", message: "text is cut - restore it first" },
260
+ ]);
261
+ expect(reduceKey(cut, key("v"))).toEqual([]);
262
+ });
263
+
264
+ test("annotation navigation without annotations reports, delete without focus is silent", () => {
265
+ // Arrange
266
+ const none = state({ annotationCount: 0, hasFocusedAnnotation: false });
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" }]);
271
+ expect(reduceKey(none, key("backspace"))).toEqual([]);
272
+ });
273
+
274
+ test("without a selected card x cuts the block and e opens the editor", () => {
275
+ // Arrange
276
+ const unfocused = state({ hasFocusedAnnotation: false });
277
+
278
+ // Assert
279
+ expect(reduceKey(unfocused, key("x"))).toEqual([{ type: "cut" }]);
280
+ expect(reduceKey(unfocused, key("e"))).toEqual([{ type: "edit" }]);
281
+ });
282
+ });
283
+
284
+ describe("span mode", () => {
285
+ // Arrange
286
+ const keyState = state({ spanMode: true });
287
+ const table: [string, Intent[]][] = [
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" }]],
299
+ ["j", []],
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
+
370
+ for (const [name, expected] of table) {
371
+ test(`${name} -> ${JSON.stringify(expected)}`, () => {
372
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
373
+ });
374
+ }
375
+ });
376
+
377
+ describe("diff mode", () => {
378
+ // Arrange
379
+ const keyState = state({ view: "diff" });
380
+ const table: [string, Intent[]][] = [
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" }]],
394
+ ];
395
+
396
+ for (const [name, expected] of table) {
397
+ test(`${name} -> ${JSON.stringify(expected)}`, () => {
398
+ expect(reduceKey(keyState, key(name))).toEqual(expected);
399
+ });
400
+ }
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
+
436
+ test("comment guards: resolved first, then non-code rows", () => {
437
+ // Assert
438
+ expect(reduceKey(state({ view: "diff", resolved: true }), key("c"))).toEqual([
439
+ { type: "status", message: "review submitted - read-only" },
440
+ ]);
441
+ expect(reduceKey(state({ view: "diff", cursorAnnotatable: false }), key("c"))).toEqual([
442
+ { type: "status", message: "move to a code line to comment" },
443
+ ]);
444
+ });
445
+ });
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
+
512
+ describe("duplicated-branch collapse", () => {
513
+ test("plan and diff produce the same intents for annotation nav, delete, and submit", () => {
514
+ const patches: Partial<KeyState>[] = [
515
+ {},
516
+ { annotationCount: 0, hasFocusedAnnotation: false },
517
+ { resolved: true },
518
+ ];
519
+
520
+ for (const patch of patches) {
521
+ for (const name of ["n", "p", "backspace", "return"]) {
522
+ const plan = reduceKey(state(patch), key(name));
523
+ const diff = reduceKey(state({ ...patch, view: "diff" }), key(name));
524
+
525
+ expect(diff).toEqual(plan);
526
+ }
527
+ }
528
+ });
529
+ });
530
+
531
+ describe("read-only filter", () => {
532
+ test("every mutating key answers observer - read-only, in plan and diff", () => {
533
+ for (const view of ["plan", "diff"] as const) {
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
+ ]);
540
+ }
541
+ }
542
+ });
543
+
544
+ test("navigation and annotation focus still work for observers", () => {
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
+ ]);
572
+ });
573
+ });
574
+
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([]);
610
+ });
611
+ });