@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,698 @@
1
+ /**
2
+ * The intent reducer in isolation: with the App component's dependencies
3
+ * mocked, each Intent maps to exactly the controller call or state update it
4
+ * should - the navigation clamps, the annotation wrap-around, the verdict
5
+ * cycle, and the submit-reveal fix, none of which needed a rendered TUI.
6
+ */
7
+
8
+ import { describe, expect, mock, test } from "bun:test";
9
+ import type { SetStateAction } from "react";
10
+ import { SCHEMA_VERSION, type Annotation, type Thread } from "@cueloop/schema";
11
+ import { createIntentDispatch, type IntentDispatchDeps } from "./intent-dispatch";
12
+ import type { ControllerSnapshot, CurationItem, ReviewController } from "./thread-controller";
13
+ import type { DisplayBlock } from "./view-plan";
14
+ import type { TreeRow } from "./tree-view";
15
+
16
+ function block(text: string): DisplayBlock {
17
+ return { type: "same", kind: "p", work: { kind: "p", text, lineStart: 0, lineEnd: 0 } };
18
+ }
19
+
20
+ function annotation(id: string, overrides: Partial<Annotation> = {}): Annotation {
21
+ return {
22
+ id,
23
+ kind: "comment",
24
+ anchor: { quote: "", prefix: "", suffix: "" },
25
+ body: "",
26
+ createdAt: "2026-01-01T00:00:00.000Z",
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ function sessionWith(annotations: Annotation[], overrides: Partial<Thread> = {}): Thread {
32
+ return {
33
+ schemaVersion: SCHEMA_VERSION,
34
+ id: "ses_1",
35
+ workspace: { repoRoot: "/repo", branch: "main" },
36
+ artifact: { type: "plan", content: "# Plan\n", meta: {} },
37
+ revisions: [],
38
+ annotations,
39
+ verdict: null,
40
+ status: "pending",
41
+ createdAt: "2026-01-01T00:00:00.000Z",
42
+ ...overrides,
43
+ };
44
+ }
45
+
46
+ const EMPTY_SNAPSHOT: ControllerSnapshot = {
47
+ session: null,
48
+ inbox: null,
49
+ status: "",
50
+ toast: null,
51
+ error: null,
52
+ completion: { phase: "idle" },
53
+ editOrphanCount: 0,
54
+ walk: null,
55
+ };
56
+
57
+ /** A controller where every primitive is a mock; annotate returns undefined by default. */
58
+ function baseController(): ReviewController {
59
+ return {
60
+ readOnly: false,
61
+ subscribe: mock(() => () => {}),
62
+ getSnapshot: mock(() => EMPTY_SNAPSHOT),
63
+ connect: mock(),
64
+ close: mock(),
65
+ applyConfig: mock(),
66
+ setStatus: mock(),
67
+ showToast: mock(),
68
+ dismissToast: mock(),
69
+ display: mock(() => []),
70
+ rows: mock(() => []),
71
+ setFileCollapsed: mock(),
72
+ isFileCollapsed: mock(() => false),
73
+ setFileExpanded: mock(),
74
+ isFileExpanded: mock(() => false),
75
+ canExpandFile: mock(() => false),
76
+ copyFilePath: mock(),
77
+ fileStats: mock(() => new Map()),
78
+ files: mock(() => []),
79
+ working: mock(() => ""),
80
+ projectFiles: mock(() => Promise.resolve<string[]>([])),
81
+ readFile: mock(() => Promise.resolve<string | null>(null)),
82
+ repoFiles: mock(() => Promise.resolve<string[]>([])),
83
+ repoReadFile: mock(() => Promise.resolve<string | null>(null)),
84
+ repoChanges: mock(() => Promise.resolve([])),
85
+ open: mock(),
86
+ deleteSession: mock(),
87
+ renameSession: mock(),
88
+ setSelfName: mock(),
89
+ cut: mock(),
90
+ toggleRejectHunk: mock(),
91
+ toggleRejectChange: mock(),
92
+ rejectedRows: mock(() => new Set<number>()),
93
+ curationItems: mock(() => []),
94
+ restoreCuration: mock(),
95
+ edit: mock(),
96
+ annotate: mock(() => undefined),
97
+ addComment: mock(() => undefined),
98
+ commentOnWorkbench: mock(() => Promise.resolve()),
99
+ reply: mock(() => undefined),
100
+ annotatePrototype: mock(() => undefined),
101
+ updateAnnotation: mock(),
102
+ removeAnnotation: mock(),
103
+ setWorkingCopy: mock(),
104
+ walkStart: mock(),
105
+ walkForward: mock(),
106
+ walkBack: mock(),
107
+ walkLeave: mock(),
108
+ submit: mock(),
109
+ share: mock(),
110
+ treeRows: mock(() => []),
111
+ goToEntry: mock(),
112
+ branch: mock(),
113
+ labelTip: mock(),
114
+ fork: mock(),
115
+ forkAndShare: mock(),
116
+ pullShared: mock(() => Promise.resolve()),
117
+ startShareSync: mock(() => () => {}),
118
+ finishReview: mock(),
119
+ dismissCompletion: mock(),
120
+ optInAutoClose: mock(),
121
+ };
122
+ }
123
+
124
+ /** A deps bag where every effect is a mock and every read has a plain default. */
125
+ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDeps {
126
+ return {
127
+ controller: baseController(),
128
+ onExit: mock(),
129
+ isDiff: false,
130
+ display: [],
131
+ rows: [],
132
+ cursor: 0,
133
+ inbox: null,
134
+ inboxCursor: 0,
135
+ mode: { type: "normal" },
136
+ session: null,
137
+ focusedAnnotationId: undefined,
138
+ selectedCurationId: undefined,
139
+ railTab: "review",
140
+ selectedEntryId: undefined,
141
+ authorNames: {},
142
+ quickActions: [],
143
+ renameAuthor: mock(),
144
+ renameThread: mock(),
145
+ liveInput: { current: "" },
146
+ setCursor: mock(),
147
+ setInboxCursor: mock(),
148
+ setMode: mock(),
149
+ setRailTab: mock(),
150
+ setSelectedEntryId: mock(),
151
+ setFocusedAnnotationId: mock(),
152
+ setSelectedCurationId: mock(),
153
+ setPulsedAnnotationId: mock(),
154
+ selectCardFromDocument: mock(),
155
+ runEditorHandOff: mock(),
156
+ openCardEdit: mock(),
157
+ toggleDiffView: mock(),
158
+ ...overrides,
159
+ };
160
+ }
161
+
162
+ function isUpdater(action: SetStateAction<number>): action is (current: number) => number {
163
+ return typeof action === "function";
164
+ }
165
+
166
+ describe("move", () => {
167
+ test("down clamps at the last row and up clamps at the first", () => {
168
+ // Arrange
169
+ const setCursor = mock((_action: SetStateAction<number>) => {});
170
+ const deps = makeDeps({ display: [block("a"), block("b"), block("c")], setCursor });
171
+ const dispatch = createIntentDispatch(deps);
172
+
173
+ // Act
174
+ dispatch({ type: "move", to: "down" });
175
+
176
+ // Assert
177
+ const advance = setCursor.mock.calls[0]![0];
178
+
179
+ if (!isUpdater(advance)) throw new Error("expected setCursor to receive an updater");
180
+ expect(advance(2)).toBe(2); // already at the end, stays
181
+ expect(advance(0)).toBe(1);
182
+ });
183
+
184
+ test("bottom jumps to the last navigable row", () => {
185
+ // Arrange
186
+ const deps = makeDeps({ display: [block("a"), block("b"), block("c")] });
187
+ const dispatch = createIntentDispatch(deps);
188
+
189
+ // Act
190
+ dispatch({ type: "move", to: "bottom" });
191
+
192
+ // Assert
193
+ expect(deps.setCursor).toHaveBeenCalledWith(2);
194
+ });
195
+ });
196
+
197
+ describe("annotation navigation", () => {
198
+ test("nextAnnotation wraps from the last annotation back to the first", () => {
199
+ // Arrange
200
+ const deps = makeDeps({
201
+ session: sessionWith([annotation("a1"), annotation("a2"), annotation("a3")]),
202
+ focusedAnnotationId: "a3",
203
+ });
204
+ const dispatch = createIntentDispatch(deps);
205
+
206
+ // Act
207
+ dispatch({ type: "nextAnnotation" });
208
+
209
+ // Assert
210
+ expect(deps.selectCardFromDocument).toHaveBeenCalledWith("a1");
211
+ });
212
+
213
+ test("prevAnnotation wraps from the first annotation to the last", () => {
214
+ // Arrange
215
+ const deps = makeDeps({
216
+ session: sessionWith([annotation("a1"), annotation("a2"), annotation("a3")]),
217
+ focusedAnnotationId: "a1",
218
+ });
219
+ const dispatch = createIntentDispatch(deps);
220
+
221
+ // Act
222
+ dispatch({ type: "prevAnnotation" });
223
+
224
+ // Assert
225
+ expect(deps.selectCardFromDocument).toHaveBeenCalledWith("a3");
226
+ });
227
+
228
+ test("cycling skips annotations a revision already addressed", () => {
229
+ // Arrange: a2 is addressed, so next from a1 lands on a3
230
+ const session = sessionWith([
231
+ annotation("a1"),
232
+ annotation("a2", { resolution: { revision: 2, source: "agent" } }),
233
+ annotation("a3"),
234
+ ]);
235
+ const deps = makeDeps({ session, focusedAnnotationId: "a1" });
236
+ const dispatch = createIntentDispatch(deps);
237
+
238
+ // Act
239
+ dispatch({ type: "nextAnnotation" });
240
+
241
+ // Assert
242
+ expect(deps.selectCardFromDocument).toHaveBeenCalledWith("a3");
243
+ });
244
+ });
245
+
246
+ describe("cycleVerdict", () => {
247
+ test("advances through the verdict list in the submit overlay", () => {
248
+ // Arrange
249
+ const deps = makeDeps({ mode: { type: "submit", verdict: "approve", summary: "" } });
250
+ const dispatch = createIntentDispatch(deps);
251
+
252
+ // Act
253
+ dispatch({ type: "cycleVerdict", direction: 1 });
254
+
255
+ // Assert
256
+ expect(deps.setMode).toHaveBeenCalledWith({
257
+ type: "submit",
258
+ verdict: "request_changes",
259
+ summary: "",
260
+ });
261
+ });
262
+ });
263
+
264
+ describe("marker-actions popover", () => {
265
+ const span = { displayIndex: 3, wordIndex: 0, wordEnd: 0, start: 2, end: 9 };
266
+
267
+ test("spanCut cuts the span's block and closes the popover", () => {
268
+ // Arrange
269
+ const deps = makeDeps({ mode: { type: "span", span } });
270
+ const dispatch = createIntentDispatch(deps);
271
+
272
+ // Act
273
+ dispatch({ type: "spanCut" });
274
+
275
+ // Assert
276
+ expect(deps.controller.cut).toHaveBeenCalledWith(3);
277
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
278
+ });
279
+
280
+ test("openSpanActions enters the list at index 0", () => {
281
+ // Arrange
282
+ const deps = makeDeps({ mode: { type: "span", span } });
283
+ const dispatch = createIntentDispatch(deps);
284
+
285
+ // Act
286
+ dispatch({ type: "openSpanActions" });
287
+
288
+ // Assert
289
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "spanActions", span, index: 0 });
290
+ });
291
+
292
+ test("moveSpanAction clamps within the actions list", () => {
293
+ // Arrange
294
+ const deps = makeDeps({
295
+ mode: { type: "spanActions", span, index: 1 },
296
+ quickActions: [{ prompt: "one" }, { prompt: "two" }],
297
+ });
298
+ const dispatch = createIntentDispatch(deps);
299
+
300
+ // Act
301
+ dispatch({ type: "moveSpanAction", direction: 1 });
302
+
303
+ // Assert - already at the last row, so the index holds
304
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "spanActions", span, index: 1 });
305
+ });
306
+
307
+ test("pickSpanAction inserts the preset comment and returns to normal", () => {
308
+ // Arrange
309
+ const annotate = mock(() => "an_new");
310
+ const deps = makeDeps({
311
+ mode: { type: "spanActions", span, index: 1 },
312
+ session: sessionWith([]),
313
+ quickActions: [{ prompt: "Needs a test" }, { prompt: "YAGNI", metadata: "cut scope" }],
314
+ controller: { ...baseController(), annotate },
315
+ });
316
+ const dispatch = createIntentDispatch(deps);
317
+
318
+ // Act
319
+ dispatch({ type: "pickSpanAction" });
320
+
321
+ // Assert - metadata joins the prompt with a blank line
322
+ expect(annotate).toHaveBeenCalledWith("comment", 3, 2, 9, "YAGNI\n\ncut scope");
323
+ expect(deps.setFocusedAnnotationId).toHaveBeenCalledWith("an_new");
324
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
325
+ });
326
+
327
+ test("pickSpanAction honors an explicit index (mouse click)", () => {
328
+ // Arrange
329
+ const annotate = mock(() => "an_new");
330
+ const deps = makeDeps({
331
+ mode: { type: "spanActions", span, index: 0 },
332
+ session: sessionWith([]),
333
+ quickActions: [{ prompt: "Needs a test" }, { prompt: "Extract the duplication" }],
334
+ controller: { ...baseController(), annotate },
335
+ });
336
+ const dispatch = createIntentDispatch(deps);
337
+
338
+ // Act
339
+ dispatch({ type: "pickSpanAction", index: 1 });
340
+
341
+ // Assert
342
+ expect(annotate).toHaveBeenCalledWith("comment", 3, 2, 9, "Extract the duplication");
343
+ });
344
+
345
+ test("closeSpanActions returns to the span toolbar", () => {
346
+ // Arrange
347
+ const deps = makeDeps({ mode: { type: "spanActions", span, index: 2 } });
348
+ const dispatch = createIntentDispatch(deps);
349
+
350
+ // Act
351
+ dispatch({ type: "closeSpanActions" });
352
+
353
+ // Assert
354
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "span", span });
355
+ });
356
+ });
357
+
358
+ describe("openSubmit", () => {
359
+ test("opens the submit confirm with the default verdict", () => {
360
+ // Arrange
361
+ const deps = makeDeps({ session: sessionWith([]) });
362
+ const dispatch = createIntentDispatch(deps);
363
+
364
+ // Act
365
+ dispatch({ type: "openSubmit" });
366
+
367
+ // Assert
368
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "submit", verdict: "approve", summary: "" });
369
+ });
370
+
371
+ test("does nothing without a session", () => {
372
+ // Arrange
373
+ const deps = makeDeps({ session: null });
374
+ const dispatch = createIntentDispatch(deps);
375
+
376
+ // Act
377
+ dispatch({ type: "openSubmit" });
378
+
379
+ // Assert
380
+ expect(deps.setMode).not.toHaveBeenCalled();
381
+ });
382
+ });
383
+
384
+ describe("share", () => {
385
+ test("dispatches to the controller's share", () => {
386
+ // Arrange
387
+ const deps = makeDeps();
388
+ const dispatch = createIntentDispatch(deps);
389
+
390
+ // Act
391
+ dispatch({ type: "share" });
392
+
393
+ // Assert
394
+ expect(deps.controller.share).toHaveBeenCalled();
395
+ });
396
+ });
397
+
398
+ describe("restoreCuration", () => {
399
+ const items: CurationItem[] = [
400
+ { id: "diff:f#0#hunk", source: "diff", preview: [], revealIndex: 1 },
401
+ { id: "diff:f#1#2", source: "diff", preview: [], revealIndex: 9 },
402
+ ];
403
+
404
+ test("restores the selected item and clears the selection", () => {
405
+ // Arrange
406
+ const controller = { ...baseController(), curationItems: mock(() => items) };
407
+ const deps = makeDeps({ selectedCurationId: "diff:f#1#2", controller });
408
+ const dispatch = createIntentDispatch(deps);
409
+
410
+ // Act
411
+ dispatch({ type: "restoreCuration" });
412
+
413
+ // Assert
414
+ expect(deps.controller.restoreCuration).toHaveBeenCalledWith("diff:f#1#2");
415
+ expect(deps.setSelectedCurationId).toHaveBeenCalledWith(undefined);
416
+ });
417
+
418
+ test("with nothing selected, undoes the last removal", () => {
419
+ // Arrange
420
+ const controller = { ...baseController(), curationItems: mock(() => items) };
421
+ const deps = makeDeps({ selectedCurationId: undefined, controller });
422
+ const dispatch = createIntentDispatch(deps);
423
+
424
+ // Act
425
+ dispatch({ type: "restoreCuration" });
426
+
427
+ // Assert
428
+ expect(deps.controller.restoreCuration).toHaveBeenCalledWith("diff:f#1#2");
429
+ });
430
+
431
+ test("does nothing when there is nothing curated out", () => {
432
+ // Arrange
433
+ const deps = makeDeps();
434
+ const dispatch = createIntentDispatch(deps);
435
+
436
+ // Act
437
+ dispatch({ type: "restoreCuration" });
438
+
439
+ // Assert
440
+ expect(deps.controller.restoreCuration).not.toHaveBeenCalled();
441
+ });
442
+ });
443
+
444
+ describe("toggleDiffView", () => {
445
+ test("delegates to the App-owned toggle", () => {
446
+ // Arrange
447
+ const deps = makeDeps();
448
+ const dispatch = createIntentDispatch(deps);
449
+
450
+ // Act
451
+ dispatch({ type: "toggleDiffView" });
452
+
453
+ // Assert
454
+ expect(deps.toggleDiffView).toHaveBeenCalled();
455
+ });
456
+ });
457
+
458
+ describe("exit", () => {
459
+ test("calls the exit hook with 0", () => {
460
+ // Arrange
461
+ const deps = makeDeps();
462
+ const dispatch = createIntentDispatch(deps);
463
+
464
+ // Act
465
+ dispatch({ type: "exit" });
466
+
467
+ // Assert
468
+ expect(deps.onExit).toHaveBeenCalledWith(0);
469
+ });
470
+ });
471
+
472
+ describe("inbox delete", () => {
473
+ test("requestDeleteSession opens the confirm on the cursor row", () => {
474
+ // Arrange
475
+ const inbox = [
476
+ sessionWith([], { artifact: { type: "plan", content: "", meta: { title: "Plan A" } } }),
477
+ ];
478
+ const deps = makeDeps({ inbox, inboxCursor: 0 });
479
+
480
+ // Act
481
+ createIntentDispatch(deps)({ type: "requestDeleteSession" });
482
+
483
+ // Assert
484
+ expect(deps.setMode).toHaveBeenCalledWith({
485
+ type: "confirmDelete",
486
+ sessionId: "ses_1",
487
+ title: "Plan A",
488
+ });
489
+ });
490
+
491
+ test("confirmDialog deletes the session and closes", () => {
492
+ // Arrange
493
+ const deps = makeDeps({ mode: { type: "confirmDelete", sessionId: "ses_1", title: "Plan A" } });
494
+
495
+ // Act
496
+ createIntentDispatch(deps)({ type: "confirmDialog" });
497
+
498
+ // Assert
499
+ expect(deps.controller.deleteSession).toHaveBeenCalledWith("ses_1");
500
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
501
+ });
502
+ });
503
+
504
+ describe("rename author", () => {
505
+ test("openRename seeds the prompt with the author's current local name", () => {
506
+ // Arrange
507
+ const session = sessionWith([annotation("a1", { author: "SHA256:x" })]);
508
+ const deps = makeDeps({
509
+ session,
510
+ focusedAnnotationId: "a1",
511
+ authorNames: { "SHA256:x": "Alex" },
512
+ });
513
+
514
+ // Act
515
+ createIntentDispatch(deps)({ type: "openRename" });
516
+
517
+ // Assert
518
+ expect(deps.setMode).toHaveBeenCalledWith({
519
+ type: "rename",
520
+ authorId: "SHA256:x",
521
+ text: "Alex",
522
+ });
523
+ });
524
+
525
+ test("openRename on your own note does nothing but explain", () => {
526
+ // Arrange
527
+ const session = sessionWith([annotation("a1")]);
528
+ const deps = makeDeps({ session, focusedAnnotationId: "a1" });
529
+
530
+ // Act
531
+ createIntentDispatch(deps)({ type: "openRename" });
532
+
533
+ // Assert
534
+ expect(deps.controller.setStatus).toHaveBeenCalled();
535
+ expect(deps.setMode).not.toHaveBeenCalled();
536
+ });
537
+
538
+ test("confirmDialog in rename mode persists the trimmed name and closes", () => {
539
+ // Arrange
540
+ const deps = makeDeps({ mode: { type: "rename", authorId: "SHA256:x", text: " Alex " } });
541
+
542
+ // Act
543
+ createIntentDispatch(deps)({ type: "confirmDialog" });
544
+
545
+ // Assert
546
+ expect(deps.renameAuthor).toHaveBeenCalledWith("SHA256:x", "Alex");
547
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
548
+ });
549
+ });
550
+
551
+ describe("collaborator self-name", () => {
552
+ test("confirmDialog in nameSelf mode records the trimmed name and closes", () => {
553
+ // Arrange
554
+ const deps = makeDeps({ mode: { type: "nameSelf", text: " Robin " } });
555
+
556
+ // Act
557
+ createIntentDispatch(deps)({ type: "confirmDialog" });
558
+
559
+ // Assert
560
+ expect(deps.controller.setSelfName).toHaveBeenCalledWith("Robin");
561
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
562
+ });
563
+
564
+ test("an empty name still closes - the collaborator stays anonymous", () => {
565
+ // Arrange
566
+ const deps = makeDeps({ mode: { type: "nameSelf", text: " " } });
567
+
568
+ // Act
569
+ createIntentDispatch(deps)({ type: "confirmDialog" });
570
+
571
+ // Assert
572
+ expect(deps.controller.setSelfName).toHaveBeenCalledWith("");
573
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
574
+ });
575
+ });
576
+
577
+ describe("tree intents", () => {
578
+ const rows: TreeRow[] = [
579
+ {
580
+ entryId: "e1",
581
+ depth: 0,
582
+ glyph: "◉",
583
+ text: "revision 1",
584
+ onPath: true,
585
+ tips: [],
586
+ isCurrentTip: false,
587
+ },
588
+ {
589
+ entryId: "e2",
590
+ depth: 0,
591
+ glyph: "·",
592
+ text: "comment",
593
+ onPath: true,
594
+ tips: ["main"],
595
+ isCurrentTip: false,
596
+ },
597
+ {
598
+ entryId: "e3",
599
+ depth: 1,
600
+ glyph: "·",
601
+ text: "comment",
602
+ onPath: false,
603
+ tips: ["alt"],
604
+ isCurrentTip: true,
605
+ },
606
+ ];
607
+
608
+ function treeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDeps {
609
+ const deps = makeDeps({ railTab: "tree", ...overrides });
610
+
611
+ deps.controller.treeRows = mock(() => rows);
612
+
613
+ return deps;
614
+ }
615
+
616
+ test("toggleTree shows the Tree tab and hides it again", () => {
617
+ // Arrange
618
+ const shown = makeDeps({ railTab: "review" });
619
+ const hidden = makeDeps({ railTab: "tree" });
620
+
621
+ // Act
622
+ createIntentDispatch(shown)({ type: "toggleTree" });
623
+ createIntentDispatch(hidden)({ type: "toggleTree" });
624
+
625
+ // Assert
626
+ expect(shown.setRailTab).toHaveBeenCalledWith("tree");
627
+ expect(hidden.setRailTab).toHaveBeenCalledWith("review");
628
+ });
629
+
630
+ test("treeMove starts from the current tip and stays inside the rows", () => {
631
+ // Arrange
632
+ const fromTip = treeDeps();
633
+ const atTop = treeDeps({ selectedEntryId: "e1" });
634
+
635
+ // Act
636
+ createIntentDispatch(fromTip)({ type: "treeMove", direction: -1 });
637
+ createIntentDispatch(atTop)({ type: "treeMove", direction: -1 });
638
+
639
+ // Assert
640
+ expect(fromTip.setSelectedEntryId).toHaveBeenCalledWith("e2");
641
+ expect(atTop.setSelectedEntryId).toHaveBeenCalledWith("e1");
642
+ });
643
+
644
+ test("treeGo: the tip answers, a tip of another branch switches, an inner entry asks for a summary", () => {
645
+ // Arrange
646
+ const atTip = treeDeps({ selectedEntryId: "e3" });
647
+ const toTip = treeDeps({ selectedEntryId: "e2" });
648
+ const inner = treeDeps({ selectedEntryId: "e1" });
649
+
650
+ // Act
651
+ createIntentDispatch(atTip)({ type: "treeGo" });
652
+ createIntentDispatch(toTip)({ type: "treeGo" });
653
+ createIntentDispatch(inner)({ type: "treeGo" });
654
+
655
+ // Assert
656
+ expect(atTip.controller.setStatus).toHaveBeenCalledWith("already at the tip");
657
+ expect(toTip.controller.goToEntry).toHaveBeenCalledWith("e2");
658
+ expect(inner.setMode).toHaveBeenCalledWith({
659
+ type: "treePrompt",
660
+ ask: "navigate",
661
+ entryId: "e1",
662
+ text: "",
663
+ });
664
+ });
665
+
666
+ test("the prompts confirm into branch, label, and a move back with the typed summary", () => {
667
+ // Arrange
668
+ const branch = treeDeps({ mode: { type: "treePrompt", ask: "branch", text: "alt" } });
669
+ const label = treeDeps({ mode: { type: "treePrompt", ask: "label", text: "start" } });
670
+ const navigate = treeDeps({
671
+ mode: { type: "treePrompt", ask: "navigate", entryId: "e1", text: " too early " },
672
+ });
673
+
674
+ // Act
675
+ createIntentDispatch(branch)({ type: "confirmDialog" });
676
+ createIntentDispatch(label)({ type: "confirmDialog" });
677
+ createIntentDispatch(navigate)({ type: "confirmDialog" });
678
+
679
+ // Assert
680
+ expect(branch.controller.branch).toHaveBeenCalledWith("alt");
681
+ expect(label.controller.labelTip).toHaveBeenCalledWith("start");
682
+ expect(navigate.controller.goToEntry).toHaveBeenCalledWith("e1", "too early");
683
+ expect(navigate.setMode).toHaveBeenCalledWith({ type: "normal" });
684
+ });
685
+
686
+ test("treeFork and treeForkShare reach the controller", () => {
687
+ // Arrange
688
+ const deps = treeDeps();
689
+
690
+ // Act
691
+ createIntentDispatch(deps)({ type: "treeFork" });
692
+ createIntentDispatch(deps)({ type: "treeForkShare" });
693
+
694
+ // Assert
695
+ expect(deps.controller.fork).toHaveBeenCalledTimes(1);
696
+ expect(deps.controller.forkAndShare).toHaveBeenCalledTimes(1);
697
+ });
698
+ });