@cueloop/client 0.1.0-alpha.11 → 0.1.0-alpha.110

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 (360) 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/annotations/annotation-palette.ts +63 -0
  9. package/src/annotations/components/AnnotationCards.stories.tsx +86 -0
  10. package/src/annotations/components/AnnotationCards.test.tsx +42 -0
  11. package/src/annotations/components/AnnotationCards.tsx +383 -0
  12. package/src/annotations/components/DiscussionMarkerRail.stories.tsx +54 -0
  13. package/src/annotations/components/DiscussionMarkerRail.tsx +189 -0
  14. package/src/annotations/discussions.ts +78 -0
  15. package/src/annotations/thread-selection.test.ts +210 -0
  16. package/src/annotations/thread-selection.ts +248 -0
  17. package/src/annotations/use-annotation-surface.tsx +1378 -0
  18. package/src/app/App.appearance.test.tsx +53 -0
  19. package/src/{App.autoclose.test.tsx → app/App.autoclose.test.tsx} +77 -51
  20. package/src/app/App.deleted-scroll.test.tsx +190 -0
  21. package/src/app/App.diff.test.tsx +313 -0
  22. package/src/app/App.export.test.tsx +105 -0
  23. package/src/app/App.focus.test.ts +89 -0
  24. package/src/app/App.harness-settings.test.tsx +77 -0
  25. package/src/app/App.identity.test.tsx +84 -0
  26. package/src/app/App.plan-render.test.tsx +211 -0
  27. package/src/app/App.plan-review.test.tsx +429 -0
  28. package/src/app/App.prototype-markdown.test.tsx +129 -0
  29. package/src/app/App.prototype.test.tsx +176 -0
  30. package/src/app/App.readonly.test.tsx +274 -0
  31. package/src/app/App.regression.test.tsx +99 -0
  32. package/src/app/App.rendering.test.tsx +134 -0
  33. package/src/app/App.share-dialog.test.tsx +177 -0
  34. package/src/app/App.submit-confirm.test.tsx +267 -0
  35. package/src/app/App.test.tsx +497 -0
  36. package/src/app/App.thread-agent.test.ts +20 -0
  37. package/src/app/App.toast.test.tsx +124 -0
  38. package/src/app/App.tsx +1758 -0
  39. package/src/app/App.visual.test.tsx +60 -0
  40. package/src/app/App.walk.test.tsx +300 -0
  41. package/src/app/App.workbench.test.tsx +1152 -0
  42. package/src/app/app-key-state.ts +61 -0
  43. package/src/app/app-screens.tsx +607 -0
  44. package/src/app/app-view-model.ts +178 -0
  45. package/src/app/components/AppHeader.stories.tsx +50 -0
  46. package/src/app/components/AppHeader.tsx +81 -0
  47. package/src/app/components/AppShell.brand.test.tsx +58 -0
  48. package/src/app/components/AppShell.extensions.test.tsx +191 -0
  49. package/src/app/components/AppShell.stories.tsx +89 -0
  50. package/src/app/components/AppShell.tsx +358 -0
  51. package/src/app/components/AppShell.zoom.test.tsx +149 -0
  52. package/src/app/components/Breadcrumb.stories.tsx +19 -0
  53. package/src/app/components/Breadcrumb.tsx +45 -0
  54. package/src/app/components/MenuBar.stories.tsx +28 -0
  55. package/src/app/components/MenuBar.tsx +44 -0
  56. package/src/app/components/PaneGrid.stories.tsx +84 -0
  57. package/src/app/components/PaneGrid.tsx +128 -0
  58. package/src/app/components/PanelColumn.stories.tsx +25 -0
  59. package/src/app/components/PanelColumn.tsx +93 -0
  60. package/src/app/components/ShellHeader.stories.tsx +50 -0
  61. package/src/app/components/ShellHeader.tsx +137 -0
  62. package/src/app/components/WorkspacePanels.stories.tsx +36 -0
  63. package/src/app/components/WorkspacePanels.tsx +121 -0
  64. package/src/app/components/pages/Workspace.stories.tsx +181 -0
  65. package/src/app/intent-dispatch.test.ts +729 -0
  66. package/src/app/intent-dispatch.ts +713 -0
  67. package/src/app/launch-layout.test.ts +78 -0
  68. package/src/app/launch-layout.ts +57 -0
  69. package/src/app/ready-signal.test.ts +85 -0
  70. package/src/app/ready-signal.ts +54 -0
  71. package/src/app/run.ts +111 -0
  72. package/src/{serve.test.ts → app/serve.test.ts} +28 -12
  73. package/src/app/serve.ts +111 -0
  74. package/src/app/version.ts +5 -0
  75. package/src/appearance/components/theme-context.stories.tsx +46 -0
  76. package/src/appearance/components/theme-context.tsx +37 -0
  77. package/src/appearance/theme-presets.test.ts +51 -0
  78. package/src/appearance/theme-presets.ts +219 -0
  79. package/src/appearance/theme.ts +112 -0
  80. package/src/appearance/visual/contrast.test.ts +35 -0
  81. package/src/appearance/visual/contrast.ts +33 -0
  82. package/src/diff/components/AnnotatableFileView.stories.tsx +42 -0
  83. package/src/diff/components/AnnotatableFileView.test.tsx +160 -0
  84. package/src/diff/components/AnnotatableFileView.tsx +142 -0
  85. package/src/diff/components/BareWorkbenchFileView.stories.tsx +27 -0
  86. package/src/diff/components/BareWorkbenchFileView.tsx +63 -0
  87. package/src/diff/components/DiffContentView.scroll.test.tsx +443 -0
  88. package/src/diff/components/DiffContentView.split.test.tsx +140 -0
  89. package/src/diff/components/DiffContentView.stories.tsx +80 -0
  90. package/src/diff/components/DiffContentView.syntax.test.tsx +85 -0
  91. package/src/diff/components/DiffContentView.tsx +1080 -0
  92. package/src/diff/components/WalkWizard.stories.tsx +100 -0
  93. package/src/diff/components/WalkWizard.tsx +146 -0
  94. package/src/diff/components/diff-content-view-layout.test.ts +198 -0
  95. package/src/diff/components/diff-content-view-layout.ts +87 -0
  96. package/src/diff/diff-fold.test.ts +78 -0
  97. package/src/diff/diff-fold.ts +130 -0
  98. package/src/diff/diff-hunk-curate.test.ts +315 -0
  99. package/src/diff/diff-hunk-curate.ts +155 -0
  100. package/src/diff/diff-intraline.test.ts +239 -0
  101. package/src/diff/diff-intraline.ts +244 -0
  102. package/src/diff/diff-syntax.test.ts +125 -0
  103. package/src/diff/diff-syntax.ts +194 -0
  104. package/src/diff/split-diff.test.ts +110 -0
  105. package/src/diff/split-diff.ts +150 -0
  106. package/src/diff/view-diff.test.ts +325 -0
  107. package/src/diff/view-diff.ts +540 -0
  108. package/src/diff/walk.test.ts +121 -0
  109. package/src/diff/walk.ts +76 -0
  110. package/src/index.ts +32 -2
  111. package/src/integrations/attribution.test.ts +31 -0
  112. package/src/integrations/attribution.ts +44 -0
  113. package/src/integrations/client-extension-registry.test.tsx +61 -0
  114. package/src/integrations/client-extension-registry.ts +163 -0
  115. package/src/integrations/clipboard.test.ts +23 -0
  116. package/src/integrations/clipboard.ts +34 -0
  117. package/src/integrations/components/ExtensionZone.stories.tsx +28 -0
  118. package/src/integrations/components/ExtensionZone.tsx +145 -0
  119. package/src/integrations/components/ShareDialog.agent.test.tsx +52 -0
  120. package/src/integrations/components/ShareDialog.stories.tsx +121 -0
  121. package/src/integrations/components/ShareDialog.tsx +602 -0
  122. package/src/integrations/components/share-dialog-store.ts +59 -0
  123. package/src/integrations/editor.test.ts +149 -0
  124. package/src/integrations/editor.ts +163 -0
  125. package/src/integrations/github-identity.test.ts +59 -0
  126. package/src/integrations/github-identity.ts +42 -0
  127. package/src/integrations/herdr.test.ts +43 -0
  128. package/src/integrations/herdr.ts +40 -0
  129. package/src/integrations/integrations.test.ts +41 -0
  130. package/src/integrations/integrations.ts +32 -0
  131. package/src/integrations/pasted-image.test.ts +67 -0
  132. package/src/integrations/pasted-image.ts +62 -0
  133. package/src/integrations/share.ts +283 -0
  134. package/src/keyboard/components/CompletionOverlay.stories.tsx +37 -0
  135. package/src/keyboard/components/CompletionOverlay.tsx +75 -0
  136. package/src/keyboard/components/HandleChipsInput.stories.tsx +24 -0
  137. package/src/keyboard/components/HandleChipsInput.test.tsx +35 -0
  138. package/src/keyboard/components/HandleChipsInput.tsx +147 -0
  139. package/src/keyboard/components/KeybindsDialog.stories.tsx +31 -0
  140. package/src/keyboard/components/KeybindsDialog.tsx +103 -0
  141. package/src/keyboard/components/NavModeHint.stories.tsx +40 -0
  142. package/src/keyboard/components/NavModeHint.tsx +31 -0
  143. package/src/keyboard/components/SlashComposer.stories.tsx +37 -0
  144. package/src/keyboard/components/SlashComposer.tsx +90 -0
  145. package/src/keyboard/key-bindings.test.ts +156 -0
  146. package/src/keyboard/key-bindings.ts +462 -0
  147. package/src/keyboard/keymap.test.ts +611 -0
  148. package/src/keyboard/keymap.ts +446 -0
  149. package/src/keyboard/skills.test.ts +74 -0
  150. package/src/keyboard/skills.ts +77 -0
  151. package/src/keyboard/slash-palette.test.ts +103 -0
  152. package/src/keyboard/slash-palette.ts +137 -0
  153. package/src/keyboard/thread-chords.test.ts +79 -0
  154. package/src/keyboard/thread-chords.ts +168 -0
  155. package/src/keyboard/use-shared-keyboard.ts +56 -0
  156. package/src/markdown/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  157. package/src/markdown/components/MarkdownGridBlock.stories.tsx +33 -0
  158. package/src/markdown/components/MarkdownGridBlock.tsx +72 -0
  159. package/src/markdown/components/MarkdownThreadEditor.stories.tsx +15 -0
  160. package/src/markdown/components/MarkdownThreadEditor.test.tsx +98 -0
  161. package/src/markdown/components/MarkdownThreadEditor.tsx +165 -0
  162. package/src/markdown/components/ThreadView.selection.test.tsx +954 -0
  163. package/src/markdown/components/ThreadView.slash.test.tsx +146 -0
  164. package/src/markdown/components/ThreadView.stories.tsx +26 -0
  165. package/src/markdown/components/ThreadView.tsx +577 -0
  166. package/src/markdown/mark-runs.ts +125 -0
  167. package/src/markdown/markdown-editor-syntax-style.ts +66 -0
  168. package/src/markdown/markdown-grid.test.ts +101 -0
  169. package/src/markdown/markdown-grid.ts +209 -0
  170. package/src/markdown/markdown-highlight.test.ts +61 -0
  171. package/src/markdown/markdown-highlight.ts +125 -0
  172. package/src/markdown/thread-code-syntax.ts +48 -0
  173. package/src/markdown/use-thread-body-editing.tsx +54 -0
  174. package/src/markdown/view-plan.regression.test.ts +60 -0
  175. package/src/markdown/view-plan.test.ts +545 -0
  176. package/src/markdown/view-plan.ts +1061 -0
  177. package/src/performance/otlp-trace.test.ts +119 -0
  178. package/src/performance/otlp-trace.ts +134 -0
  179. package/src/performance/perf-report.ts +19 -0
  180. package/src/performance/perf-timings.test.ts +18 -0
  181. package/src/performance/perf-timings.ts +40 -0
  182. package/src/prototype/components/PrototypeContentView.stories.tsx +19 -0
  183. package/src/prototype/components/PrototypeContentView.tsx +459 -0
  184. package/src/prototype/prototype-browser.test.ts +81 -0
  185. package/src/prototype/prototype-browser.ts +332 -0
  186. package/src/prototype/prototype-pixels.tsx +35 -0
  187. package/src/settings/components/SettingsDialog.stories.tsx +117 -0
  188. package/src/settings/components/SettingsDialog.tsx +186 -0
  189. package/src/settings/components/SettingsRows.stories.tsx +18 -0
  190. package/src/settings/components/SettingsRows.tsx +112 -0
  191. package/src/settings/components/quick-actions-editor.stories.tsx +58 -0
  192. package/src/settings/components/quick-actions-editor.test.tsx +120 -0
  193. package/src/settings/components/quick-actions-editor.tsx +222 -0
  194. package/src/settings/config-identity.test.ts +78 -0
  195. package/src/settings/config.test.ts +754 -0
  196. package/src/settings/config.ts +718 -0
  197. package/src/settings/use-settings-dialog.test.tsx +172 -0
  198. package/src/settings/use-settings-dialog.tsx +465 -0
  199. package/src/settings/use-thread-harness-setting.test.tsx +87 -0
  200. package/src/settings/use-thread-harness-setting.ts +86 -0
  201. package/src/stories/__snapshots__/stories.test.tsx.snap +2358 -0
  202. package/src/stories/stories-app.tsx +196 -0
  203. package/src/stories/stories.test.tsx +78 -0
  204. package/src/stories/story-fixtures.ts +102 -0
  205. package/src/stories/story-tree.test.ts +108 -0
  206. package/src/stories/story.ts +160 -0
  207. package/src/terminal/ansi-screen.test.ts +28 -0
  208. package/src/terminal/ansi-screen.ts +77 -0
  209. package/src/terminal/components/AnsiScreen.stories.tsx +12 -0
  210. package/src/terminal/components/AnsiScreen.tsx +28 -0
  211. package/src/terminal/components/terminal-pane.test.tsx +40 -0
  212. package/src/terminal/components/terminal-pane.ts +208 -0
  213. package/src/terminal/ghostty-terminal.test.ts +85 -0
  214. package/src/terminal/ghostty-terminal.ts +207 -0
  215. package/src/terminal/kitty-image.test.ts +66 -0
  216. package/src/terminal/kitty-image.ts +140 -0
  217. package/src/terminal/pty.test.ts +81 -0
  218. package/src/terminal/pty.ts +255 -0
  219. package/src/testing/test-support.test.ts +266 -0
  220. package/src/testing/test-support.ts +424 -0
  221. package/src/thread/agent-continuation.test.ts +34 -0
  222. package/src/thread/agent-continuation.ts +13 -0
  223. package/src/thread/agent-transcript.test.ts +161 -0
  224. package/src/thread/agent-transcript.ts +344 -0
  225. package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
  226. package/src/thread/components/AgentConfigControls.test.tsx +153 -0
  227. package/src/thread/components/AgentConfigControls.tsx +121 -0
  228. package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
  229. package/src/thread/components/AgentThreadPane.test.tsx +1248 -0
  230. package/src/thread/components/AgentThreadPane.tsx +458 -0
  231. package/src/thread/components/ThreadFooter.stories.tsx +30 -0
  232. package/src/thread/components/ThreadFooter.test.tsx +49 -0
  233. package/src/thread/components/ThreadFooter.tsx +77 -0
  234. package/src/thread/components/ThreadTree.stories.tsx +46 -0
  235. package/src/thread/components/ThreadTree.test.tsx +41 -0
  236. package/src/thread/components/ThreadTree.tsx +268 -0
  237. package/src/thread/components/thread-tree.test.ts +138 -0
  238. package/src/thread/components/thread-tree.ts +112 -0
  239. package/src/thread/thread-controller.curate.test.ts +504 -0
  240. package/src/thread/thread-controller.derivation.test.ts +62 -0
  241. package/src/thread/thread-controller.live-diff.test.ts +357 -0
  242. package/src/thread/thread-controller.optimistic.test.ts +69 -0
  243. package/src/thread/thread-controller.share.test.ts +618 -0
  244. package/src/thread/thread-controller.switch-cache.test.ts +174 -0
  245. package/src/thread/thread-controller.tree.test.ts +263 -0
  246. package/src/thread/thread-controller.ts +2151 -0
  247. package/src/thread/tree-view.test.ts +128 -0
  248. package/src/thread/tree-view.ts +150 -0
  249. package/src/thread/use-thread-agent.test.tsx +209 -0
  250. package/src/thread/use-thread-agent.ts +122 -0
  251. package/src/ui/components/CodeBlock.stories.tsx +42 -0
  252. package/src/ui/components/CodeBlock.tsx +82 -0
  253. package/src/ui/components/ConfirmCard.stories.tsx +37 -0
  254. package/src/ui/components/ConfirmCard.tsx +151 -0
  255. package/src/ui/components/ConfirmDialog.stories.tsx +21 -0
  256. package/src/ui/components/ConfirmDialog.tsx +72 -0
  257. package/src/ui/components/OverlayScrollbar.stories.tsx +33 -0
  258. package/src/ui/components/OverlayScrollbar.test.tsx +142 -0
  259. package/src/ui/components/OverlayScrollbar.tsx +134 -0
  260. package/src/ui/components/PromptDialog.stories.tsx +36 -0
  261. package/src/ui/components/PromptDialog.test.tsx +65 -0
  262. package/src/ui/components/PromptDialog.tsx +101 -0
  263. package/src/ui/components/RootOverlay.stories.tsx +45 -0
  264. package/src/ui/components/RootOverlay.tsx +50 -0
  265. package/src/ui/components/ScrollArea.stories.tsx +20 -0
  266. package/src/ui/components/ScrollArea.tsx +63 -0
  267. package/src/ui/components/Toast.stories.tsx +19 -0
  268. package/src/ui/components/Toast.tsx +54 -0
  269. package/src/ui/components/Tooltip.stories.tsx +26 -0
  270. package/src/ui/components/Tooltip.tsx +93 -0
  271. package/src/ui/components/menu-control.stories.tsx +35 -0
  272. package/src/ui/components/menu-control.tsx +43 -0
  273. package/src/ui/components/primitives/Button.stories.tsx +32 -0
  274. package/src/ui/components/primitives/Button.tsx +58 -0
  275. package/src/ui/components/primitives/Card.stories.tsx +25 -0
  276. package/src/ui/components/primitives/Card.tsx +62 -0
  277. package/src/ui/components/primitives/Dialog.stories.tsx +24 -0
  278. package/src/ui/components/primitives/Dialog.test.tsx +38 -0
  279. package/src/ui/components/primitives/Dialog.tsx +69 -0
  280. package/src/ui/components/primitives/DialogActions.stories.tsx +20 -0
  281. package/src/ui/components/primitives/DialogActions.tsx +39 -0
  282. package/src/ui/components/primitives/IconButton.stories.tsx +36 -0
  283. package/src/ui/components/primitives/IconButton.tsx +58 -0
  284. package/src/ui/components/primitives/StatusBar.stories.tsx +11 -0
  285. package/src/ui/components/primitives/StatusBar.tsx +23 -0
  286. package/src/ui/components/primitives/Switch.stories.tsx +17 -0
  287. package/src/ui/components/primitives/Switch.tsx +32 -0
  288. package/src/ui/components/primitives/Tabs.stories.tsx +29 -0
  289. package/src/ui/components/primitives/Tabs.tsx +102 -0
  290. package/src/ui/components/primitives/Toolbar.stories.tsx +17 -0
  291. package/src/ui/components/primitives/Toolbar.tsx +14 -0
  292. package/src/ui/components/primitives/Tree.stories.tsx +124 -0
  293. package/src/ui/components/primitives/Tree.tsx +137 -0
  294. package/src/ui/components/primitives/frame.test.ts +12 -0
  295. package/src/ui/components/primitives/frame.ts +17 -0
  296. package/src/ui/components/primitives/icons.ts +61 -0
  297. package/src/ui/components/primitives/tree-model.test.ts +99 -0
  298. package/src/ui/components/primitives/tree-model.ts +144 -0
  299. package/src/ui/components/syntax-highlight.ts +154 -0
  300. package/src/ui/components/truncate-text.ts +7 -0
  301. package/src/ui/components/truncate-title.test.ts +31 -0
  302. package/src/ui/components/truncate-title.ts +16 -0
  303. package/src/ui/use-frame-measure.ts +88 -0
  304. package/src/ui/use-terminal-virtualizer.ts +286 -0
  305. package/src/workbench/components/ChangesColumn.stories.tsx +44 -0
  306. package/src/workbench/components/ChangesColumn.tsx +210 -0
  307. package/src/workbench/components/ChangesFileTree.test.tsx +184 -0
  308. package/src/workbench/components/EditorGrid.split.test.tsx +119 -0
  309. package/src/workbench/components/EditorGrid.stories.tsx +86 -0
  310. package/src/workbench/components/EditorGrid.tabs.test.tsx +118 -0
  311. package/src/workbench/components/EditorGrid.tsx +552 -0
  312. package/src/workbench/components/EditorGrid.zoom.test.tsx +132 -0
  313. package/src/workbench/components/FileContentsView.stories.tsx +16 -0
  314. package/src/workbench/components/FileContentsView.test.tsx +29 -0
  315. package/src/workbench/components/FileContentsView.tsx +104 -0
  316. package/src/workbench/components/GridTabContent.actions.test.tsx +151 -0
  317. package/src/workbench/components/GridTabContent.stories.tsx +59 -0
  318. package/src/workbench/components/GridTabContent.tsx +195 -0
  319. package/src/workbench/components/ProjectTreeView.stories.tsx +25 -0
  320. package/src/workbench/components/ProjectTreeView.test.tsx +239 -0
  321. package/src/workbench/components/ProjectTreeView.tsx +159 -0
  322. package/src/workbench/components/TreePane.stories.tsx +106 -0
  323. package/src/workbench/components/TreePane.tsx +157 -0
  324. package/src/workbench/components/WelcomePlayground.stories.tsx +12 -0
  325. package/src/workbench/components/WelcomePlayground.test.tsx +83 -0
  326. package/src/workbench/components/WelcomePlayground.tsx +138 -0
  327. package/src/workbench/components/editor-grid.test.ts +184 -0
  328. package/src/workbench/components/editor-grid.ts +284 -0
  329. package/src/workbench/components/file-tree.test.ts +60 -0
  330. package/src/workbench/components/file-tree.ts +114 -0
  331. package/src/workbench/use-changes-workbench.ts +210 -0
  332. package/src/workbench/use-remember-layout.ts +33 -0
  333. package/src/workbench/use-repo-changes.ts +93 -0
  334. package/src/workbench/workbench-snapshot.test.ts +99 -0
  335. package/src/workbench/workbench-snapshot.ts +59 -0
  336. package/src/App.diff.test.tsx +0 -99
  337. package/src/App.export.test.tsx +0 -88
  338. package/src/App.plan-review.test.tsx +0 -259
  339. package/src/App.readonly.test.tsx +0 -148
  340. package/src/App.rendering.test.tsx +0 -108
  341. package/src/App.submit-confirm.test.tsx +0 -183
  342. package/src/App.test.tsx +0 -218
  343. package/src/App.tsx +0 -1249
  344. package/src/config.test.ts +0 -116
  345. package/src/config.ts +0 -150
  346. package/src/editor.ts +0 -34
  347. package/src/herdr.test.ts +0 -68
  348. package/src/herdr.ts +0 -34
  349. package/src/keymap.test.ts +0 -270
  350. package/src/keymap.ts +0 -173
  351. package/src/run.ts +0 -30
  352. package/src/serve.ts +0 -75
  353. package/src/session-controller.ts +0 -398
  354. package/src/syntax.test.ts +0 -41
  355. package/src/syntax.ts +0 -88
  356. package/src/theme.ts +0 -46
  357. package/src/view-diff.test.ts +0 -55
  358. package/src/view-diff.ts +0 -86
  359. package/src/view.test.ts +0 -157
  360. package/src/view.ts +0 -364
