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

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 +1406 -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 +787 -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 +29 -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 +78 -0
  241. package/src/launch-layout.ts +62 -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
@@ -0,0 +1,724 @@
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 message
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
+ message: 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
+ refreshDiff: mock(() => Promise.resolve()),
86
+ open: mock(),
87
+ deleteSession: mock(),
88
+ renameSession: mock(),
89
+ setSelfName: mock(),
90
+ cut: mock(),
91
+ toggleRejectHunk: mock(),
92
+ toggleRejectChange: mock(),
93
+ rejectedRows: mock(() => new Set<number>()),
94
+ curationItems: mock(() => []),
95
+ restoreCuration: mock(),
96
+ edit: mock(),
97
+ saveEditedBody: mock(),
98
+ annotate: mock(() => undefined),
99
+ addComment: mock(() => undefined),
100
+ commentOnWorkbench: mock(() => Promise.resolve()),
101
+ commentOnWorkbenchDiff: mock(() => Promise.resolve()),
102
+ reply: mock(() => undefined),
103
+ annotatePrototype: mock(() => undefined),
104
+ updateAnnotation: mock(),
105
+ removeAnnotation: mock(),
106
+ setWorkingCopy: mock(),
107
+ walkStart: mock(),
108
+ walkForward: mock(),
109
+ walkBack: mock(),
110
+ walkLeave: mock(),
111
+ submit: mock(),
112
+ share: mock(),
113
+ unshare: mock(),
114
+ setShareAccess: mock(),
115
+ shareLinks: mock(() => []),
116
+ createShareLink: mock(),
117
+ updateShareLink: mock(),
118
+ deleteShareLink: mock(),
119
+ copyShareLink: mock(),
120
+ treeRows: mock(() => []),
121
+ goToEntry: mock(),
122
+ branch: mock(),
123
+ labelTip: mock(),
124
+ fork: mock(),
125
+ forkAndShare: mock(),
126
+ pullShared: mock(() => Promise.resolve()),
127
+ startShareSync: mock(() => () => {}),
128
+ finishReview: mock(),
129
+ dismissCompletion: mock(),
130
+ optInAutoClose: mock(),
131
+ };
132
+ }
133
+
134
+ /** A deps bag where every effect is a mock and every read has a plain default. */
135
+ function makeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDeps {
136
+ return {
137
+ controller: baseController(),
138
+ onExit: mock(),
139
+ isDiff: false,
140
+ display: [],
141
+ rows: [],
142
+ cursor: 0,
143
+ inbox: null,
144
+ inboxCursor: 0,
145
+ mode: { type: "normal" },
146
+ session: null,
147
+ defaultMessage: "approved",
148
+ focusedAnnotationId: undefined,
149
+ selectedCurationId: undefined,
150
+ railTab: "review",
151
+ selectedEntryId: undefined,
152
+ authorNames: {},
153
+ quickActions: [],
154
+ renameAuthor: mock(),
155
+ renameThread: mock(),
156
+ setLocalIdentityName: mock(),
157
+ liveInput: { current: "" },
158
+ setCursor: mock(),
159
+ setInboxCursor: mock(),
160
+ setMode: mock(),
161
+ setRailTab: mock(),
162
+ setSelectedEntryId: mock(),
163
+ setFocusedAnnotationId: mock(),
164
+ setSelectedCurationId: mock(),
165
+ setPulsedAnnotationId: mock(),
166
+ selectCardFromDocument: mock(),
167
+ openBodyEditor: mock(),
168
+ openCardEdit: mock(),
169
+ toggleDiffView: mock(),
170
+ openShareDialog: mock(),
171
+ ...overrides,
172
+ };
173
+ }
174
+
175
+ function isUpdater(action: SetStateAction<number>): action is (current: number) => number {
176
+ return typeof action === "function";
177
+ }
178
+
179
+ describe("move", () => {
180
+ test("down clamps at the last row and up clamps at the first", () => {
181
+ // Arrange
182
+ const setCursor = mock((_action: SetStateAction<number>) => {});
183
+ const deps = makeDeps({ display: [block("a"), block("b"), block("c")], setCursor });
184
+ const dispatch = createIntentDispatch(deps);
185
+
186
+ // Act
187
+ dispatch({ type: "move", to: "down" });
188
+
189
+ // Assert
190
+ const advance = setCursor.mock.calls[0]![0];
191
+
192
+ if (!isUpdater(advance)) throw new Error("expected setCursor to receive an updater");
193
+ expect(advance(2)).toBe(2); // already at the end, stays
194
+ expect(advance(0)).toBe(1);
195
+ });
196
+
197
+ test("bottom jumps to the last navigable row", () => {
198
+ // Arrange
199
+ const deps = makeDeps({ display: [block("a"), block("b"), block("c")] });
200
+ const dispatch = createIntentDispatch(deps);
201
+
202
+ // Act
203
+ dispatch({ type: "move", to: "bottom" });
204
+
205
+ // Assert
206
+ expect(deps.setCursor).toHaveBeenCalledWith(2);
207
+ });
208
+ });
209
+
210
+ describe("annotation navigation", () => {
211
+ test("nextAnnotation wraps from the last annotation back to the first", () => {
212
+ // Arrange
213
+ const deps = makeDeps({
214
+ session: sessionWith([annotation("a1"), annotation("a2"), annotation("a3")]),
215
+ focusedAnnotationId: "a3",
216
+ });
217
+ const dispatch = createIntentDispatch(deps);
218
+
219
+ // Act
220
+ dispatch({ type: "nextAnnotation" });
221
+
222
+ // Assert
223
+ expect(deps.selectCardFromDocument).toHaveBeenCalledWith("a1");
224
+ });
225
+
226
+ test("prevAnnotation wraps from the first annotation to the last", () => {
227
+ // Arrange
228
+ const deps = makeDeps({
229
+ session: sessionWith([annotation("a1"), annotation("a2"), annotation("a3")]),
230
+ focusedAnnotationId: "a1",
231
+ });
232
+ const dispatch = createIntentDispatch(deps);
233
+
234
+ // Act
235
+ dispatch({ type: "prevAnnotation" });
236
+
237
+ // Assert
238
+ expect(deps.selectCardFromDocument).toHaveBeenCalledWith("a3");
239
+ });
240
+
241
+ test("cycling skips annotations a revision already addressed", () => {
242
+ // Arrange: a2 is addressed, so next from a1 lands on a3
243
+ const session = sessionWith([
244
+ annotation("a1"),
245
+ annotation("a2", { resolution: { revision: 2, source: "agent" } }),
246
+ annotation("a3"),
247
+ ]);
248
+ const deps = makeDeps({ session, focusedAnnotationId: "a1" });
249
+ const dispatch = createIntentDispatch(deps);
250
+
251
+ // Act
252
+ dispatch({ type: "nextAnnotation" });
253
+
254
+ // Assert
255
+ expect(deps.selectCardFromDocument).toHaveBeenCalledWith("a3");
256
+ });
257
+ });
258
+
259
+ describe("cycleMessage", () => {
260
+ test("advances through the message list in the submit overlay", () => {
261
+ // Arrange
262
+ const deps = makeDeps({ mode: { type: "submit", message: "approved", summary: "" } });
263
+ const dispatch = createIntentDispatch(deps);
264
+
265
+ // Act
266
+ dispatch({ type: "cycleMessage", direction: 1 });
267
+
268
+ // Assert
269
+ expect(deps.setMode).toHaveBeenCalledWith({
270
+ type: "submit",
271
+ message: "changes_requested",
272
+ summary: "",
273
+ });
274
+ });
275
+ });
276
+
277
+ describe("marker-actions popover", () => {
278
+ const span = { displayIndex: 3, wordIndex: 0, wordEnd: 0, start: 2, end: 9 };
279
+
280
+ test("spanCut cuts only the marked character range and closes the popover", () => {
281
+ // Arrange
282
+ const deps = makeDeps({ mode: { type: "span", span } });
283
+ const dispatch = createIntentDispatch(deps);
284
+
285
+ // Act
286
+ dispatch({ type: "spanCut" });
287
+
288
+ // Assert
289
+ expect(deps.controller.cut).toHaveBeenCalledWith(3, 2, 9);
290
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
291
+ });
292
+
293
+ test("openSpanActions enters the list at index 0", () => {
294
+ // Arrange
295
+ const deps = makeDeps({ mode: { type: "span", span } });
296
+ const dispatch = createIntentDispatch(deps);
297
+
298
+ // Act
299
+ dispatch({ type: "openSpanActions" });
300
+
301
+ // Assert
302
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "spanActions", span, index: 0 });
303
+ });
304
+
305
+ test("moveSpanAction clamps within the actions list", () => {
306
+ // Arrange
307
+ const deps = makeDeps({
308
+ mode: { type: "spanActions", span, index: 1 },
309
+ quickActions: [{ prompt: "one" }, { prompt: "two" }],
310
+ });
311
+ const dispatch = createIntentDispatch(deps);
312
+
313
+ // Act
314
+ dispatch({ type: "moveSpanAction", direction: 1 });
315
+
316
+ // Assert - already at the last row, so the index holds
317
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "spanActions", span, index: 1 });
318
+ });
319
+
320
+ test("pickSpanAction inserts the preset comment and returns to normal", () => {
321
+ // Arrange
322
+ const annotate = mock(() => "an_new");
323
+ const deps = makeDeps({
324
+ mode: { type: "spanActions", span, index: 1 },
325
+ session: sessionWith([]),
326
+ quickActions: [{ prompt: "Needs a test" }, { prompt: "YAGNI", metadata: "cut scope" }],
327
+ controller: { ...baseController(), annotate },
328
+ });
329
+ const dispatch = createIntentDispatch(deps);
330
+
331
+ // Act
332
+ dispatch({ type: "pickSpanAction" });
333
+
334
+ // Assert - metadata joins the prompt with a blank line
335
+ expect(annotate).toHaveBeenCalledWith("comment", 3, 2, 9, "YAGNI\n\ncut scope");
336
+ expect(deps.setFocusedAnnotationId).toHaveBeenCalledWith("an_new");
337
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
338
+ });
339
+
340
+ test("pickSpanAction honors an explicit index (mouse click)", () => {
341
+ // Arrange
342
+ const annotate = mock(() => "an_new");
343
+ const deps = makeDeps({
344
+ mode: { type: "spanActions", span, index: 0 },
345
+ session: sessionWith([]),
346
+ quickActions: [{ prompt: "Needs a test" }, { prompt: "Extract the duplication" }],
347
+ controller: { ...baseController(), annotate },
348
+ });
349
+ const dispatch = createIntentDispatch(deps);
350
+
351
+ // Act
352
+ dispatch({ type: "pickSpanAction", index: 1 });
353
+
354
+ // Assert
355
+ expect(annotate).toHaveBeenCalledWith("comment", 3, 2, 9, "Extract the duplication");
356
+ });
357
+
358
+ test("closeSpanActions returns to the span toolbar", () => {
359
+ // Arrange
360
+ const deps = makeDeps({ mode: { type: "spanActions", span, index: 2 } });
361
+ const dispatch = createIntentDispatch(deps);
362
+
363
+ // Act
364
+ dispatch({ type: "closeSpanActions" });
365
+
366
+ // Assert
367
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "span", span });
368
+ });
369
+ });
370
+
371
+ describe("openSubmit", () => {
372
+ test("opens the submit confirm with the default message", () => {
373
+ // Arrange
374
+ const deps = makeDeps({ session: sessionWith([]) });
375
+ const dispatch = createIntentDispatch(deps);
376
+
377
+ // Act
378
+ dispatch({ type: "openSubmit" });
379
+
380
+ // Assert
381
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "submit", message: "approved", summary: "" });
382
+ });
383
+
384
+ test("does nothing without a session", () => {
385
+ // Arrange
386
+ const deps = makeDeps({ session: null });
387
+ const dispatch = createIntentDispatch(deps);
388
+
389
+ // Act
390
+ dispatch({ type: "openSubmit" });
391
+
392
+ // Assert
393
+ expect(deps.setMode).not.toHaveBeenCalled();
394
+ });
395
+ });
396
+
397
+ describe("share", () => {
398
+ test("opens the share dialog instead of publishing directly", () => {
399
+ // Arrange
400
+ const deps = makeDeps();
401
+ const dispatch = createIntentDispatch(deps);
402
+
403
+ // Act
404
+ dispatch({ type: "share" });
405
+
406
+ // Assert
407
+ expect(deps.openShareDialog).toHaveBeenCalled();
408
+ expect(deps.controller.share).not.toHaveBeenCalled();
409
+ });
410
+ });
411
+
412
+ describe("restoreCuration", () => {
413
+ const items: CurationItem[] = [
414
+ { id: "diff:f#0#hunk", source: "diff", preview: [], revealIndex: 1 },
415
+ { id: "diff:f#1#2", source: "diff", preview: [], revealIndex: 9 },
416
+ ];
417
+
418
+ test("restores the selected item and clears the selection", () => {
419
+ // Arrange
420
+ const controller = { ...baseController(), curationItems: mock(() => items) };
421
+ const deps = makeDeps({ selectedCurationId: "diff:f#1#2", 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
+ expect(deps.setSelectedCurationId).toHaveBeenCalledWith(undefined);
430
+ });
431
+
432
+ test("with nothing selected, undoes the last removal", () => {
433
+ // Arrange
434
+ const controller = { ...baseController(), curationItems: mock(() => items) };
435
+ const deps = makeDeps({ selectedCurationId: undefined, controller });
436
+ const dispatch = createIntentDispatch(deps);
437
+
438
+ // Act
439
+ dispatch({ type: "restoreCuration" });
440
+
441
+ // Assert
442
+ expect(deps.controller.restoreCuration).toHaveBeenCalledWith("diff:f#1#2");
443
+ });
444
+
445
+ test("does nothing when there is nothing curated out", () => {
446
+ // Arrange
447
+ const deps = makeDeps();
448
+ const dispatch = createIntentDispatch(deps);
449
+
450
+ // Act
451
+ dispatch({ type: "restoreCuration" });
452
+
453
+ // Assert
454
+ expect(deps.controller.restoreCuration).not.toHaveBeenCalled();
455
+ });
456
+ });
457
+
458
+ describe("toggleDiffView", () => {
459
+ test("delegates to the App-owned toggle", () => {
460
+ // Arrange
461
+ const deps = makeDeps();
462
+ const dispatch = createIntentDispatch(deps);
463
+
464
+ // Act
465
+ dispatch({ type: "toggleDiffView" });
466
+
467
+ // Assert
468
+ expect(deps.toggleDiffView).toHaveBeenCalled();
469
+ });
470
+ });
471
+
472
+ describe("exit", () => {
473
+ test("calls the exit hook with 0", () => {
474
+ // Arrange
475
+ const deps = makeDeps();
476
+ const dispatch = createIntentDispatch(deps);
477
+
478
+ // Act
479
+ dispatch({ type: "exit" });
480
+
481
+ // Assert
482
+ expect(deps.onExit).toHaveBeenCalledWith(0);
483
+ });
484
+ });
485
+
486
+ describe("inbox delete", () => {
487
+ test("requestDeleteSession opens the confirm on the cursor row", () => {
488
+ // Arrange
489
+ const inbox = [
490
+ sessionWith([], { artifact: { type: "plan", content: "", meta: { title: "Plan A" } } }),
491
+ ];
492
+ const deps = makeDeps({ inbox, inboxCursor: 0 });
493
+
494
+ // Act
495
+ createIntentDispatch(deps)({ type: "requestDeleteSession" });
496
+
497
+ // Assert
498
+ expect(deps.setMode).toHaveBeenCalledWith({
499
+ type: "confirmDelete",
500
+ sessionId: "ses_1",
501
+ title: "Plan A",
502
+ });
503
+ });
504
+
505
+ test("confirmDialog deletes the session and closes", () => {
506
+ // Arrange
507
+ const deps = makeDeps({ mode: { type: "confirmDelete", sessionId: "ses_1", title: "Plan A" } });
508
+
509
+ // Act
510
+ createIntentDispatch(deps)({ type: "confirmDialog" });
511
+
512
+ // Assert
513
+ expect(deps.controller.deleteSession).toHaveBeenCalledWith("ses_1");
514
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
515
+ });
516
+ });
517
+
518
+ describe("rename author", () => {
519
+ test("openRename seeds the prompt with the author's current local name", () => {
520
+ // Arrange
521
+ const session = sessionWith([annotation("a1", { author: "SHA256:x" })]);
522
+ const deps = makeDeps({
523
+ session,
524
+ focusedAnnotationId: "a1",
525
+ authorNames: { "SHA256:x": "Alex" },
526
+ });
527
+
528
+ // Act
529
+ createIntentDispatch(deps)({ type: "openRename" });
530
+
531
+ // Assert
532
+ expect(deps.setMode).toHaveBeenCalledWith({
533
+ type: "rename",
534
+ authorId: "SHA256:x",
535
+ text: "Alex",
536
+ });
537
+ });
538
+
539
+ test("openRename on your own note does nothing but explain", () => {
540
+ // Arrange
541
+ const session = sessionWith([annotation("a1")]);
542
+ const deps = makeDeps({ session, focusedAnnotationId: "a1" });
543
+
544
+ // Act
545
+ createIntentDispatch(deps)({ type: "openRename" });
546
+
547
+ // Assert
548
+ expect(deps.controller.setStatus).toHaveBeenCalled();
549
+ expect(deps.setMode).not.toHaveBeenCalled();
550
+ });
551
+
552
+ test("confirmDialog in rename mode persists the trimmed name and closes", () => {
553
+ // Arrange
554
+ const deps = makeDeps({ mode: { type: "rename", authorId: "SHA256:x", text: " Alex " } });
555
+
556
+ // Act
557
+ createIntentDispatch(deps)({ type: "confirmDialog" });
558
+
559
+ // Assert
560
+ expect(deps.renameAuthor).toHaveBeenCalledWith("SHA256:x", "Alex");
561
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
562
+ });
563
+
564
+ test("confirmDialog in renameSelf mode sets the trimmed local identity name and closes", () => {
565
+ // Arrange
566
+ const deps = makeDeps({ mode: { type: "renameSelf", text: " Robin " } });
567
+
568
+ // Act
569
+ createIntentDispatch(deps)({ type: "confirmDialog" });
570
+
571
+ // Assert
572
+ expect(deps.setLocalIdentityName).toHaveBeenCalledWith("Robin");
573
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
574
+ });
575
+ });
576
+
577
+ describe("collaborator self-name", () => {
578
+ test("confirmDialog in nameSelf mode records the trimmed name and closes", () => {
579
+ // Arrange
580
+ const deps = makeDeps({ mode: { type: "nameSelf", text: " Robin " } });
581
+
582
+ // Act
583
+ createIntentDispatch(deps)({ type: "confirmDialog" });
584
+
585
+ // Assert
586
+ expect(deps.controller.setSelfName).toHaveBeenCalledWith("Robin");
587
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
588
+ });
589
+
590
+ test("an empty name still closes - the collaborator stays anonymous", () => {
591
+ // Arrange
592
+ const deps = makeDeps({ mode: { type: "nameSelf", text: " " } });
593
+
594
+ // Act
595
+ createIntentDispatch(deps)({ type: "confirmDialog" });
596
+
597
+ // Assert
598
+ expect(deps.controller.setSelfName).toHaveBeenCalledWith("");
599
+ expect(deps.setMode).toHaveBeenCalledWith({ type: "normal" });
600
+ });
601
+ });
602
+
603
+ describe("tree intents", () => {
604
+ const rows: TreeRow[] = [
605
+ {
606
+ entryId: "e1",
607
+ depth: 0,
608
+ glyph: "◉",
609
+ text: "revision 1",
610
+ onPath: true,
611
+ tips: [],
612
+ isCurrentTip: false,
613
+ },
614
+ {
615
+ entryId: "e2",
616
+ depth: 0,
617
+ glyph: "·",
618
+ text: "comment",
619
+ onPath: true,
620
+ tips: ["main"],
621
+ isCurrentTip: false,
622
+ },
623
+ {
624
+ entryId: "e3",
625
+ depth: 1,
626
+ glyph: "·",
627
+ text: "comment",
628
+ onPath: false,
629
+ tips: ["alt"],
630
+ isCurrentTip: true,
631
+ },
632
+ ];
633
+
634
+ function treeDeps(overrides: Partial<IntentDispatchDeps> = {}): IntentDispatchDeps {
635
+ const deps = makeDeps({ railTab: "tree", ...overrides });
636
+
637
+ deps.controller.treeRows = mock(() => rows);
638
+
639
+ return deps;
640
+ }
641
+
642
+ test("toggleTree shows the Tree tab and hides it again", () => {
643
+ // Arrange
644
+ const shown = makeDeps({ railTab: "review" });
645
+ const hidden = makeDeps({ railTab: "tree" });
646
+
647
+ // Act
648
+ createIntentDispatch(shown)({ type: "toggleTree" });
649
+ createIntentDispatch(hidden)({ type: "toggleTree" });
650
+
651
+ // Assert
652
+ expect(shown.setRailTab).toHaveBeenCalledWith("tree");
653
+ expect(hidden.setRailTab).toHaveBeenCalledWith("review");
654
+ });
655
+
656
+ test("treeMove starts from the current tip and stays inside the rows", () => {
657
+ // Arrange
658
+ const fromTip = treeDeps();
659
+ const atTop = treeDeps({ selectedEntryId: "e1" });
660
+
661
+ // Act
662
+ createIntentDispatch(fromTip)({ type: "treeMove", direction: -1 });
663
+ createIntentDispatch(atTop)({ type: "treeMove", direction: -1 });
664
+
665
+ // Assert
666
+ expect(fromTip.setSelectedEntryId).toHaveBeenCalledWith("e2");
667
+ expect(atTop.setSelectedEntryId).toHaveBeenCalledWith("e1");
668
+ });
669
+
670
+ test("treeGo: the tip answers, a tip of another branch switches, an inner entry asks for a summary", () => {
671
+ // Arrange
672
+ const atTip = treeDeps({ selectedEntryId: "e3" });
673
+ const toTip = treeDeps({ selectedEntryId: "e2" });
674
+ const inner = treeDeps({ selectedEntryId: "e1" });
675
+
676
+ // Act
677
+ createIntentDispatch(atTip)({ type: "treeGo" });
678
+ createIntentDispatch(toTip)({ type: "treeGo" });
679
+ createIntentDispatch(inner)({ type: "treeGo" });
680
+
681
+ // Assert
682
+ expect(atTip.controller.setStatus).toHaveBeenCalledWith("already at the tip");
683
+ expect(toTip.controller.goToEntry).toHaveBeenCalledWith("e2");
684
+ expect(inner.setMode).toHaveBeenCalledWith({
685
+ type: "treePrompt",
686
+ ask: "navigate",
687
+ entryId: "e1",
688
+ text: "",
689
+ });
690
+ });
691
+
692
+ test("the prompts confirm into branch, label, and a move back with the typed summary", () => {
693
+ // Arrange
694
+ const branch = treeDeps({ mode: { type: "treePrompt", ask: "branch", text: "alt" } });
695
+ const label = treeDeps({ mode: { type: "treePrompt", ask: "label", text: "start" } });
696
+ const navigate = treeDeps({
697
+ mode: { type: "treePrompt", ask: "navigate", entryId: "e1", text: " too early " },
698
+ });
699
+
700
+ // Act
701
+ createIntentDispatch(branch)({ type: "confirmDialog" });
702
+ createIntentDispatch(label)({ type: "confirmDialog" });
703
+ createIntentDispatch(navigate)({ type: "confirmDialog" });
704
+
705
+ // Assert
706
+ expect(branch.controller.branch).toHaveBeenCalledWith("alt");
707
+ expect(label.controller.labelTip).toHaveBeenCalledWith("start");
708
+ expect(navigate.controller.goToEntry).toHaveBeenCalledWith("e1", "too early");
709
+ expect(navigate.setMode).toHaveBeenCalledWith({ type: "normal" });
710
+ });
711
+
712
+ test("treeFork and treeForkShare reach the controller", () => {
713
+ // Arrange
714
+ const deps = treeDeps();
715
+
716
+ // Act
717
+ createIntentDispatch(deps)({ type: "treeFork" });
718
+ createIntentDispatch(deps)({ type: "treeForkShare" });
719
+
720
+ // Assert
721
+ expect(deps.controller.fork).toHaveBeenCalledTimes(1);
722
+ expect(deps.controller.forkAndShare).toHaveBeenCalledTimes(1);
723
+ });
724
+ });