@cueloop/client 0.1.0-alpha.10 → 0.1.0-alpha.100

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 (355) 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 +62 -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 +364 -0
  12. package/src/annotations/components/DiscussionMarkerRail.stories.tsx +54 -0
  13. package/src/annotations/components/DiscussionMarkerRail.tsx +186 -0
  14. package/src/annotations/discussions.ts +77 -0
  15. package/src/annotations/thread-selection.test.ts +210 -0
  16. package/src/annotations/thread-selection.ts +242 -0
  17. package/src/annotations/use-annotation-surface.tsx +1238 -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 +187 -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.identity.test.tsx +84 -0
  25. package/src/app/App.plan-render.test.tsx +209 -0
  26. package/src/app/App.plan-review.test.tsx +428 -0
  27. package/src/app/App.prototype-markdown.test.tsx +128 -0
  28. package/src/app/App.prototype.test.tsx +176 -0
  29. package/src/app/App.readonly.test.tsx +210 -0
  30. package/src/app/App.regression.test.tsx +99 -0
  31. package/src/app/App.rendering.test.tsx +134 -0
  32. package/src/app/App.share-dialog.test.tsx +177 -0
  33. package/src/app/App.submit-confirm.test.tsx +267 -0
  34. package/src/app/App.test.tsx +496 -0
  35. package/src/app/App.thread-agent.test.ts +12 -0
  36. package/src/app/App.toast.test.tsx +124 -0
  37. package/src/app/App.tsx +1650 -0
  38. package/src/app/App.visual.test.tsx +59 -0
  39. package/src/app/App.walk.test.tsx +299 -0
  40. package/src/app/App.workbench.test.tsx +1149 -0
  41. package/src/app/app-key-state.ts +61 -0
  42. package/src/app/app-screens.tsx +606 -0
  43. package/src/app/app-view-model.ts +168 -0
  44. package/src/app/components/AppHeader.stories.tsx +50 -0
  45. package/src/app/components/AppHeader.tsx +81 -0
  46. package/src/app/components/AppShell.brand.test.tsx +58 -0
  47. package/src/app/components/AppShell.extensions.test.tsx +191 -0
  48. package/src/app/components/AppShell.stories.tsx +89 -0
  49. package/src/app/components/AppShell.tsx +358 -0
  50. package/src/app/components/AppShell.zoom.test.tsx +149 -0
  51. package/src/app/components/Breadcrumb.stories.tsx +19 -0
  52. package/src/app/components/Breadcrumb.tsx +45 -0
  53. package/src/app/components/MenuBar.stories.tsx +28 -0
  54. package/src/app/components/MenuBar.tsx +44 -0
  55. package/src/app/components/PaneGrid.stories.tsx +84 -0
  56. package/src/app/components/PaneGrid.tsx +128 -0
  57. package/src/app/components/PanelColumn.stories.tsx +25 -0
  58. package/src/app/components/PanelColumn.tsx +93 -0
  59. package/src/app/components/ShellHeader.stories.tsx +50 -0
  60. package/src/app/components/ShellHeader.tsx +137 -0
  61. package/src/app/components/WorkspacePanels.stories.tsx +36 -0
  62. package/src/app/components/WorkspacePanels.tsx +120 -0
  63. package/src/app/components/pages/Workspace.stories.tsx +181 -0
  64. package/src/app/intent-dispatch.test.ts +728 -0
  65. package/src/app/intent-dispatch.ts +687 -0
  66. package/src/app/launch-layout.test.ts +78 -0
  67. package/src/app/launch-layout.ts +57 -0
  68. package/src/app/ready-signal.test.ts +85 -0
  69. package/src/app/ready-signal.ts +52 -0
  70. package/src/app/run.ts +109 -0
  71. package/src/{serve.test.ts → app/serve.test.ts} +24 -12
  72. package/src/app/serve.ts +111 -0
  73. package/src/app/version.ts +5 -0
  74. package/src/appearance/components/theme-context.stories.tsx +46 -0
  75. package/src/appearance/components/theme-context.tsx +37 -0
  76. package/src/appearance/theme-presets.test.ts +51 -0
  77. package/src/appearance/theme-presets.ts +219 -0
  78. package/src/appearance/theme.ts +112 -0
  79. package/src/appearance/visual/contrast.test.ts +35 -0
  80. package/src/appearance/visual/contrast.ts +33 -0
  81. package/src/diff/components/AnnotatableFileView.stories.tsx +42 -0
  82. package/src/diff/components/AnnotatableFileView.test.tsx +157 -0
  83. package/src/diff/components/AnnotatableFileView.tsx +141 -0
  84. package/src/diff/components/BareWorkbenchFileView.stories.tsx +27 -0
  85. package/src/diff/components/BareWorkbenchFileView.tsx +63 -0
  86. package/src/diff/components/DiffContentView.scroll.test.tsx +434 -0
  87. package/src/diff/components/DiffContentView.split.test.tsx +140 -0
  88. package/src/diff/components/DiffContentView.stories.tsx +80 -0
  89. package/src/diff/components/DiffContentView.syntax.test.tsx +82 -0
  90. package/src/diff/components/DiffContentView.tsx +1055 -0
  91. package/src/diff/components/WalkWizard.stories.tsx +100 -0
  92. package/src/diff/components/WalkWizard.tsx +146 -0
  93. package/src/diff/components/diff-content-view-layout.test.ts +198 -0
  94. package/src/diff/components/diff-content-view-layout.ts +84 -0
  95. package/src/diff/diff-fold.test.ts +78 -0
  96. package/src/diff/diff-fold.ts +126 -0
  97. package/src/diff/diff-hunk-curate.test.ts +315 -0
  98. package/src/diff/diff-hunk-curate.ts +152 -0
  99. package/src/diff/diff-intraline.test.ts +239 -0
  100. package/src/diff/diff-intraline.ts +238 -0
  101. package/src/diff/diff-syntax.test.ts +125 -0
  102. package/src/diff/diff-syntax.ts +186 -0
  103. package/src/diff/split-diff.test.ts +110 -0
  104. package/src/diff/split-diff.ts +144 -0
  105. package/src/diff/view-diff.test.ts +325 -0
  106. package/src/diff/view-diff.ts +522 -0
  107. package/src/diff/walk.test.ts +121 -0
  108. package/src/diff/walk.ts +73 -0
  109. package/src/index.ts +31 -2
  110. package/src/integrations/attribution.test.ts +31 -0
  111. package/src/integrations/attribution.ts +42 -0
  112. package/src/integrations/client-extension-registry.test.tsx +61 -0
  113. package/src/integrations/client-extension-registry.ts +159 -0
  114. package/src/integrations/clipboard.test.ts +23 -0
  115. package/src/integrations/clipboard.ts +33 -0
  116. package/src/integrations/components/ExtensionZone.stories.tsx +28 -0
  117. package/src/integrations/components/ExtensionZone.tsx +145 -0
  118. package/src/integrations/components/ShareDialog.stories.tsx +121 -0
  119. package/src/integrations/components/ShareDialog.tsx +550 -0
  120. package/src/integrations/components/share-dialog-store.ts +55 -0
  121. package/src/integrations/editor.test.ts +149 -0
  122. package/src/integrations/editor.ts +160 -0
  123. package/src/integrations/github-identity.test.ts +58 -0
  124. package/src/integrations/github-identity.ts +41 -0
  125. package/src/integrations/herdr.test.ts +43 -0
  126. package/src/integrations/herdr.ts +38 -0
  127. package/src/integrations/integrations.test.ts +41 -0
  128. package/src/integrations/integrations.ts +32 -0
  129. package/src/integrations/pasted-image.test.ts +67 -0
  130. package/src/integrations/pasted-image.ts +60 -0
  131. package/src/integrations/share.ts +238 -0
  132. package/src/keyboard/components/CompletionOverlay.stories.tsx +37 -0
  133. package/src/keyboard/components/CompletionOverlay.tsx +75 -0
  134. package/src/keyboard/components/HandleChipsInput.stories.tsx +24 -0
  135. package/src/keyboard/components/HandleChipsInput.test.tsx +35 -0
  136. package/src/keyboard/components/HandleChipsInput.tsx +143 -0
  137. package/src/keyboard/components/KeybindsDialog.stories.tsx +31 -0
  138. package/src/keyboard/components/KeybindsDialog.tsx +103 -0
  139. package/src/keyboard/components/NavModeHint.stories.tsx +40 -0
  140. package/src/keyboard/components/NavModeHint.tsx +31 -0
  141. package/src/keyboard/components/SlashComposer.stories.tsx +37 -0
  142. package/src/keyboard/components/SlashComposer.tsx +87 -0
  143. package/src/keyboard/key-bindings.test.ts +156 -0
  144. package/src/keyboard/key-bindings.ts +459 -0
  145. package/src/keyboard/keymap.test.ts +611 -0
  146. package/src/keyboard/keymap.ts +395 -0
  147. package/src/keyboard/skills.test.ts +74 -0
  148. package/src/keyboard/skills.ts +75 -0
  149. package/src/keyboard/slash-palette.test.ts +103 -0
  150. package/src/keyboard/slash-palette.ts +129 -0
  151. package/src/keyboard/thread-chords.test.ts +79 -0
  152. package/src/keyboard/thread-chords.ts +163 -0
  153. package/src/keyboard/use-shared-keyboard.ts +55 -0
  154. package/src/markdown/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  155. package/src/markdown/components/MarkdownGridBlock.stories.tsx +33 -0
  156. package/src/markdown/components/MarkdownGridBlock.tsx +72 -0
  157. package/src/markdown/components/MarkdownThreadEditor.stories.tsx +15 -0
  158. package/src/markdown/components/MarkdownThreadEditor.test.tsx +98 -0
  159. package/src/markdown/components/MarkdownThreadEditor.tsx +160 -0
  160. package/src/markdown/components/ThreadView.selection.test.tsx +951 -0
  161. package/src/markdown/components/ThreadView.slash.test.tsx +146 -0
  162. package/src/markdown/components/ThreadView.stories.tsx +26 -0
  163. package/src/markdown/components/ThreadView.tsx +560 -0
  164. package/src/markdown/mark-runs.ts +121 -0
  165. package/src/markdown/markdown-editor-syntax-style.ts +50 -0
  166. package/src/markdown/markdown-grid.test.ts +101 -0
  167. package/src/markdown/markdown-grid.ts +203 -0
  168. package/src/markdown/markdown-highlight.test.ts +61 -0
  169. package/src/markdown/markdown-highlight.ts +124 -0
  170. package/src/markdown/thread-code-syntax.ts +45 -0
  171. package/src/markdown/use-thread-body-editing.tsx +54 -0
  172. package/src/markdown/view-plan.regression.test.ts +60 -0
  173. package/src/markdown/view-plan.test.ts +545 -0
  174. package/src/markdown/view-plan.ts +1009 -0
  175. package/src/performance/otlp-trace.test.ts +119 -0
  176. package/src/performance/otlp-trace.ts +134 -0
  177. package/src/performance/perf-report.ts +18 -0
  178. package/src/performance/perf-timings.test.ts +18 -0
  179. package/src/performance/perf-timings.ts +39 -0
  180. package/src/prototype/components/PrototypeContentView.stories.tsx +19 -0
  181. package/src/prototype/components/PrototypeContentView.tsx +442 -0
  182. package/src/prototype/prototype-browser.test.ts +81 -0
  183. package/src/prototype/prototype-browser.ts +321 -0
  184. package/src/prototype/prototype-pixels.tsx +35 -0
  185. package/src/settings/components/SettingsDialog.stories.tsx +117 -0
  186. package/src/settings/components/SettingsDialog.tsx +183 -0
  187. package/src/settings/components/SettingsRows.stories.tsx +18 -0
  188. package/src/settings/components/SettingsRows.tsx +112 -0
  189. package/src/settings/components/quick-actions-editor.stories.tsx +58 -0
  190. package/src/settings/components/quick-actions-editor.test.tsx +120 -0
  191. package/src/settings/components/quick-actions-editor.tsx +222 -0
  192. package/src/settings/config-identity.test.ts +78 -0
  193. package/src/settings/config.test.ts +676 -0
  194. package/src/settings/config.ts +628 -0
  195. package/src/settings/use-settings-dialog.tsx +351 -0
  196. package/src/stories/__snapshots__/stories.test.tsx.snap +2358 -0
  197. package/src/stories/stories-app.tsx +196 -0
  198. package/src/stories/stories.test.tsx +74 -0
  199. package/src/stories/story-fixtures.ts +102 -0
  200. package/src/stories/story-tree.test.ts +108 -0
  201. package/src/stories/story.ts +154 -0
  202. package/src/terminal/ansi-screen.test.ts +28 -0
  203. package/src/terminal/ansi-screen.ts +72 -0
  204. package/src/terminal/components/AnsiScreen.stories.tsx +12 -0
  205. package/src/terminal/components/AnsiScreen.tsx +28 -0
  206. package/src/terminal/components/terminal-pane.test.tsx +40 -0
  207. package/src/terminal/components/terminal-pane.ts +196 -0
  208. package/src/terminal/ghostty-terminal.test.ts +85 -0
  209. package/src/terminal/ghostty-terminal.ts +197 -0
  210. package/src/terminal/kitty-image.test.ts +66 -0
  211. package/src/terminal/kitty-image.ts +137 -0
  212. package/src/terminal/pty.test.ts +81 -0
  213. package/src/terminal/pty.ts +246 -0
  214. package/src/testing/test-support.test.ts +264 -0
  215. package/src/testing/test-support.ts +367 -0
  216. package/src/thread/agent-continuation.test.ts +32 -0
  217. package/src/thread/agent-continuation.ts +13 -0
  218. package/src/thread/agent-transcript.test.ts +161 -0
  219. package/src/thread/agent-transcript.ts +336 -0
  220. package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
  221. package/src/thread/components/AgentConfigControls.test.tsx +112 -0
  222. package/src/thread/components/AgentConfigControls.tsx +114 -0
  223. package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
  224. package/src/thread/components/AgentThreadPane.test.tsx +829 -0
  225. package/src/thread/components/AgentThreadPane.tsx +367 -0
  226. package/src/thread/components/ThreadFooter.stories.tsx +30 -0
  227. package/src/thread/components/ThreadFooter.test.tsx +49 -0
  228. package/src/thread/components/ThreadFooter.tsx +77 -0
  229. package/src/thread/components/ThreadTree.stories.tsx +46 -0
  230. package/src/thread/components/ThreadTree.test.tsx +41 -0
  231. package/src/thread/components/ThreadTree.tsx +267 -0
  232. package/src/thread/components/thread-tree.test.ts +137 -0
  233. package/src/thread/components/thread-tree.ts +112 -0
  234. package/src/thread/thread-controller.curate.test.ts +502 -0
  235. package/src/thread/thread-controller.derivation.test.ts +62 -0
  236. package/src/thread/thread-controller.live-diff.test.ts +357 -0
  237. package/src/thread/thread-controller.optimistic.test.ts +69 -0
  238. package/src/thread/thread-controller.share.test.ts +573 -0
  239. package/src/thread/thread-controller.switch-cache.test.ts +174 -0
  240. package/src/thread/thread-controller.tree.test.ts +263 -0
  241. package/src/thread/thread-controller.ts +2018 -0
  242. package/src/thread/tree-view.test.ts +128 -0
  243. package/src/thread/tree-view.ts +146 -0
  244. package/src/thread/use-thread-agent.test.tsx +208 -0
  245. package/src/thread/use-thread-agent.ts +111 -0
  246. package/src/ui/components/CodeBlock.stories.tsx +42 -0
  247. package/src/ui/components/CodeBlock.tsx +80 -0
  248. package/src/ui/components/ConfirmCard.stories.tsx +37 -0
  249. package/src/ui/components/ConfirmCard.tsx +151 -0
  250. package/src/ui/components/ConfirmDialog.stories.tsx +21 -0
  251. package/src/ui/components/ConfirmDialog.tsx +72 -0
  252. package/src/ui/components/OverlayScrollbar.stories.tsx +33 -0
  253. package/src/ui/components/OverlayScrollbar.test.tsx +139 -0
  254. package/src/ui/components/OverlayScrollbar.tsx +129 -0
  255. package/src/ui/components/PromptDialog.stories.tsx +36 -0
  256. package/src/ui/components/PromptDialog.test.tsx +65 -0
  257. package/src/ui/components/PromptDialog.tsx +99 -0
  258. package/src/ui/components/RootOverlay.stories.tsx +45 -0
  259. package/src/ui/components/RootOverlay.tsx +49 -0
  260. package/src/ui/components/ScrollArea.stories.tsx +20 -0
  261. package/src/ui/components/ScrollArea.tsx +61 -0
  262. package/src/ui/components/Toast.stories.tsx +19 -0
  263. package/src/ui/components/Toast.tsx +54 -0
  264. package/src/ui/components/Tooltip.stories.tsx +26 -0
  265. package/src/ui/components/Tooltip.tsx +72 -0
  266. package/src/ui/components/menu-control.stories.tsx +35 -0
  267. package/src/ui/components/menu-control.tsx +43 -0
  268. package/src/ui/components/primitives/Button.stories.tsx +32 -0
  269. package/src/ui/components/primitives/Button.tsx +57 -0
  270. package/src/ui/components/primitives/Card.stories.tsx +25 -0
  271. package/src/ui/components/primitives/Card.tsx +62 -0
  272. package/src/ui/components/primitives/Dialog.stories.tsx +24 -0
  273. package/src/ui/components/primitives/Dialog.test.tsx +38 -0
  274. package/src/ui/components/primitives/Dialog.tsx +69 -0
  275. package/src/ui/components/primitives/DialogActions.stories.tsx +20 -0
  276. package/src/ui/components/primitives/DialogActions.tsx +39 -0
  277. package/src/ui/components/primitives/IconButton.stories.tsx +36 -0
  278. package/src/ui/components/primitives/IconButton.tsx +58 -0
  279. package/src/ui/components/primitives/StatusBar.stories.tsx +11 -0
  280. package/src/ui/components/primitives/StatusBar.tsx +23 -0
  281. package/src/ui/components/primitives/Switch.stories.tsx +17 -0
  282. package/src/ui/components/primitives/Switch.tsx +32 -0
  283. package/src/ui/components/primitives/Tabs.stories.tsx +29 -0
  284. package/src/ui/components/primitives/Tabs.tsx +101 -0
  285. package/src/ui/components/primitives/Toolbar.stories.tsx +17 -0
  286. package/src/ui/components/primitives/Toolbar.tsx +14 -0
  287. package/src/ui/components/primitives/Tree.stories.tsx +124 -0
  288. package/src/ui/components/primitives/Tree.tsx +133 -0
  289. package/src/ui/components/primitives/frame.test.ts +12 -0
  290. package/src/ui/components/primitives/frame.ts +17 -0
  291. package/src/ui/components/primitives/icons.ts +61 -0
  292. package/src/ui/components/primitives/tree-model.test.ts +99 -0
  293. package/src/ui/components/primitives/tree-model.ts +137 -0
  294. package/src/ui/components/syntax-highlight.ts +133 -0
  295. package/src/ui/components/truncate-text.ts +7 -0
  296. package/src/ui/components/truncate-title.test.ts +31 -0
  297. package/src/ui/components/truncate-title.ts +15 -0
  298. package/src/ui/use-frame-measure.ts +74 -0
  299. package/src/ui/use-terminal-virtualizer.ts +259 -0
  300. package/src/workbench/components/ChangesColumn.stories.tsx +44 -0
  301. package/src/workbench/components/ChangesColumn.tsx +203 -0
  302. package/src/workbench/components/ChangesFileTree.test.tsx +181 -0
  303. package/src/workbench/components/EditorGrid.split.test.tsx +118 -0
  304. package/src/workbench/components/EditorGrid.stories.tsx +86 -0
  305. package/src/workbench/components/EditorGrid.tabs.test.tsx +118 -0
  306. package/src/workbench/components/EditorGrid.tsx +544 -0
  307. package/src/workbench/components/EditorGrid.zoom.test.tsx +132 -0
  308. package/src/workbench/components/FileContentsView.stories.tsx +16 -0
  309. package/src/workbench/components/FileContentsView.test.tsx +29 -0
  310. package/src/workbench/components/FileContentsView.tsx +100 -0
  311. package/src/workbench/components/GridTabContent.actions.test.tsx +151 -0
  312. package/src/workbench/components/GridTabContent.stories.tsx +59 -0
  313. package/src/workbench/components/GridTabContent.tsx +193 -0
  314. package/src/workbench/components/ProjectTreeView.stories.tsx +25 -0
  315. package/src/workbench/components/ProjectTreeView.test.tsx +236 -0
  316. package/src/workbench/components/ProjectTreeView.tsx +151 -0
  317. package/src/workbench/components/TreePane.stories.tsx +106 -0
  318. package/src/workbench/components/TreePane.tsx +155 -0
  319. package/src/workbench/components/WelcomePlayground.stories.tsx +12 -0
  320. package/src/workbench/components/WelcomePlayground.test.tsx +83 -0
  321. package/src/workbench/components/WelcomePlayground.tsx +138 -0
  322. package/src/workbench/components/editor-grid.test.ts +184 -0
  323. package/src/workbench/components/editor-grid.ts +281 -0
  324. package/src/workbench/components/file-tree.test.ts +60 -0
  325. package/src/workbench/components/file-tree.ts +112 -0
  326. package/src/workbench/use-changes-workbench.ts +205 -0
  327. package/src/workbench/use-remember-layout.ts +31 -0
  328. package/src/workbench/use-repo-changes.ts +91 -0
  329. package/src/workbench/workbench-snapshot.test.ts +99 -0
  330. package/src/workbench/workbench-snapshot.ts +59 -0
  331. package/src/App.diff.test.tsx +0 -99
  332. package/src/App.export.test.tsx +0 -88
  333. package/src/App.plan-review.test.tsx +0 -259
  334. package/src/App.readonly.test.tsx +0 -148
  335. package/src/App.rendering.test.tsx +0 -108
  336. package/src/App.submit-confirm.test.tsx +0 -183
  337. package/src/App.test.tsx +0 -218
  338. package/src/App.tsx +0 -1249
  339. package/src/config.test.ts +0 -116
  340. package/src/config.ts +0 -150
  341. package/src/editor.ts +0 -34
  342. package/src/herdr.test.ts +0 -68
  343. package/src/herdr.ts +0 -34
  344. package/src/keymap.test.ts +0 -270
  345. package/src/keymap.ts +0 -173
  346. package/src/run.ts +0 -30
  347. package/src/serve.ts +0 -75
  348. package/src/session-controller.ts +0 -398
  349. package/src/syntax.test.ts +0 -41
  350. package/src/syntax.ts +0 -88
  351. package/src/theme.ts +0 -46
  352. package/src/view-diff.test.ts +0 -55
  353. package/src/view-diff.ts +0 -86
  354. package/src/view.test.ts +0 -157
  355. package/src/view.ts +0 -364
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Discussions: the conversations an annotated surface hosts inline. A root
3
+ * annotation, its replies, and the span they mark, grouped so two roots on the
4
+ * very same text read as one conversation. Pure over the resolved marks, so the
5
+ * plan thread view and the diff sheet group the same way.
6
+ */
7
+
8
+ import type { Annotation, Thread } from "@cueloop/schema";
9
+ import type { Mark } from "../markdown/view-plan";
10
+ import { comparePositions, type TextSpan } from "./thread-selection";
11
+
12
+ /** One conversation: a root annotation, its replies, and the span they mark. */
13
+ export interface Discussion {
14
+ key: string;
15
+ rootId: string;
16
+ blockIndex: number;
17
+ span: TextSpan;
18
+ annotations: Annotation[];
19
+ }
20
+
21
+ export function spanKey(span: TextSpan): string {
22
+ return `${span.start.blockIndex}:${span.start.char}:${span.end.blockIndex}:${span.end.char}`;
23
+ }
24
+
25
+ /**
26
+ * Roots group by the span they resolved to (two roots on the very same text
27
+ * read as one conversation); replies join the discussion their replyTo names, or
28
+ * stand as roots when that root is gone.
29
+ */
30
+ export function discussionsFrom(session: Thread, marks: Map<number, Mark[]>): Discussion[] {
31
+ const byId = new Map(session.annotations.map((annotation) => [annotation.id, annotation]));
32
+ const spanOf = new Map<string, TextSpan>();
33
+
34
+ for (const [displayIndex, blockMarks] of marks) {
35
+ for (const mark of blockMarks) {
36
+ if (!mark.annotationId || spanOf.has(mark.annotationId)) continue;
37
+ spanOf.set(
38
+ mark.annotationId,
39
+ mark.span ?? {
40
+ start: { blockIndex: displayIndex, char: mark.start },
41
+ end: { blockIndex: displayIndex, char: mark.end },
42
+ },
43
+ );
44
+ }
45
+ }
46
+ const resolved = [...spanOf.keys()].map((id) => byId.get(id)!).filter(Boolean);
47
+ const isRoot = (annotation: Annotation): boolean =>
48
+ annotation.replyTo === undefined || !spanOf.has(annotation.replyTo);
49
+ const grouped = new Map<string, Discussion>();
50
+ const discussionOfRoot = new Map<string, Discussion>();
51
+
52
+ for (const annotation of resolved.filter(isRoot)) {
53
+ const span = spanOf.get(annotation.id)!;
54
+ const key = spanKey(span);
55
+ const discussion = grouped.get(key) ?? {
56
+ key,
57
+ rootId: annotation.id,
58
+ blockIndex: span.end.blockIndex,
59
+ span,
60
+ annotations: [],
61
+ };
62
+
63
+ discussion.annotations.push(annotation);
64
+ grouped.set(key, discussion);
65
+ discussionOfRoot.set(annotation.id, discussion);
66
+ }
67
+ for (const annotation of resolved.filter((candidate) => !isRoot(candidate))) {
68
+ discussionOfRoot.get(annotation.replyTo!)?.annotations.push(annotation);
69
+ }
70
+ for (const discussion of grouped.values()) {
71
+ discussion.annotations.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
72
+ }
73
+
74
+ return [...grouped.values()].toSorted((left, right) =>
75
+ comparePositions(left.span.start, right.span.start),
76
+ );
77
+ }
@@ -0,0 +1,210 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ isCollapsed,
4
+ isDoubleClick,
5
+ nextWordStart,
6
+ orderedSpan,
7
+ positionAt,
8
+ previousWordStart,
9
+ snapSpanToWords,
10
+ snapToWords,
11
+ spanRangeInBlock,
12
+ tightenSpan,
13
+ wordRangeAt,
14
+ type LineGeometry,
15
+ } from "./thread-selection";
16
+
17
+ const TEXT = "All persistence lives in a new module.";
18
+ const BULLET = "store.ts - the store";
19
+ // block 3 wraps onto two rows, a blank row, then bullet block 4 on one row
20
+ const LINES: LineGeometry[] = [
21
+ { blockIndex: 3, start: 0, end: 21, x: 4, y: 10 },
22
+ { blockIndex: 3, start: 22, end: 38, x: 4, y: 11 },
23
+ { blockIndex: 4, start: 0, end: 20, x: 6, y: 13 },
24
+ ];
25
+ const textLengthOf = (blockIndex: number): number =>
26
+ blockIndex === 3 ? TEXT.length : BULLET.length;
27
+
28
+ describe("positionAt", () => {
29
+ test("maps a cell inside a line to the character under it", () => {
30
+ // Act
31
+ const position = positionAt(LINES, 4 + 6, 10);
32
+
33
+ // Assert: column 6 of the first line is the "r" in "persistence"
34
+ expect(position).toEqual({ blockIndex: 3, char: 6 });
35
+ expect(TEXT[6]).toBe("r");
36
+ });
37
+
38
+ test("past the right edge resolves to the position after the line's last char", () => {
39
+ // Assert
40
+ expect(positionAt(LINES, 90, 10)).toEqual({ blockIndex: 3, char: 21 });
41
+ });
42
+
43
+ test("a row without text resolves to the end of the closest line above", () => {
44
+ // Assert: the blank row between the blocks, and a row below everything
45
+ expect(positionAt(LINES, 30, 12)).toEqual({ blockIndex: 3, char: 38 });
46
+ expect(positionAt(LINES, 30, 40)).toEqual({ blockIndex: 4, char: 20 });
47
+ });
48
+
49
+ test("rows above all text resolve to the first character", () => {
50
+ // Assert
51
+ expect(positionAt(LINES, 30, 2)).toEqual({ blockIndex: 3, char: 0 });
52
+ });
53
+
54
+ test("a row of another block maps into that block", () => {
55
+ // Act
56
+ const position = positionAt(LINES, 6 + 3, 13);
57
+
58
+ // Assert
59
+ expect(position).toEqual({ blockIndex: 4, char: 3 });
60
+ expect(positionAt([], 5, 10)).toBeNull();
61
+ });
62
+ });
63
+
64
+ describe("orderedSpan and tightenSpan", () => {
65
+ test("a backward drag normalizes to document order", () => {
66
+ // Act
67
+ const span = orderedSpan({
68
+ anchor: { blockIndex: 4, char: 5 },
69
+ head: { blockIndex: 3, char: 6 },
70
+ });
71
+
72
+ // Assert
73
+ expect(span).toEqual({
74
+ start: { blockIndex: 3, char: 6 },
75
+ end: { blockIndex: 4, char: 5 },
76
+ });
77
+ });
78
+
79
+ test("a collapsed selection is no span", () => {
80
+ // Arrange
81
+ const selection = {
82
+ anchor: { blockIndex: 3, char: 6 },
83
+ head: { blockIndex: 3, char: 6 },
84
+ };
85
+
86
+ // Assert
87
+ expect(isCollapsed(selection)).toBe(true);
88
+ expect(orderedSpan(selection)).toBeNull();
89
+ });
90
+
91
+ test("a span opening at a block's end moves onto the next block", () => {
92
+ // Act: the pointer sat on the blank row above the bullet when the press landed
93
+ const tightened = tightenSpan(
94
+ {
95
+ start: { blockIndex: 3, char: TEXT.length },
96
+ end: { blockIndex: 4, char: 5 },
97
+ },
98
+ textLengthOf,
99
+ );
100
+
101
+ // Assert
102
+ expect(tightened).toEqual({
103
+ start: { blockIndex: 4, char: 0 },
104
+ end: { blockIndex: 4, char: 5 },
105
+ });
106
+ });
107
+
108
+ test("a span closing at a block's start moves back onto the previous block", () => {
109
+ // Act
110
+ const tightened = tightenSpan(
111
+ { start: { blockIndex: 3, char: 10 }, end: { blockIndex: 4, char: 0 } },
112
+ textLengthOf,
113
+ );
114
+
115
+ // Assert
116
+ expect(tightened).toEqual({
117
+ start: { blockIndex: 3, char: 10 },
118
+ end: { blockIndex: 3, char: TEXT.length },
119
+ });
120
+ });
121
+
122
+ test("a span with nothing left in it is null", () => {
123
+ // Assert
124
+ expect(
125
+ tightenSpan(
126
+ {
127
+ start: { blockIndex: 3, char: TEXT.length },
128
+ end: { blockIndex: 4, char: 0 },
129
+ },
130
+ textLengthOf,
131
+ ),
132
+ ).toBeNull();
133
+ });
134
+ });
135
+
136
+ describe("spanRangeInBlock", () => {
137
+ const span = {
138
+ start: { blockIndex: 3, char: 22 },
139
+ end: { blockIndex: 5, char: 4 },
140
+ };
141
+
142
+ test("the first block runs from the start to its end, middles fully, the last to the end char", () => {
143
+ // Assert
144
+ expect(spanRangeInBlock(span, 3, TEXT.length)).toEqual({
145
+ start: 22,
146
+ end: TEXT.length,
147
+ });
148
+ expect(spanRangeInBlock(span, 4, BULLET.length)).toEqual({
149
+ start: 0,
150
+ end: BULLET.length,
151
+ });
152
+ expect(spanRangeInBlock(span, 5, 30)).toEqual({ start: 0, end: 4 });
153
+ });
154
+
155
+ test("blocks outside the span have no range", () => {
156
+ // Assert
157
+ expect(spanRangeInBlock(span, 2, 10)).toBeNull();
158
+ expect(spanRangeInBlock(span, 6, 10)).toBeNull();
159
+ });
160
+ });
161
+
162
+ describe("word rules", () => {
163
+ test("the boundary rule attributes trailing whitespace to the preceding word", () => {
164
+ // Assert: char 3 is the space after "All"
165
+ expect(wordRangeAt(TEXT, 3)).toEqual({ start: 0, end: 3 });
166
+ });
167
+
168
+ test("snapToWords grows a partial range to whole words on both sides", () => {
169
+ // Assert: "rsistence liv" -> "persistence lives"
170
+ expect(snapToWords(TEXT, { start: 6, end: 19 })).toEqual({
171
+ start: 4,
172
+ end: 21,
173
+ });
174
+ });
175
+
176
+ test("snapSpanToWords snaps each end of a cross-block span within its own block", () => {
177
+ // Act: "rsistence ... st" -> "persistence ... store.ts"
178
+ const snapped = snapSpanToWords(
179
+ { start: { blockIndex: 3, char: 6 }, end: { blockIndex: 4, char: 2 } },
180
+ (blockIndex) => (blockIndex === 3 ? TEXT : BULLET),
181
+ );
182
+
183
+ // Assert
184
+ expect(snapped).toEqual({
185
+ start: { blockIndex: 3, char: 4 },
186
+ end: { blockIndex: 4, char: 8 },
187
+ });
188
+ });
189
+
190
+ test("word starts step in both directions and stop at the ends", () => {
191
+ // Assert
192
+ expect(nextWordStart(TEXT, 0)).toBe(4);
193
+ expect(nextWordStart(TEXT, 31)).toBeNull();
194
+ expect(previousWordStart(TEXT, 22)).toBe(16);
195
+ expect(previousWordStart(TEXT, 0)).toBeNull();
196
+ });
197
+ });
198
+
199
+ describe("isDoubleClick", () => {
200
+ test("same cell within the window counts, a moved or late press does not", () => {
201
+ // Arrange
202
+ const first = { time: 1000, x: 5, y: 7 };
203
+
204
+ // Assert
205
+ expect(isDoubleClick(first, { time: 1300, x: 5, y: 7 })).toBe(true);
206
+ expect(isDoubleClick(first, { time: 1300, x: 6, y: 7 })).toBe(false);
207
+ expect(isDoubleClick(first, { time: 1500, x: 5, y: 7 })).toBe(false);
208
+ expect(isDoubleClick(null, { time: 1300, x: 5, y: 7 })).toBe(false);
209
+ });
210
+ });
@@ -0,0 +1,242 @@
1
+ /**
2
+ * The thread view's text-selection model: a pure, renderer-free description
3
+ * of where the caret is and what is marked, plus the hit-testing that maps a
4
+ * pointer cell onto a character. Character-precise (partial words), spanning
5
+ * wrapped lines and consecutive blocks, normalized for backward drags. Word snapping
6
+ * happens only where the grammar asks for it: the typing anchor for a bare
7
+ * caret, and double-click word mode.
8
+ *
9
+ * Isolated on purpose: the interaction can be unit-tested and benchmarked
10
+ * without a terminal, and the view layer only wires events to it.
11
+ */
12
+
13
+ export interface TextPosition {
14
+ blockIndex: number;
15
+ char: number;
16
+ }
17
+
18
+ /** Where one visual line of a block sits on screen, in cells. */
19
+ export interface LineGeometry {
20
+ blockIndex: number;
21
+ start: number;
22
+ end: number;
23
+ x: number;
24
+ y: number;
25
+ }
26
+
27
+ /** The held selection: `anchor` is where the press landed, `head` follows the pointer. */
28
+ export interface Selection {
29
+ anchor: TextPosition;
30
+ head: TextPosition;
31
+ }
32
+
33
+ export interface CharRange {
34
+ start: number;
35
+ end: number;
36
+ }
37
+
38
+ /** An ordered stretch of text, possibly across consecutive blocks. */
39
+ export interface TextSpan {
40
+ start: TextPosition;
41
+ end: TextPosition;
42
+ }
43
+
44
+ /** Document order: block first, then character. */
45
+ export function comparePositions(left: TextPosition, right: TextPosition): number {
46
+ return left.blockIndex - right.blockIndex || left.char - right.char;
47
+ }
48
+
49
+ /** Word boundaries of a block's text. */
50
+ export function wordRanges(text: string): CharRange[] {
51
+ const ranges: CharRange[] = [];
52
+ const matcher = /\S+/g;
53
+ let match: RegExpExecArray | null;
54
+
55
+ while ((match = matcher.exec(text)) !== null) {
56
+ ranges.push({ start: match.index, end: match.index + match[0].length });
57
+ }
58
+
59
+ return ranges;
60
+ }
61
+
62
+ /**
63
+ * The marked word for a char offset: the word containing it, or - when the
64
+ * caret sits in the whitespace immediately after a word - that preceding
65
+ * word, never the next one.
66
+ */
67
+ export function wordIndexAt(text: string, char: number): number {
68
+ const words = wordRanges(text);
69
+
70
+ for (let index = 0; index < words.length; index++) {
71
+ if (char < words[index]!.start) return Math.max(0, index - 1);
72
+ if (char < words[index]!.end) return index;
73
+ }
74
+
75
+ return Math.max(0, words.length - 1);
76
+ }
77
+
78
+ /** The word range the boundary rule assigns to a char offset; null for empty text. */
79
+ export function wordRangeAt(text: string, char: number): CharRange | null {
80
+ const words = wordRanges(text);
81
+
82
+ return words[wordIndexAt(text, char)] ?? null;
83
+ }
84
+
85
+ /**
86
+ * Map a pointer cell to a text position over every registered line. A row
87
+ * without text (a blank row, a card) resolves to the end of the closest line
88
+ * above it, rows above all text to the first character, and a cell past a
89
+ * line's last cell to the position after that line's last character, so a
90
+ * drag that runs off the right edge still selects to the line end.
91
+ */
92
+ export function positionAt(lines: LineGeometry[], x: number, y: number): TextPosition | null {
93
+ if (lines.length === 0) return null;
94
+ const ordered = lines.toSorted((left, right) => left.y - right.y || left.x - right.x);
95
+ const onRow = ordered.find((line) => line.y === y);
96
+
97
+ if (onRow) {
98
+ const column = Math.max(0, Math.min(onRow.end - onRow.start, x - onRow.x));
99
+
100
+ return { blockIndex: onRow.blockIndex, char: onRow.start + column };
101
+ }
102
+ const above = ordered.findLast((line) => line.y < y);
103
+
104
+ if (above) return { blockIndex: above.blockIndex, char: above.end };
105
+ const first = ordered[0]!;
106
+
107
+ return { blockIndex: first.blockIndex, char: first.start };
108
+ }
109
+
110
+ /** True when nothing is held: the caret is a point, not a range. */
111
+ export function isCollapsed(selection: Selection): boolean {
112
+ return comparePositions(selection.anchor, selection.head) === 0;
113
+ }
114
+
115
+ /** The selection in document order, or null when collapsed. */
116
+ export function orderedSpan(selection: Selection): TextSpan | null {
117
+ if (isCollapsed(selection)) return null;
118
+
119
+ return comparePositions(selection.anchor, selection.head) < 0
120
+ ? { start: selection.anchor, end: selection.head }
121
+ : { start: selection.head, end: selection.anchor };
122
+ }
123
+
124
+ /**
125
+ * Pull a span's edges off block boundaries: opening at a block's end or
126
+ * closing at a block's start marks nothing there, so the edge moves onto the
127
+ * neighbouring block. Null when nothing remains.
128
+ */
129
+ export function tightenSpan(
130
+ span: TextSpan,
131
+ textLengthOf: (blockIndex: number) => number,
132
+ ): TextSpan | null {
133
+ let { start, end } = span;
134
+
135
+ while (start.blockIndex < end.blockIndex && start.char >= textLengthOf(start.blockIndex)) {
136
+ start = { blockIndex: start.blockIndex + 1, char: 0 };
137
+ }
138
+ while (end.blockIndex > start.blockIndex && end.char === 0) {
139
+ end = {
140
+ blockIndex: end.blockIndex - 1,
141
+ char: textLengthOf(end.blockIndex - 1),
142
+ };
143
+ }
144
+ const clampedEnd = {
145
+ blockIndex: end.blockIndex,
146
+ char: Math.min(end.char, textLengthOf(end.blockIndex)),
147
+ };
148
+
149
+ return comparePositions(start, clampedEnd) < 0 ? { start, end: clampedEnd } : null;
150
+ }
151
+
152
+ /** The part of a span that falls on one block, as a char range; null when none. */
153
+ export function spanRangeInBlock(
154
+ span: TextSpan,
155
+ blockIndex: number,
156
+ textLength: number,
157
+ ): CharRange | null {
158
+ if (blockIndex < span.start.blockIndex || blockIndex > span.end.blockIndex) return null;
159
+ const start = blockIndex === span.start.blockIndex ? span.start.char : 0;
160
+ const end = blockIndex === span.end.blockIndex ? Math.min(span.end.char, textLength) : textLength;
161
+
162
+ return end > start ? { start, end } : null;
163
+ }
164
+
165
+ /** Grow a char range outward to the word boundaries it touches (double-click word mode). */
166
+ export function snapToWords(text: string, range: CharRange): CharRange {
167
+ const words = wordRanges(text);
168
+ const first = words.find((word) => range.start < word.end) ?? words[words.length - 1];
169
+ const last = words.toReversed().find((word) => range.end > word.start) ?? words[0];
170
+
171
+ if (!first || !last) return range;
172
+
173
+ return {
174
+ start: Math.min(first.start, range.start),
175
+ end: Math.max(last.end, range.end),
176
+ };
177
+ }
178
+
179
+ /** Snap both ends of a span to word boundaries, each within its own block. */
180
+ export function snapSpanToWords(span: TextSpan, textOf: (blockIndex: number) => string): TextSpan {
181
+ if (span.start.blockIndex === span.end.blockIndex) {
182
+ const snapped = snapToWords(textOf(span.start.blockIndex), {
183
+ start: span.start.char,
184
+ end: span.end.char,
185
+ });
186
+
187
+ return {
188
+ start: { blockIndex: span.start.blockIndex, char: snapped.start },
189
+ end: { blockIndex: span.end.blockIndex, char: snapped.end },
190
+ };
191
+ }
192
+ const startText = textOf(span.start.blockIndex);
193
+ const endText = textOf(span.end.blockIndex);
194
+
195
+ return {
196
+ start: {
197
+ blockIndex: span.start.blockIndex,
198
+ char: snapToWords(startText, {
199
+ start: span.start.char,
200
+ end: startText.length,
201
+ }).start,
202
+ },
203
+ end: {
204
+ blockIndex: span.end.blockIndex,
205
+ char: snapToWords(endText, { start: 0, end: span.end.char }).end,
206
+ },
207
+ };
208
+ }
209
+
210
+ export interface ClickStamp {
211
+ time: number;
212
+ x: number;
213
+ y: number;
214
+ }
215
+
216
+ /** Two presses on the same cell inside this window read as a double-click. */
217
+ export const DOUBLE_CLICK_WINDOW_MS = 400;
218
+
219
+ export function isDoubleClick(previous: ClickStamp | null, current: ClickStamp): boolean {
220
+ return (
221
+ previous !== null &&
222
+ previous.x === current.x &&
223
+ previous.y === current.y &&
224
+ current.time - previous.time <= DOUBLE_CLICK_WINDOW_MS
225
+ );
226
+ }
227
+
228
+ /** Next word start after `char`, or null past the last word. */
229
+ export function nextWordStart(text: string, char: number): number | null {
230
+ const next = wordRanges(text).find((word) => word.start > char);
231
+
232
+ return next ? next.start : null;
233
+ }
234
+
235
+ /** Previous word start before `char`, or null before the first word. */
236
+ export function previousWordStart(text: string, char: number): number | null {
237
+ const previous = wordRanges(text)
238
+ .toReversed()
239
+ .find((word) => word.start < char);
240
+
241
+ return previous ? previous.start : null;
242
+ }