@@ -0,0 +1,954 @@
1
+ /**
2
+ * The marking-feel harness: drives real mouse events through the virtual
3
+ * terminal and reads the PAINTED highlight back from styled spans, so it
4
+ * measures what the reviewer sees - which characters carry the mark, and how
5
+ * many frames / milliseconds after each drag step they appear. Character
6
+ * precision, backward drags, multi-line drags, and double-click word mode are
7
+ * all asserted on the frame, not on internal state.
8
+ */
9
+
10
+ import { afterEach, beforeEach, describe, expect, mock, spyOn, test, type Mock } from "bun:test";
11
+ import React from "react";
12
+ import { testRender } from "@opentui/react/test-utils";
13
+ import { TextAttributes } from "@opentui/core";
14
+ import type { TestRendererSetup } from "@opentui/core/testing";
15
+ import { DEFAULT_QUICK_ACTIONS } from "../../settings/config";
16
+ import { settle, typeText } from "../../testing/test-support";
17
+ import { buildDisplay, marksByDisplay } from "../view-plan";
18
+ import { makeAnchor, parseBlocks, type Annotation } from "@cueloop/schema";
19
+ import type { TextSpan } from "../../annotations/thread-selection";
20
+ import * as markRuns from "../mark-runs";
21
+ import { fixturePlanSession } from "../../stories/story-fixtures";
22
+ import { ThreadView } from "./ThreadView";
23
+
24
+ const PARAGRAPH =
25
+ "The daemon persists sessions to disk atomically through a temp file and a rename so that a crash never leaves a torn document behind.";
26
+ /** A paragraph with hard source line breaks: one terminal row per source line. */
27
+ const STANZA_ROWS = [
28
+ "Sessions are written as one JSON document per",
29
+ "session, through a temp file and an atomic rename,",
30
+ "so a crash can never leave a torn file on disk.",
31
+ ];
32
+ const STANZA = STANZA_ROWS.join("\n");
33
+ const BULLETS = ["store.ts - the ThreadStore class", "schema.ts - the on-disk record shape"];
34
+ const PLAN = `# Plan\n\n${PARAGRAPH}\n\n${STANZA}\n\n- ${BULLETS[0]}\n- ${BULLETS[1]}\n`;
35
+ /** Display block indices: title, paragraph, stanza, two bullets. */
36
+ const STANZA_BLOCK = 2;
37
+ const FIRST_BULLET_BLOCK = 3;
38
+ /** The mark backdrop the dark palette paints under selected text. */
39
+ const MARK_RGB = [70, 56, 82];
40
+ /** The idle caret cell of the dark palette. */
41
+ const CARET_RGB = [86, 91, 104];
42
+
43
+ type AnnotateHandler = (span: TextSpan, body: string) => void;
44
+ type ReplyHandler = (rootAnnotationId: string, body: string) => void;
45
+
46
+ let setup: TestRendererSetup;
47
+ let annotate: Mock<AnnotateHandler>;
48
+ let reply: Mock<ReplyHandler>;
49
+
50
+ function session(annotations: Annotation[] = []) {
51
+ return fixturePlanSession({
52
+ artifact: {
53
+ type: "plan",
54
+ content: PLAN,
55
+ meta: { title: "Plan", planPath: "plan.md" },
56
+ },
57
+ annotations,
58
+ });
59
+ }
60
+
61
+ async function mount(annotations: Annotation[] = [], height = 24): Promise<void> {
62
+ annotate = mock<AnnotateHandler>(() => {});
63
+ reply = mock<ReplyHandler>(() => {});
64
+ const display = buildDisplay(PLAN, undefined);
65
+
66
+ setup = await testRender(
67
+ <ThreadView
68
+ session={session(annotations)}
69
+ display={display}
70
+ marks={marksByDisplay(annotations, display)}
71
+ quickActions={DEFAULT_QUICK_ACTIONS}
72
+ observer={false}
73
+ onAnnotate={annotate}
74
+ onReply={reply}
75
+ onUpdateAnnotation={() => {}}
76
+ onExit={() => {}}
77
+ />,
78
+ { width: 64, height },
79
+ );
80
+ await settle(setup);
81
+ await settle(setup);
82
+ }
83
+
84
+ /** Rows with their concatenated mark-highlighted text (empty when none). */
85
+ function highlightedByRow(): Map<number, string> {
86
+ const rows = new Map<number, string>();
87
+ const frame = setup.captureSpans();
88
+
89
+ frame.lines.forEach((line, rowIndex) => {
90
+ let marked = "";
91
+
92
+ for (const span of line.spans) {
93
+ const [red, green, blue] = span.bg.toInts();
94
+ const backdrop = red === MARK_RGB[0] && green === MARK_RGB[1] && blue === MARK_RGB[2];
95
+ const caretCell = red === CARET_RGB[0] && green === CARET_RGB[1] && blue === CARET_RGB[2];
96
+ // the caret cell shows through a mark; the underline says it is still marked
97
+ const underlined = (span.attributes & TextAttributes.UNDERLINE) !== 0;
98
+
99
+ if (backdrop || (caretCell && underlined)) marked += span.text;
100
+ }
101
+
102
+ if (marked.length > 0) rows.set(rowIndex, marked);
103
+ });
104
+
105
+ return rows;
106
+ }
107
+
108
+ function highlightedText(): string {
109
+ return [...highlightedByRow().values()].join("");
110
+ }
111
+
112
+ /** Every painted caret cell as (row, column, character). */
113
+ function caretCells(): Array<{ row: number; column: number; text: string }> {
114
+ const cells: Array<{ row: number; column: number; text: string }> = [];
115
+
116
+ setup.captureSpans().lines.forEach((line, row) => {
117
+ let column = 0;
118
+
119
+ for (const span of line.spans) {
120
+ const [red, green, blue] = span.bg.toInts();
121
+
122
+ if (red === CARET_RGB[0] && green === CARET_RGB[1] && blue === CARET_RGB[2]) {
123
+ cells.push({ row, column, text: span.text });
124
+ }
125
+
126
+ column += span.width;
127
+ }
128
+ });
129
+
130
+ return cells;
131
+ }
132
+
133
+ /** The highlighted rows read as one string, the way the source reads across its line breaks. */
134
+ function highlightedAcrossRows(): string {
135
+ return [...highlightedByRow().values()].join(" ");
136
+ }
137
+
138
+ /** The stanza's char offset of a column on one of its rows. */
139
+ function stanzaOffset(rowIndex: number, column: number): number {
140
+ return STANZA_ROWS.slice(0, rowIndex).reduce((total, row) => total + row.length + 1, 0) + column;
141
+ }
142
+
143
+ /** Column of a word on a row, from the char frame. */
144
+ function locate(needle: string) {
145
+ const rows = setup.captureCharFrame().split("\n");
146
+ const row = rows.findIndex((line) => line.includes(needle));
147
+
148
+ expect(row, `"${needle}" not on screen`).toBeGreaterThanOrEqual(0);
149
+
150
+ return { row, column: rows[row]!.indexOf(needle) };
151
+ }
152
+
153
+ /**
154
+ * Render frames until the highlight equals `expected`, returning how many
155
+ * frames it took - the feel metric: 1 means the very next frame after the
156
+ * event already carries the mark.
157
+ */
158
+ async function framesUntilHighlight(expected: string, limit = 4): Promise<number> {
159
+ for (let frame = 1; frame <= limit; frame++) {
160
+ // one scheduler turn (the input parser and React commit), then one frame
161
+ await new Promise((resolve) => setTimeout(resolve, 0));
162
+ await setup.renderOnce();
163
+
164
+ if (highlightedText() === expected) return frame;
165
+ }
166
+
167
+ throw new Error(`highlight never became "${expected}"; last: "${highlightedText()}"`);
168
+ }
169
+
170
+ beforeEach(async () => {
171
+ await mount();
172
+ });
173
+ afterEach(() => {
174
+ setup.renderer.destroy();
175
+ });
176
+
177
+ describe("marking feel", () => {
178
+ test("a drag from mid-word paints a character-precise selection within one frame per step", async () => {
179
+ // Arrange: press inside "daemon" (on the "e"), not at a word boundary
180
+ const { row, column } = locate("daemon");
181
+ const pressColumn = column + 2;
182
+
183
+ await setup.mockMouse.pressDown(pressColumn, row);
184
+ await settle(setup);
185
+
186
+ // Act + Assert: each step to the right extends the mark by exactly one
187
+ // character, and the mark is painted by the next frame
188
+ const latencies: number[] = [];
189
+ const frames: number[] = [];
190
+
191
+ for (let step = 1; step <= 8; step++) {
192
+ const started = performance.now();
193
+
194
+ await setup.mockMouse.moveTo(pressColumn + step, row);
195
+ frames.push(await framesUntilHighlight(PARAGRAPH.slice(6, 6 + step)));
196
+ latencies.push(performance.now() - started);
197
+ }
198
+ await setup.mockMouse.release(pressColumn + 8, row);
199
+
200
+ expect(highlightedText()).toBe("emon per");
201
+ // the target is the very next frame; a scheduler starved by the rest of
202
+ // the suite may land the paint one frame later, which is still immediate
203
+ expect(Math.max(...frames)).toBeLessThanOrEqual(2);
204
+ expect(frames.filter((frame) => frame === 1).length).toBeGreaterThanOrEqual(frames.length / 2);
205
+ const sorted = latencies.toSorted((left, right) => left - right);
206
+
207
+ console.log(
208
+ `drag step latency p50 ${sorted[Math.floor(sorted.length / 2)]!.toFixed(
209
+ 2,
210
+ )}ms p95 ${sorted[sorted.length - 1]!.toFixed(2)}ms`,
211
+ );
212
+ });
213
+
214
+ test("a backward drag normalizes to the same characters", async () => {
215
+ // Arrange: press at the "s" of "persists", drag left into "daemon"
216
+ const { row, column } = locate("persists");
217
+
218
+ await setup.mockMouse.pressDown(column + 3, row);
219
+ await settle(setup);
220
+
221
+ // Act
222
+ await setup.mockMouse.moveTo(column - 4, row);
223
+ await settle(setup);
224
+ await setup.mockMouse.release(column - 4, row);
225
+
226
+ // Assert: "mon per" - partial words on both ends, ordered start->end
227
+ expect(highlightedText()).toBe("mon per");
228
+ });
229
+
230
+ test("dragging onto the next visual line selects across the wrap", async () => {
231
+ // Arrange: the paragraph wraps at 64 columns; press on line one
232
+ const first = locate("atomically");
233
+ const second = locate("crash");
234
+
235
+ expect(second.row).toBe(first.row + 1);
236
+ await setup.mockMouse.pressDown(first.column, first.row);
237
+ await settle(setup);
238
+
239
+ // Act: drag down onto the second line
240
+ await setup.mockMouse.moveTo(second.column + 5, second.row);
241
+ await settle(setup);
242
+
243
+ // Assert: both rows carry highlight, and together they read as the text
244
+ // between the two positions (minus the wrap's dropped space)
245
+ const rows = highlightedByRow();
246
+
247
+ expect(rows.size).toBe(2);
248
+ const joined = [...rows.values()].join(" ");
249
+ const start = PARAGRAPH.indexOf("atomically");
250
+ const end = PARAGRAPH.indexOf("crash") + 5;
251
+
252
+ expect(joined).toBe(PARAGRAPH.slice(start, end));
253
+ });
254
+
255
+ test("dragging past the right edge selects to the line end", async () => {
256
+ // Arrange
257
+ const { row, column } = locate("torn");
258
+
259
+ await setup.mockMouse.pressDown(column, row);
260
+ await settle(setup);
261
+
262
+ // Act: far past the text
263
+ await setup.mockMouse.moveTo(63, row);
264
+ await settle(setup);
265
+
266
+ // Assert
267
+ expect(highlightedText()).toBe("torn document behind.");
268
+ });
269
+
270
+ test("double-click selects the whole word under the pointer", async () => {
271
+ // Arrange + Act
272
+ const { row, column } = locate("sessions");
273
+
274
+ await setup.mockMouse.doubleClick(column + 3, row);
275
+ await settle(setup);
276
+
277
+ // Assert
278
+ expect(highlightedText()).toBe("sessions");
279
+ });
280
+
281
+ test("backspace on an empty draft undoes it, keeps the selection, and typing re-opens it", async () => {
282
+ // Arrange: mark "emon per" and start a draft
283
+ const { row, column } = locate("daemon");
284
+
285
+ await setup.mockMouse.drag(column + 2, row, column + 10, row);
286
+ await settle(setup);
287
+ await typeText(setup, "x");
288
+ expect(setup.captureCharFrame()).toContain("● x");
289
+
290
+ // Act: delete the only character - the card must stay, now empty
291
+ setup.mockInput.pressKey("BACKSPACE");
292
+ await settle(setup);
293
+ await settle(setup);
294
+
295
+ // Assert
296
+ expect(setup.captureCharFrame()).toContain("●");
297
+ expect(setup.captureCharFrame()).not.toContain("● x");
298
+
299
+ // Act: backspace again on the empty draft
300
+ setup.mockInput.pressKey("BACKSPACE");
301
+ await settle(setup);
302
+ await settle(setup);
303
+
304
+ // Assert: the card is gone, the selection is still painted
305
+ expect(setup.captureCharFrame()).not.toContain("●");
306
+ expect(highlightedText()).toBe("emon per");
307
+
308
+ // Act + Assert: typing again re-opens a draft on the same selection
309
+ await typeText(setup, "y");
310
+ expect(setup.captureCharFrame()).toContain("● y");
311
+ expect(highlightedText()).toBe("emon per");
312
+ });
313
+
314
+ test("typing after a partial-word drag anchors the comment to exactly those characters", async () => {
315
+ // Arrange: mark "emon per"
316
+ const { row, column } = locate("daemon");
317
+
318
+ await setup.mockMouse.drag(column + 2, row, column + 10, row);
319
+ await settle(setup);
320
+ expect(highlightedText()).toBe("emon per");
321
+
322
+ // Act: type and send
323
+ await typeText(setup, "partial");
324
+ setup.mockInput.pressKey("RETURN", { meta: true });
325
+ await settle(setup);
326
+
327
+ // Assert: the annotation span is the exact partial selection
328
+ expect(annotate).toHaveBeenCalledTimes(1);
329
+ const [span, body] = annotate.mock.calls[0]!;
330
+
331
+ expect(span).toEqual({
332
+ start: { blockIndex: 1, char: 6 },
333
+ end: { blockIndex: 1, char: 14 },
334
+ });
335
+ expect(PARAGRAPH.slice(span.start.char, span.end.char)).toBe("emon per");
336
+ expect(body).toBe("partial");
337
+ });
338
+ });
339
+
340
+ describe("marking across rows", () => {
341
+ /** Row and left column of the stanza on screen. */
342
+ function stanzaOrigin() {
343
+ const { row, column } = locate(STANZA_ROWS[0]!);
344
+
345
+ for (const [index, text] of STANZA_ROWS.entries()) {
346
+ expect(setup.captureCharFrame().split("\n")[row + index]).toContain(text);
347
+ }
348
+
349
+ return { row, column };
350
+ }
351
+
352
+ test("a drag onto the row below marks exactly two rows, to the character under the pointer", async () => {
353
+ // Arrange: press inside "written" on row one (source line one)
354
+ const origin = stanzaOrigin();
355
+ const pressColumn = STANZA_ROWS[0]!.indexOf("written") + 3;
356
+ const headColumn = STANZA_ROWS[1]!.indexOf("temp") + 2;
357
+
358
+ await setup.mockMouse.pressDown(origin.column + pressColumn, origin.row);
359
+ await settle(setup);
360
+
361
+ // Act: one row down, inside "temp"
362
+ await setup.mockMouse.moveTo(origin.column + headColumn, origin.row + 1);
363
+ await settle(setup);
364
+
365
+ // Assert: two rows, the second ending mid-word under the pointer
366
+ const rows = highlightedByRow();
367
+
368
+ expect([...rows.keys()]).toEqual([origin.row, origin.row + 1]);
369
+ expect(highlightedAcrossRows()).toBe(
370
+ STANZA.slice(stanzaOffset(0, pressColumn), stanzaOffset(1, headColumn)).replace("\n", " "),
371
+ );
372
+ expect(rows.get(origin.row + 1)).toBe("session, through a te");
373
+ });
374
+
375
+ test("moving down a row then back up shrinks the mark again", async () => {
376
+ // Arrange
377
+ const origin = stanzaOrigin();
378
+ const pressColumn = 4;
379
+
380
+ await setup.mockMouse.pressDown(origin.column + pressColumn, origin.row);
381
+ await settle(setup);
382
+
383
+ // Act: two rows down, then back to one
384
+ await setup.mockMouse.moveTo(origin.column + 10, origin.row + 2);
385
+ await settle(setup);
386
+ expect(highlightedByRow().size).toBe(3);
387
+ await setup.mockMouse.moveTo(origin.column + 10, origin.row + 1);
388
+ await settle(setup);
389
+
390
+ // Assert
391
+ const rows = highlightedByRow();
392
+
393
+ expect(rows.size).toBe(2);
394
+ expect(rows.get(origin.row + 1)).toBe("session, t");
395
+ });
396
+
397
+ test("shift+down extends a mark to the same column on the next visual row", async () => {
398
+ const origin = stanzaOrigin();
399
+ const column = STANZA_ROWS[0]!.indexOf("written");
400
+
401
+ await setup.mockMouse.click(origin.column + column, origin.row);
402
+ await settle(setup);
403
+ setup.mockInput.pressKey("ARROW_DOWN", { shift: true });
404
+ await settle(setup);
405
+
406
+ expect(highlightedAcrossRows()).toBe(
407
+ STANZA.slice(stanzaOffset(0, column), stanzaOffset(1, column)).replace("\n", " "),
408
+ );
409
+ });
410
+
411
+ test("a backward drag up two rows normalizes to the same characters", async () => {
412
+ // Arrange: press inside "crash" on row three
413
+ const origin = stanzaOrigin();
414
+ const pressColumn = STANZA_ROWS[2]!.indexOf("crash") + 2;
415
+ const headColumn = STANZA_ROWS[0]!.indexOf("JSON");
416
+
417
+ await setup.mockMouse.pressDown(origin.column + pressColumn, origin.row + 2);
418
+ await settle(setup);
419
+
420
+ // Act: drag up to the "J" of "JSON"
421
+ await setup.mockMouse.moveTo(origin.column + headColumn, origin.row);
422
+ await settle(setup);
423
+
424
+ // Assert
425
+ expect(highlightedAcrossRows()).toBe(
426
+ STANZA.slice(stanzaOffset(0, headColumn), stanzaOffset(2, pressColumn)).replaceAll("\n", " "),
427
+ );
428
+ expect(highlightedByRow().get(origin.row + 2)).toBe("so a cr");
429
+ });
430
+
431
+ test("dragging below all text marks through to the document's last character", async () => {
432
+ // Arrange: press on the stanza's last row
433
+ const origin = stanzaOrigin();
434
+ const pressColumn = STANZA_ROWS[2]!.indexOf("torn");
435
+
436
+ await setup.mockMouse.pressDown(origin.column + pressColumn, origin.row + 2);
437
+ await settle(setup);
438
+
439
+ // Act: a row below everything, past the right edge
440
+ await setup.mockMouse.moveTo(63, origin.row + 8);
441
+ await settle(setup);
442
+
443
+ // Assert: the stanza tail and both bullets (list items sit on adjacent rows)
444
+ const rows = highlightedByRow();
445
+
446
+ expect(rows.get(origin.row + 2)).toBe("torn file on disk.");
447
+ expect(rows.get(origin.row + 4)).toBe(BULLETS[0]);
448
+ expect(rows.get(origin.row + 5)).toBe(BULLETS[1]);
449
+ expect(rows.size).toBe(3);
450
+ });
451
+
452
+ test("dragging above all text marks back from the document's first character", async () => {
453
+ // Arrange: press on the paragraph's first row
454
+ const { row, column } = locate("daemon");
455
+
456
+ await setup.mockMouse.pressDown(column, row);
457
+ await settle(setup);
458
+
459
+ // Act: the padding row above the title
460
+ await setup.mockMouse.moveTo(30, 0);
461
+ await settle(setup);
462
+
463
+ // Assert: the title and the paragraph head
464
+ const rows = highlightedByRow();
465
+
466
+ expect(rows.get(locate("Plan").row)).toBe("Plan");
467
+ expect(rows.get(row)).toBe("The ");
468
+ expect(rows.size).toBe(2);
469
+ });
470
+
471
+ test("a press and release on the same cell marks nothing", async () => {
472
+ // Arrange + Act
473
+ const origin = stanzaOrigin();
474
+
475
+ await setup.mockMouse.pressDown(origin.column + 12, origin.row + 1);
476
+ await settle(setup);
477
+ await setup.mockMouse.release(origin.column + 12, origin.row + 1);
478
+ await settle(setup);
479
+
480
+ // Assert
481
+ expect(highlightedByRow().size).toBe(0);
482
+ });
483
+
484
+ test("holding a mark at the viewport edge scrolls and extends it", async () => {
485
+ setup.renderer.destroy();
486
+ await mount([], 10);
487
+ const origin = locate("daemon");
488
+
489
+ await setup.mockMouse.pressDown(origin.column, origin.row);
490
+ await setup.mockMouse.moveTo(63, 9);
491
+ await new Promise((resolve) => setTimeout(resolve, 350));
492
+ await settle(setup);
493
+ await setup.mockMouse.release(63, 9);
494
+
495
+ expect(setup.captureCharFrame()).not.toContain("# Plan");
496
+ expect(highlightedText().length).toBeGreaterThan("daemon".length);
497
+ });
498
+
499
+ test("opening a comment on the document's last line reveals its composer", async () => {
500
+ setup.renderer.destroy();
501
+ await mount([], 10);
502
+
503
+ for (let step = 0; step < 20; step++) {
504
+ setup.mockInput.pressKey("ARROW_DOWN");
505
+ // eslint-disable-next-line no-await-in-loop
506
+ await settle(setup);
507
+ }
508
+ const lastLine = locate(BULLETS[1]!);
509
+
510
+ await setup.mockMouse.drag(
511
+ lastLine.column,
512
+ lastLine.row,
513
+ lastLine.column + "schema.ts".length,
514
+ lastLine.row,
515
+ );
516
+ await settle(setup);
517
+ await typeText(setup, "bottom comment");
518
+
519
+ expect(setup.captureCharFrame()).toContain("● bottom comment");
520
+ });
521
+ });
522
+
523
+ describe("marking across blocks", () => {
524
+ /** Screen row and left column of a text on screen, asserting it is there. */
525
+ function rowOf(text: string) {
526
+ return locate(text);
527
+ }
528
+
529
+ test("a drag from a paragraph into the bullet below marks the tail and the head, not the gap", async () => {
530
+ // Arrange: press inside "torn" on the stanza's last row
531
+ const torn = rowOf("torn file");
532
+ const bullet = rowOf(BULLETS[0]!);
533
+
534
+ expect(bullet.row).toBe(torn.row + 2);
535
+ await setup.mockMouse.pressDown(torn.column, torn.row);
536
+ await settle(setup);
537
+
538
+ // Act: onto "Thread" in the first bullet
539
+ await setup.mockMouse.moveTo(bullet.column + BULLETS[0]!.indexOf("Thread") + 6, bullet.row);
540
+ await settle(setup);
541
+
542
+ // Assert: two rows, the blank row between them untouched
543
+ const rows = highlightedByRow();
544
+
545
+ expect([...rows.keys()]).toEqual([torn.row, bullet.row]);
546
+ expect(rows.get(torn.row)).toBe("torn file on disk.");
547
+ expect(rows.get(bullet.row)).toBe("store.ts - the Thread");
548
+ });
549
+
550
+ test("a drag across two bullets marks both", async () => {
551
+ // Arrange
552
+ const first = rowOf(BULLETS[0]!);
553
+ const second = rowOf(BULLETS[1]!);
554
+
555
+ await setup.mockMouse.pressDown(first.column + "store.ts - ".length, first.row);
556
+ await settle(setup);
557
+
558
+ // Act
559
+ await setup.mockMouse.moveTo(second.column + "schema.ts".length, second.row);
560
+ await settle(setup);
561
+
562
+ // Assert
563
+ const rows = highlightedByRow();
564
+
565
+ expect(rows.get(first.row)).toBe("the ThreadStore class");
566
+ expect(rows.get(second.row)).toBe("schema.ts");
567
+ expect(rows.size).toBe(2);
568
+ });
569
+
570
+ test("the pointer on the blank row below a block marks to that block's end only", async () => {
571
+ // Arrange
572
+ const torn = rowOf("torn file");
573
+
574
+ await setup.mockMouse.pressDown(torn.column, torn.row);
575
+ await settle(setup);
576
+
577
+ // Act
578
+ await setup.mockMouse.moveTo(10, torn.row + 1);
579
+ await settle(setup);
580
+
581
+ // Assert
582
+ expect(highlightedByRow().size).toBe(1);
583
+ expect(highlightedText()).toBe("torn file on disk.");
584
+ });
585
+
586
+ test("a backward drag from a bullet up into the paragraph normalizes across the blocks", async () => {
587
+ // Arrange: press after "store.ts" and drag up into "disk"
588
+ const bullet = rowOf(BULLETS[0]!);
589
+ const disk = rowOf("disk.");
590
+
591
+ await setup.mockMouse.pressDown(bullet.column + "store.ts".length, bullet.row);
592
+ await settle(setup);
593
+
594
+ // Act
595
+ await setup.mockMouse.moveTo(disk.column, disk.row);
596
+ await settle(setup);
597
+
598
+ // Assert
599
+ const rows = highlightedByRow();
600
+
601
+ expect(rows.get(disk.row)).toBe("disk.");
602
+ expect(rows.get(bullet.row)).toBe("store.ts");
603
+ expect(rows.size).toBe(2);
604
+ });
605
+
606
+ test("typing after a cross-block drag comments on the span and the card sits under its last block", async () => {
607
+ // Arrange
608
+ const torn = rowOf("torn file");
609
+ const bullet = rowOf(BULLETS[0]!);
610
+
611
+ await setup.mockMouse.drag(torn.column, torn.row, bullet.column + 8, bullet.row);
612
+ await settle(setup);
613
+
614
+ // Act
615
+ await typeText(setup, "one comment for both");
616
+ const frame = setup.captureCharFrame().split("\n");
617
+ const cardRow = frame.findIndex((line) => line.includes("● one comment for both"));
618
+
619
+ // Assert: the draft card renders below the bullet, not below the paragraph
620
+ expect(cardRow).toBeGreaterThan(bullet.row);
621
+ expect(frame.slice(torn.row + 1, bullet.row + 1).join("\n")).not.toContain("●");
622
+
623
+ // Act: send
624
+ setup.mockInput.pressKey("RETURN", { meta: true });
625
+ await settle(setup);
626
+
627
+ // Assert
628
+ expect(annotate).toHaveBeenCalledTimes(1);
629
+ const [span, body] = annotate.mock.calls[0]!;
630
+
631
+ expect(span).toEqual({
632
+ start: { blockIndex: STANZA_BLOCK, char: STANZA.indexOf("torn") },
633
+ end: { blockIndex: FIRST_BULLET_BLOCK, char: 8 },
634
+ });
635
+ expect(body).toBe("one comment for both");
636
+ });
637
+
638
+ test("a saved spanning comment paints every block it covers and renders one card", async () => {
639
+ // Arrange: an anchor from "torn" to "store.ts", as the daemon would hold it
640
+ setup.renderer.destroy();
641
+ const blocks = parseBlocks(PLAN);
642
+ const anchor = makeAnchor(blocks, STANZA_BLOCK, STANZA.indexOf("torn"), 8, FIRST_BULLET_BLOCK);
643
+
644
+ await mount([
645
+ {
646
+ id: "span_1",
647
+ kind: "comment",
648
+ anchor,
649
+ body: "both at once",
650
+ createdAt: "2026-09-01T10:00:00Z",
651
+ },
652
+ ]);
653
+
654
+ // Assert
655
+ const rows = highlightedByRow();
656
+ const torn = rowOf("torn file");
657
+ const bullet = rowOf(BULLETS[0]!);
658
+
659
+ expect(rows.get(torn.row)).toBe("torn file on disk.");
660
+ expect(rows.get(bullet.row)).toBe("store.ts");
661
+ expect(rows.size).toBe(2);
662
+ const frame = setup.captureCharFrame();
663
+
664
+ expect(frame.split("● both at once").length - 1).toBe(1);
665
+ expect(frame.split("\n").findIndex((line) => line.includes("● both at once"))).toBeGreaterThan(
666
+ bullet.row,
667
+ );
668
+ });
669
+ });
670
+
671
+ describe("marks stay put around cards", () => {
672
+ const seeded: Annotation[] = [
673
+ {
674
+ id: "own_1",
675
+ kind: "comment",
676
+ anchor: makeAnchor(
677
+ parseBlocks(PLAN),
678
+ 1,
679
+ PARAGRAPH.indexOf("persists"),
680
+ PARAGRAPH.indexOf("persists") + "persists".length,
681
+ ),
682
+ body: "which daemon?",
683
+ createdAt: "2026-09-01T10:00:00Z",
684
+ },
685
+ ];
686
+
687
+ beforeEach(async () => {
688
+ setup.renderer.destroy();
689
+ await mount(seeded);
690
+ });
691
+
692
+ test("clicking a card after placing the caret in another block marks nothing new", async () => {
693
+ // Arrange: a bare caret inside the first bullet
694
+ const bullet = locate(BULLETS[0]!);
695
+ const card = locate("● which daemon?");
696
+
697
+ await setup.mockMouse.click(bullet.column + 20, bullet.row);
698
+ await settle(setup);
699
+ expect(highlightedText()).toBe("persists");
700
+
701
+ // Act: focus the card, which lives under the paragraph
702
+ await setup.mockMouse.click(card.column + 4, card.row);
703
+ await settle(setup);
704
+ await settle(setup);
705
+
706
+ // Assert: only the thread's own mark is painted
707
+ expect(highlightedByRow().size).toBe(1);
708
+ expect(highlightedText()).toBe("persists");
709
+ });
710
+
711
+ test("dragging inside a card's text marks nothing in the document", async () => {
712
+ // Arrange
713
+ const bullet = locate(BULLETS[0]!);
714
+ const card = locate("● which daemon?");
715
+
716
+ await setup.mockMouse.click(bullet.column + 20, bullet.row);
717
+ await settle(setup);
718
+
719
+ // Act: a drag across the comment text
720
+ await setup.mockMouse.drag(card.column + 2, card.row, card.column + 12, card.row);
721
+ await settle(setup);
722
+ await settle(setup);
723
+
724
+ // Assert
725
+ expect(highlightedText()).toBe("persists");
726
+ });
727
+
728
+ test("jumping to a discussion with cmd+] after placing the caret elsewhere marks nothing new", async () => {
729
+ // Arrange
730
+ const bullet = locate(BULLETS[1]!);
731
+
732
+ await setup.mockMouse.click(bullet.column + 5, bullet.row);
733
+ await settle(setup);
734
+
735
+ // Act
736
+ setup.mockInput.pressKey("]", { meta: true });
737
+ await settle(setup);
738
+ await settle(setup);
739
+
740
+ // Assert
741
+ expect(highlightedText()).toBe("persists");
742
+ });
743
+ });
744
+
745
+ describe("the idle caret cell", () => {
746
+ test("a click paints one steady caret cell under the pointer", async () => {
747
+ // Arrange + Act
748
+ const { row, column } = locate("sessions");
749
+
750
+ await setup.mockMouse.click(column + 2, row);
751
+ await settle(setup);
752
+
753
+ // Assert
754
+ expect(caretCells()).toEqual([{ row, column: column + 2, text: "s" }]);
755
+ });
756
+
757
+ test("after a forward drag the caret cell sits right after the mark", async () => {
758
+ // Arrange + Act: mark "emon per"
759
+ const { row, column } = locate("daemon");
760
+
761
+ await setup.mockMouse.drag(column + 2, row, column + 10, row);
762
+ await settle(setup);
763
+
764
+ // Assert: the mark is intact and the cell after it is the caret
765
+ expect(highlightedText()).toBe("emon per");
766
+ expect(caretCells()).toEqual([{ row, column: column + 10, text: "s" }]);
767
+ });
768
+
769
+ test("after a backward drag the caret cell shows through the mark's first character", async () => {
770
+ // Arrange + Act
771
+ const { row, column } = locate("daemon");
772
+
773
+ await setup.mockMouse.drag(column + 10, row, column + 2, row);
774
+ await settle(setup);
775
+
776
+ // Assert
777
+ expect(highlightedText()).toBe("emon per");
778
+ expect(caretCells()).toEqual([{ row, column: column + 2, text: "e" }]);
779
+ });
780
+
781
+ test("an open card takes the cursor: no caret cell and no placeholder in the text", async () => {
782
+ // Arrange
783
+ const { row, column } = locate("daemon");
784
+
785
+ await setup.mockMouse.click(column + 2, row);
786
+ await settle(setup);
787
+ expect(caretCells()).toHaveLength(1);
788
+
789
+ // Act
790
+ await typeText(setup, "x");
791
+
792
+ // Assert
793
+ expect(caretCells()).toHaveLength(0);
794
+ expect(setup.captureCharFrame()).not.toContain("comment...");
795
+ });
796
+ });
797
+
798
+ describe("discussion identity", () => {
799
+ const blocks = parseBlocks(PLAN);
800
+ const rootAnchor = makeAnchor(
801
+ blocks,
802
+ 1,
803
+ PARAGRAPH.indexOf("persists"),
804
+ PARAGRAPH.indexOf("persists") + "persists".length,
805
+ );
806
+ const root: Annotation = {
807
+ id: "root",
808
+ kind: "comment",
809
+ anchor: rootAnchor,
810
+ body: "which daemon?",
811
+ createdAt: "2026-09-01T10:00:00Z",
812
+ };
813
+
814
+ test("a reply joins its root comment's card by replyTo, and a same-span legacy note still groups", async () => {
815
+ // Arrange
816
+ setup.renderer.destroy();
817
+ await mount([
818
+ root,
819
+ {
820
+ id: "reply",
821
+ kind: "comment",
822
+ anchor: rootAnchor,
823
+ body: "the local one",
824
+ author: "SHA256:ana",
825
+ replyTo: "root",
826
+ createdAt: "2026-09-01T10:01:00Z",
827
+ },
828
+ {
829
+ id: "legacy",
830
+ kind: "comment",
831
+ anchor: rootAnchor,
832
+ body: "same span, no reply link",
833
+ author: "SHA256:bob",
834
+ createdAt: "2026-09-01T09:59:00Z",
835
+ },
836
+ ]);
837
+
838
+ // Assert: one card, voices in time order
839
+ const rows = setup.captureCharFrame().split("\n");
840
+ const voices = rows
841
+ .map((row) => row.match(/│ ([●○] .*?)\s+│/)?.[1])
842
+ .filter((voice): voice is string => voice !== undefined);
843
+
844
+ expect(voices).toEqual(["○ same span, no reply link", "● which daemon?", "○ the local one"]);
845
+ });
846
+
847
+ test("enter on a discussion replies to its root comment", async () => {
848
+ // Arrange
849
+ setup.renderer.destroy();
850
+ await mount([root]);
851
+ const card = locate("● which daemon?");
852
+
853
+ await setup.mockMouse.click(card.column + 4, card.row);
854
+ await settle(setup);
855
+
856
+ // Act
857
+ setup.mockInput.pressKey("RETURN");
858
+ await settle(setup);
859
+ await typeText(setup, "agreed");
860
+ setup.mockInput.pressKey("RETURN", { meta: true });
861
+ await settle(setup);
862
+
863
+ // Assert
864
+ expect(reply).toHaveBeenCalledTimes(1);
865
+ expect(reply.mock.calls[0]).toEqual(["root", "agreed"]);
866
+ expect(annotate).not.toHaveBeenCalled();
867
+ });
868
+ });
869
+
870
+ describe("a new comment sorts into place while typing", () => {
871
+ const blocks = parseBlocks(PLAN);
872
+ const anchorOn = (word: string): Annotation["anchor"] =>
873
+ makeAnchor(blocks, 1, PARAGRAPH.indexOf(word), PARAGRAPH.indexOf(word) + word.length);
874
+ const early: Annotation = {
875
+ id: "early",
876
+ kind: "comment",
877
+ anchor: anchorOn("persists"),
878
+ body: "alpha-note",
879
+ createdAt: "2026-09-01T10:00:00Z",
880
+ };
881
+ const late: Annotation = {
882
+ id: "late",
883
+ kind: "comment",
884
+ anchor: anchorOn("atomically"),
885
+ body: "omega-note",
886
+ createdAt: "2026-09-01T10:01:00Z",
887
+ };
888
+
889
+ test("a draft anchored between two comments renders between them, not appended to the stack", async () => {
890
+ // Arrange - two comments on the same line, one earlier and one later than the draft's word
891
+ setup.renderer.destroy();
892
+ await mount([early, late]);
893
+
894
+ // Act - mark a word between them and start typing a new comment
895
+ const sessions = locate("sessions");
896
+
897
+ await setup.mockMouse.doubleClick(sessions.column + 2, sessions.row);
898
+ await settle(setup);
899
+ await typeText(setup, "midway-note");
900
+ await settle(setup);
901
+
902
+ // Assert - the draft sits where it will land on save (by span start), not at the end
903
+ const rows = setup.captureCharFrame().split("\n");
904
+ const rowOf = (needle: string): number => rows.findIndex((row) => row.includes(needle));
905
+
906
+ expect(rowOf("alpha-note")).toBeGreaterThanOrEqual(0);
907
+ expect(rowOf("midway-note")).toBeGreaterThan(rowOf("alpha-note"));
908
+ expect(rowOf("omega-note")).toBeGreaterThan(rowOf("midway-note"));
909
+ });
910
+ });
911
+
912
+ describe("virtualization guard", () => {
913
+ // The performance guard: the thread view must word-wrap only the blocks in the viewport, not the
914
+ // whole document. Deterministic (a call count, not a wall-clock), so it fails on any machine if a
915
+ // future change un-virtualizes the view - the regression the nav-latency benchmark would show.
916
+ test("a large plan word-wraps only the on-screen blocks, not every block", async () => {
917
+ // Arrange - a plan far taller than the viewport
918
+ const paragraphs = Array.from({ length: 800 }, (_, index) => `Paragraph ${index} with words.`);
919
+ const bigPlan = `# Big Plan\n\n${paragraphs.join("\n\n")}\n`;
920
+ const display = buildDisplay(bigPlan, undefined);
921
+ const bigSession = fixturePlanSession({
922
+ artifact: { type: "plan", content: bigPlan, meta: { title: "Big", planPath: "big.md" } },
923
+ annotations: [],
924
+ });
925
+ const spy = spyOn(markRuns, "wrapLines");
926
+
927
+ try {
928
+ // Act
929
+ const bigSetup = await testRender(
930
+ <ThreadView
931
+ session={bigSession}
932
+ display={display}
933
+ marks={new Map()}
934
+ quickActions={DEFAULT_QUICK_ACTIONS}
935
+ observer={false}
936
+ onAnnotate={() => {}}
937
+ onReply={() => {}}
938
+ onUpdateAnnotation={() => {}}
939
+ onExit={() => {}}
940
+ />,
941
+ { width: 64, height: 24 },
942
+ );
943
+
944
+ await settle(bigSetup);
945
+
946
+ // Assert - across the mount's renders the view never wraps a block for each of the 800; a
947
+ // viewport-worth per render stays far under one full pass over the document
948
+ expect(spy.mock.calls.length).toBeLessThan(display.length);
949
+ bigSetup.renderer.destroy();
950
+ } finally {
951
+ spy.mockRestore();
952
+ }
953
+ });
954
+ });