@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,1238 @@
1
+ /**
2
+ * The annotation surface: everything an inline-commenting view shares, over a
3
+ * list of text lines it does not know how to paint. A character-precise caret
4
+ * sits in the text; click/drag marks (word mode on double-click); typing opens a
5
+ * composer anchored at the caret word or held selection; "/" opens the quick-action
6
+ * palette; enter replies, tab folds, esc dismisses; Option+Enter saves, Ctrl+Enter invokes. The plan
7
+ * thread view and the diff sheet both drive this hook and only paint their own
8
+ * rows, so marking and commenting behave identically on prose and on code.
9
+ *
10
+ * The view registers one renderable per visual line (so a drag can hit-test any
11
+ * row on screen), asks for the mark ranges to paint on a block, and slots the
12
+ * cards this hook builds under the visual line a span ends on.
13
+ */
14
+
15
+ import React, { useContext, useEffect, useEffectEvent, useRef, useState } from "react";
16
+ import type { KeyEvent, MouseEvent as TerminalMouseEvent, TextRenderable } from "@opentui/core";
17
+ import { flushSync } from "@opentui/react";
18
+ import { useSharedKeyboard } from "../keyboard/use-shared-keyboard";
19
+ import { agentInputTarget, type Annotation, type Thread } from "@cueloop/schema";
20
+ import type { Mark } from "../markdown/view-plan";
21
+ import type { QuickAction } from "../settings/config";
22
+ import type { Theme } from "../appearance/theme";
23
+ import {
24
+ comparePositions,
25
+ isDoubleClick,
26
+ nextWordStart,
27
+ orderedSpan,
28
+ positionAt,
29
+ previousWordStart,
30
+ snapSpanToWords,
31
+ spanRangeInBlock,
32
+ tightenSpan,
33
+ wordRangeAt,
34
+ type ClickStamp,
35
+ type LineGeometry,
36
+ type TextPosition,
37
+ type TextSpan,
38
+ } from "./thread-selection";
39
+ import { annotationPaletteFor, type AnnotationPalette } from "./annotation-palette";
40
+ import { printableSequence, type MarkRange, type VisualLine } from "../markdown/mark-runs";
41
+ import {
42
+ activeSlashToken,
43
+ insertSlashItem,
44
+ isStandaloneSlashQuery,
45
+ mergeSlashItems,
46
+ slashFilter,
47
+ slashItemsFrom,
48
+ } from "../keyboard/slash-palette";
49
+ import { SlashSkillsContext } from "../keyboard/skills";
50
+ import { discussionsFrom, type Discussion } from "./discussions";
51
+ import {
52
+ CommentRow,
53
+ Composer,
54
+ ComposerPalette,
55
+ DiscussionCard,
56
+ type EdgeSegment,
57
+ } from "./components/AnnotationCards";
58
+
59
+ /** The lines a surface annotates: the text per block and which blocks take a comment. */
60
+ export interface LineSource {
61
+ count: number;
62
+ textAt(blockIndex: number): string;
63
+ annotatable(blockIndex: number): boolean;
64
+ }
65
+
66
+ /** Prompt drafts cannot carry a discussion or edit target; comment drafts retain those targets. */
67
+ export type ComposeState = { blockIndex: number; seed: string } & (
68
+ | { kind: "prompt"; discussionKey: null; editAnnotationId: null; span: TextSpan }
69
+ | {
70
+ kind: "comment";
71
+ discussionKey: string | null;
72
+ editAnnotationId: string | null;
73
+ span: TextSpan | null;
74
+ }
75
+ );
76
+
77
+ /** A completed reply requests an empty continuation prompt once per reply ID. */
78
+ export interface PromptFocusRequest {
79
+ replyId: string;
80
+ blockIndex: number;
81
+ }
82
+
83
+ /** Rejected prompt text returns to the visible composer once per request. */
84
+ export interface PromptRestoreRequest {
85
+ id: number;
86
+ text: string;
87
+ }
88
+
89
+ export interface AnnotationSurfaceOptions {
90
+ source: LineSource;
91
+ session: Thread;
92
+ marks: Map<number, Mark[]>;
93
+ quickActions: QuickAction[];
94
+ tokens: Theme;
95
+ observer: boolean;
96
+ commentsEnabled?: boolean;
97
+ resolved: boolean;
98
+ suspended: boolean;
99
+ onComposingChange?: (composing: boolean) => void;
100
+ onObserverBlocked?: (reason: "observer" | "resolved") => void;
101
+ onCursorChange?: (blockIndex: number) => void;
102
+ onVerticalStep?: (
103
+ fromBlock: number,
104
+ toBlock: number,
105
+ direction: -1 | 1,
106
+ targetY?: number,
107
+ ) => boolean;
108
+ focusedAnnotationId?: string;
109
+ onFocusAnnotation?: (annotationId: string | undefined) => void;
110
+ onAnnotate: (span: TextSpan, body: string) => void;
111
+ onReply: (rootAnnotationId: string, body: string) => void;
112
+ onUpdateAnnotation: (id: string, body: string) => void;
113
+ isAnnotationReadOnly?: (id: string) => boolean;
114
+ annotationAction?: (id: string) => { label: string; run: () => void } | undefined;
115
+ requestedBlock?: { blockIndex: number };
116
+ onInvoke?: () => void;
117
+ isPromptBlock?: (blockIndex: number) => boolean;
118
+ promptFocusRequest?: PromptFocusRequest;
119
+ promptRestoreRequest?: PromptRestoreRequest;
120
+ dragViewport?: () => {
121
+ top: number;
122
+ bottom: number;
123
+ scrollBy: (rows: -1 | 1) => boolean;
124
+ } | null;
125
+ resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
126
+ onNavCommand?: (key: KeyEvent, selection: TextSpan | null) => boolean;
127
+ onExit: () => void;
128
+ }
129
+
130
+ export interface AnnotationSurface {
131
+ palette: AnnotationPalette;
132
+ discussions: Discussion[];
133
+ cursor: number;
134
+ head: TextPosition;
135
+ compose: ComposeState | null;
136
+ navMode: boolean;
137
+ focusedDiscussion: string | null;
138
+ revealBlockIndex: number;
139
+ headVisualY: () => number | undefined;
140
+ prepareViewportScroll: () => (delta: number, top: number, bottom: number) => void;
141
+ spanQuote: (span: TextSpan) => string;
142
+ registerLine: (
143
+ blockIndex: number,
144
+ lineIndex: number,
145
+ line: VisualLine,
146
+ ) => (renderable: TextRenderable | null) => void;
147
+ onLineMouseDown: (event: TerminalMouseEvent) => void;
148
+ rootMouseProps: {
149
+ onMouseDown: (event: TerminalMouseEvent) => void;
150
+ onMouseDrag: (event: TerminalMouseEvent) => void;
151
+ onMouseDragEnd: () => void;
152
+ onMouseUp: () => void;
153
+ };
154
+ rangesFor: (blockIndex: number) => MarkRange[];
155
+ cardsAfterLine: (blockIndex: number, line: VisualLine, isLastLine: boolean) => React.ReactNode[];
156
+ jumpToDiscussion: (key: string) => void;
157
+ blurSaveCompose: () => void;
158
+ }
159
+
160
+ /** The nearest block the caret can rest on, stepping from `from` in `direction`; `from` when none. */
161
+ function nearestAnnotatable(source: LineSource, from: number, direction: 1 | -1): number {
162
+ for (let index = from + direction; index >= 0 && index < source.count; index += direction) {
163
+ if (source.annotatable(index)) return index;
164
+ }
165
+
166
+ return from;
167
+ }
168
+
169
+ /** The first block the caret can rest on, so a surface never opens on a header. */
170
+ function firstAnnotatable(source: LineSource): number {
171
+ for (let index = 0; index < source.count; index++) if (source.annotatable(index)) return index;
172
+
173
+ return 0;
174
+ }
175
+
176
+ /** The visual-row direction requested by an arrow or readline-style caret key. */
177
+ function verticalCaretDelta(key: KeyEvent): -1 | 0 | 1 {
178
+ if (key.name === "up" || (key.ctrl && key.name === "p")) return -1;
179
+ if (key.name === "down" || (key.ctrl && key.name === "n")) return 1;
180
+
181
+ return 0;
182
+ }
183
+
184
+ export function useAnnotationSurface(options: AnnotationSurfaceOptions): AnnotationSurface {
185
+ const {
186
+ source,
187
+ session,
188
+ marks,
189
+ quickActions,
190
+ tokens,
191
+ observer,
192
+ commentsEnabled = true,
193
+ resolved,
194
+ suspended,
195
+ onComposingChange,
196
+ onObserverBlocked,
197
+ onCursorChange,
198
+ onVerticalStep,
199
+ focusedAnnotationId,
200
+ onFocusAnnotation,
201
+ onAnnotate,
202
+ onReply,
203
+ onUpdateAnnotation,
204
+ isAnnotationReadOnly,
205
+ annotationAction,
206
+ requestedBlock,
207
+ onInvoke,
208
+ isPromptBlock,
209
+ promptFocusRequest,
210
+ promptRestoreRequest,
211
+ dragViewport,
212
+ resolveAuthorLabel,
213
+ onNavCommand,
214
+ onExit,
215
+ } = options;
216
+ const palette = annotationPaletteFor(tokens);
217
+ const discussions = discussionsFrom(session, marks);
218
+ const [cursor, setCursor] = useState(() => firstAnnotatable(source));
219
+ // character-precise: `head` is the caret, `anchor` the other end of a held
220
+ // selection (the same position when collapsed); both are full positions, so
221
+ // the cursor block (cards, folding) can move without conjuring a selection
222
+ const [caret, setCaret] = useState<{ head: TextPosition; anchor: TextPosition }>(() => {
223
+ const start = { blockIndex: firstAnnotatable(source), char: 0 };
224
+
225
+ return { head: start, anchor: start };
226
+ });
227
+ const focusRequested = useEffectEvent(() => onFocusAnnotation?.(undefined));
228
+
229
+ useEffect(() => {
230
+ if (requestedBlock === undefined) return;
231
+ const next = { blockIndex: requestedBlock.blockIndex, char: 0 };
232
+
233
+ setCursor(requestedBlock.blockIndex);
234
+ setCaret({ head: next, anchor: next });
235
+ focusRequested();
236
+ }, [requestedBlock]);
237
+ const [compose, setCompose] = useState<ComposeState | null>(null);
238
+ const dragPointer = useRef<{ x: number; y: number } | null>(null);
239
+ const edgeScrollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
240
+ const dragViewportRef = useRef(dragViewport);
241
+
242
+ dragViewportRef.current = dragViewport;
243
+ const [navMode, setNavModeState] = useState(false);
244
+ const navModeRef = useRef(false);
245
+ const setNavMode = (value: boolean): void => {
246
+ navModeRef.current = value;
247
+ setNavModeState(value);
248
+ };
249
+
250
+ useEffect(() => {
251
+ onComposingChange?.(compose !== null);
252
+ }, [compose, onComposingChange]);
253
+ useEffect(() => {
254
+ onCursorChange?.(caret.head.blockIndex);
255
+ }, [caret.head.blockIndex, onCursorChange]);
256
+ // the lines can change under the caret (a diff loads, a file folds): land it on
257
+ // the nearest block it may rest on rather than a header or past the end
258
+ useEffect(() => {
259
+ const { blockIndex } = caret.head;
260
+
261
+ if (source.count === 0) return;
262
+ if (blockIndex < source.count && source.annotatable(blockIndex)) return;
263
+ const clamped = Math.min(blockIndex, source.count - 1);
264
+ const next = source.annotatable(clamped)
265
+ ? clamped
266
+ : nearestAnnotatable(source, clamped, 1) !== clamped
267
+ ? nearestAnnotatable(source, clamped, 1)
268
+ : nearestAnnotatable(source, clamped, -1);
269
+ const position = { blockIndex: next, char: 0 };
270
+
271
+ setCursor(next);
272
+ setCaret({ head: position, anchor: position });
273
+ // eslint-disable-next-line react-hooks/exhaustive-deps
274
+ }, [source.count]);
275
+
276
+ // discussion focus is the rail's card focus seen from the document: when the
277
+ // app owns it (onFocusAnnotation), the focused discussion is the one holding
278
+ // the focused card and focusing here names the root comment; standalone, it
279
+ // is local state
280
+ const [localFocus, setLocalFocus] = useState<string | null>(null);
281
+ const focusedDiscussion =
282
+ onFocusAnnotation === undefined
283
+ ? localFocus
284
+ : (discussions.find((discussion) =>
285
+ discussion.annotations.some((annotation) => annotation.id === focusedAnnotationId),
286
+ )?.key ?? null);
287
+ const setFocusedDiscussion = (key: string | null): void => {
288
+ if (onFocusAnnotation === undefined) return setLocalFocus(key);
289
+ onFocusAnnotation(discussions.find((discussion) => discussion.key === key)?.rootId);
290
+ };
291
+ const [folded, setFolded] = useState<Set<string>>(new Set());
292
+ const [composeText, setComposeText] = useState("");
293
+ const composeTextRef = useRef("");
294
+ const setDraft = (text: string): void => {
295
+ composeTextRef.current = text;
296
+ setComposeText(text);
297
+ };
298
+ const [caretOffset, setCaretOffset] = useState(0);
299
+ const [slashIndex, setSlashIndex] = useState(0);
300
+ const composerReady = useRef(false);
301
+ const readComposerText = useRef<(() => string) | null>(null);
302
+ const composeRef = useRef<ComposeState | null>(null);
303
+ const promptDraft = useRef("");
304
+ // every visual line registers its renderable so a drag can hit-test any
305
+ // row on screen, across blocks (rows without text resolve to the block above)
306
+ const lineRenderables = useRef(
307
+ new Map<
308
+ string,
309
+ { blockIndex: number; start: number; end: number; renderable: TextRenderable }
310
+ >(),
311
+ );
312
+ // the press anchors the gesture; the first drag samples can land before
313
+ // React has re-rendered, so the anchor travels with the gesture, not the closure
314
+ const dragging = useRef<{ wordMode: boolean; anchor: TextPosition; head: TextPosition } | null>(
315
+ null,
316
+ );
317
+ const lastClick = useRef<ClickStamp | null>(null);
318
+
319
+ const blockText = (blockIndex: number): string =>
320
+ blockIndex < source.count ? source.textAt(blockIndex) : "";
321
+ const textLengthOf = (blockIndex: number): number => blockText(blockIndex).length;
322
+ const { head } = caret;
323
+ /** The held selection in document order, off block edges; null when collapsed. */
324
+ const heldSpan = ((): TextSpan | null => {
325
+ const span = orderedSpan({ anchor: caret.anchor, head });
326
+
327
+ return span ? tightenSpan(span, textLengthOf) : null;
328
+ })();
329
+ const caretIsSelection = heldSpan !== null;
330
+ /** Agent comments require a held selection; legacy surfaces may use the caret word. */
331
+ const caretSpan = (): TextSpan | null => {
332
+ if (heldSpan) return heldSpan;
333
+ if (onInvoke) return isPromptBlock?.(head.blockIndex) ? { start: head, end: head } : null;
334
+ const word = wordRangeAt(blockText(head.blockIndex), head.char);
335
+
336
+ return word
337
+ ? {
338
+ start: { blockIndex: cursor, char: word.start },
339
+ end: { blockIndex: cursor, char: word.end },
340
+ }
341
+ : blockText(head.blockIndex).length === 0
342
+ ? { start: head, end: head }
343
+ : null;
344
+ };
345
+ const collapseCaret = (): void => setCaret({ head, anchor: head });
346
+ const spanQuote = (span: TextSpan): string => {
347
+ const parts: string[] = [];
348
+
349
+ for (let blockIndex = span.start.blockIndex; blockIndex <= span.end.blockIndex; blockIndex++) {
350
+ const range = spanRangeInBlock(span, blockIndex, textLengthOf(blockIndex));
351
+
352
+ if (range) parts.push(blockText(blockIndex).slice(range.start, range.end));
353
+ }
354
+
355
+ return parts.join(" ");
356
+ };
357
+ const allGeometry = (): LineGeometry[] =>
358
+ [...lineRenderables.current.values()].map((entry) => ({
359
+ blockIndex: entry.blockIndex,
360
+ start: entry.start,
361
+ end: entry.end,
362
+ x: entry.renderable.x,
363
+ y: entry.renderable.y,
364
+ }));
365
+ const headVisualY = (): number | undefined => {
366
+ const textLength = textLengthOf(head.blockIndex);
367
+ const cell = Math.max(0, Math.min(textLength - 1, head.char));
368
+
369
+ return allGeometry().find(
370
+ (line) =>
371
+ line.blockIndex === head.blockIndex &&
372
+ line.start <= cell &&
373
+ (cell < line.end || line.start === line.end),
374
+ )?.y;
375
+ };
376
+ const prepareViewportScroll = (): ((delta: number, top: number, bottom: number) => void) => {
377
+ const lines = allGeometry().filter((line) => source.annotatable(line.blockIndex));
378
+ const oldHeadY = headVisualY();
379
+
380
+ return (delta, top, bottom) => {
381
+ if (delta === 0 || oldHeadY === undefined || compose || focusedDiscussion || caretIsSelection)
382
+ return;
383
+ const shiftedHeadY = oldHeadY - delta;
384
+
385
+ if (shiftedHeadY >= top && shiftedHeadY <= bottom) return;
386
+ const visible = lines
387
+ .filter((line) => line.y - delta >= top && line.y - delta <= bottom)
388
+ .toSorted((left, right) => left.y - right.y || left.x - right.x);
389
+ const target = delta < 0 ? visible.at(-1) : visible[0];
390
+
391
+ if (!target) return;
392
+ const next = { blockIndex: target.blockIndex, char: target.start };
393
+
394
+ flushSync(() => {
395
+ setCaret({ head: next, anchor: next });
396
+ setCursor(next.blockIndex);
397
+ });
398
+ };
399
+ };
400
+ const verticalTextPosition = (
401
+ position: TextPosition,
402
+ direction: -1 | 1,
403
+ ): { position: TextPosition; y: number } | null => {
404
+ const textLength = textLengthOf(position.blockIndex);
405
+ const cell = Math.max(0, Math.min(textLength - 1, position.char));
406
+ const lines = allGeometry()
407
+ .filter((line) => source.annotatable(line.blockIndex))
408
+ .toSorted((left, right) => left.y - right.y || left.x - right.x);
409
+ const currentIndex = lines.findIndex(
410
+ (line) =>
411
+ line.blockIndex === position.blockIndex &&
412
+ line.start <= cell &&
413
+ (cell < line.end || line.start === line.end),
414
+ );
415
+ const target = currentIndex === -1 ? undefined : lines[currentIndex + direction];
416
+
417
+ if (!target) return null;
418
+ const current = lines[currentIndex]!;
419
+ const column = Math.max(0, position.char - current.start);
420
+
421
+ return {
422
+ position: {
423
+ blockIndex: target.blockIndex,
424
+ char: Math.min(target.end, target.start + column),
425
+ },
426
+ y: target.y,
427
+ };
428
+ };
429
+
430
+ const openCompose = (state: ComposeState): void => {
431
+ // a view-only surface (a non-diff thread's live diff) never opens a draft
432
+ if (!commentsEnabled) return;
433
+ if (observer) return onObserverBlocked?.("observer");
434
+ if (resolved) return onObserverBlocked?.("resolved");
435
+ composerReady.current = false;
436
+ readComposerText.current = null;
437
+ composeRef.current = state;
438
+ setCompose(state);
439
+ setDraft(state.seed);
440
+ setCaretOffset(state.seed.length);
441
+ setSlashIndex(0);
442
+ };
443
+ const closeCompose = (): void => {
444
+ composeRef.current = null;
445
+ readComposerText.current = null;
446
+ setCompose(null);
447
+ setDraft("");
448
+ setCaretOffset(0);
449
+ };
450
+ const skills = useContext(SlashSkillsContext);
451
+ const paletteItems = mergeSlashItems(slashItemsFrom(quickActions), skills);
452
+ // the "/word" under the caret, anywhere in the draft, so each new "/" reopens the palette
453
+ const slashToken = compose !== null ? activeSlashToken(composeText, caretOffset) : null;
454
+ const slashActive = slashToken !== null;
455
+ const slashItems = slashToken !== null ? slashFilter(paletteItems, slashToken.slice(1)) : [];
456
+
457
+ // a fresh token starts its selection at the top, so an earlier Down never leaks into it
458
+ useEffect(() => {
459
+ if (slashActive) setSlashIndex(0);
460
+ }, [slashActive]);
461
+
462
+ const saveComment = (body: string, invoke = false): void => {
463
+ // the body saves verbatim - typed newlines are the author's choice;
464
+ // trimming only decides whether the draft is empty enough to discard
465
+ const target = composeRef.current;
466
+ const prompt = target?.kind === "prompt";
467
+
468
+ if (prompt && !invoke) return;
469
+ closeCompose();
470
+ collapseCaret();
471
+ if (!target || body.trim().length === 0) return;
472
+ if (target.editAnnotationId !== null) {
473
+ onUpdateAnnotation(target.editAnnotationId, body);
474
+ if (invoke) onInvoke?.();
475
+
476
+ return;
477
+ }
478
+ if (target.discussionKey !== null) {
479
+ const discussion = discussions.find((candidate) => candidate.key === target.discussionKey);
480
+
481
+ if (discussion) {
482
+ onReply(discussion.rootId, body);
483
+ if (invoke) onInvoke?.();
484
+
485
+ return;
486
+ }
487
+ }
488
+ const span = target.span ?? caretSpan();
489
+
490
+ if (span) {
491
+ onAnnotate(span, body);
492
+ if (invoke) onInvoke?.();
493
+ }
494
+ };
495
+ /** A new discussion on the typing anchor; the card renders under the span's last block. */
496
+ const openNewCompose = (seed: string): void => {
497
+ const span = caretSpan();
498
+
499
+ if (
500
+ onInvoke &&
501
+ agentInputTarget(Boolean(isPromptBlock?.(head.blockIndex)), Boolean(heldSpan)) === "none"
502
+ )
503
+ return;
504
+ if (isPromptBlock?.(head.blockIndex) && span) {
505
+ openCompose({
506
+ kind: "prompt",
507
+ blockIndex: head.blockIndex,
508
+ discussionKey: null,
509
+ span,
510
+ seed: promptDraft.current + seed,
511
+ editAnnotationId: null,
512
+ });
513
+ } else {
514
+ openCompose({
515
+ kind: "comment",
516
+ blockIndex: span?.end.blockIndex ?? head.blockIndex,
517
+ discussionKey: null,
518
+ span,
519
+ seed,
520
+ editAnnotationId: null,
521
+ });
522
+ }
523
+ if (isPromptBlock?.(head.blockIndex)) promptDraft.current = "";
524
+ };
525
+
526
+ // clicking away commits the draft (blur-save); only a standalone "/query" is a palette
527
+ // artifact, so prose that merely ends in a "/name" still saves
528
+ const blurSaveCompose = (): void => {
529
+ const target = composeRef.current;
530
+
531
+ if (!target) return;
532
+ if (target.kind === "prompt") {
533
+ promptDraft.current = composeTextRef.current;
534
+
535
+ return closeCompose();
536
+ }
537
+ if (isStandaloneSlashQuery(composeText) || composeText.trim().length === 0) {
538
+ return closeCompose();
539
+ }
540
+ saveComment(composeText, false);
541
+ };
542
+
543
+ /** Extend the held selection to the pointer's position (word mode snaps both ends). */
544
+ const extendSelectionTo = (pointer: TextPosition): void => {
545
+ const drag = dragging.current;
546
+
547
+ if (!drag) return;
548
+ let next = { head: pointer, anchor: drag.anchor };
549
+
550
+ if (drag.wordMode) {
551
+ const span = orderedSpan({ anchor: drag.anchor, head: pointer });
552
+
553
+ if (span) {
554
+ const snapped = snapSpanToWords(span, blockText);
555
+ const forward = comparePositions(pointer, drag.anchor) >= 0;
556
+
557
+ next = forward
558
+ ? { anchor: snapped.start, head: snapped.end }
559
+ : { anchor: snapped.end, head: snapped.start };
560
+ }
561
+ }
562
+ if (comparePositions(next.head, drag.head) === 0) return;
563
+ drag.head = next.head;
564
+ setCursor(next.head.blockIndex);
565
+ setCaret({ head: next.head, anchor: next.anchor });
566
+ };
567
+
568
+ // Drag routing lives on the view root: the renderer captures the drag on
569
+ // its FIRST drag sample, to whatever is under the pointer then, so a fast
570
+ // flick off the text row would otherwise strand the gesture. Events
571
+ // bubble, so the root sees every drag and resolves it against every line
572
+ // on screen - the mark follows the pointer across blocks.
573
+ const handleRootDrag = (event: TerminalMouseEvent): void => {
574
+ if (!dragging.current) return;
575
+ dragPointer.current = { x: event.x, y: event.y };
576
+ const position = positionAt(allGeometry(), event.x, event.y);
577
+
578
+ if (position) extendSelectionTo(position);
579
+ if (edgeScrollTimer.current === null) {
580
+ edgeScrollTimer.current = setInterval(() => {
581
+ const pointer = dragPointer.current;
582
+ const viewport = dragViewportRef.current?.();
583
+
584
+ if (!dragging.current || !pointer || !viewport) return;
585
+ const direction = pointer.y <= viewport.top ? -1 : pointer.y >= viewport.bottom ? 1 : 0;
586
+
587
+ if (direction === 0 || !viewport.scrollBy(direction)) return;
588
+ const next = positionAt(allGeometry(), pointer.x, pointer.y);
589
+
590
+ if (next) extendSelectionTo(next);
591
+ }, 100);
592
+ }
593
+ };
594
+ const endDrag = (): void => {
595
+ dragging.current = null;
596
+ dragPointer.current = null;
597
+ if (edgeScrollTimer.current !== null) clearInterval(edgeScrollTimer.current);
598
+ edgeScrollTimer.current = null;
599
+ };
600
+
601
+ useEffect(() => endDrag, []);
602
+
603
+ const jumpToDiscussion = (key: string): void => {
604
+ const target = discussions.find((discussion) => discussion.key === key);
605
+
606
+ if (!target) return;
607
+ blurSaveCompose();
608
+ setCursor(target.blockIndex);
609
+ setFocusedDiscussion(key);
610
+ };
611
+
612
+ // ── keyboard, split per concern to stay within the complexity budget ──
613
+
614
+ const handleSlashKey = (key: KeyEvent, activeCompose: ComposeState): boolean => {
615
+ if (!slashActive || slashItems.length === 0) return false;
616
+ const selected = Math.min(slashIndex, slashItems.length - 1);
617
+
618
+ if (key.name === "up") {
619
+ setSlashIndex(Math.max(0, selected - 1));
620
+
621
+ return true;
622
+ }
623
+ if (key.name === "down") {
624
+ setSlashIndex(Math.min(slashItems.length - 1, selected + 1));
625
+
626
+ return true;
627
+ }
628
+ if (key.name === "return" || key.name === "tab") {
629
+ const inserted = insertSlashItem(composeText, caretOffset, slashItems[selected]!.name);
630
+
631
+ openCompose({ ...activeCompose, seed: inserted.text });
632
+ setCaretOffset(inserted.caret);
633
+
634
+ return true;
635
+ }
636
+
637
+ return false;
638
+ };
639
+
640
+ /** Pre-mount window: buffer printables, honor a fast comment save, agent invocation, or newline. */
641
+ const handlePremountKey = (key: KeyEvent, activeCompose: ComposeState): void => {
642
+ if (key.name === "return") {
643
+ if (key.super && onInvoke) return;
644
+ if (key.meta || key.ctrl || key.super)
645
+ return saveComment(activeCompose.seed, isAgentInvokeKey(key, onInvoke));
646
+ const grown = { ...activeCompose, seed: `${activeCompose.seed}\n` };
647
+
648
+ composeRef.current = grown;
649
+ setDraft(grown.seed);
650
+ setCaretOffset(grown.seed.length);
651
+
652
+ return setCompose(grown);
653
+ }
654
+ const sequence = printableSequence(key);
655
+
656
+ if (sequence) {
657
+ const grown = { ...activeCompose, seed: activeCompose.seed + sequence };
658
+
659
+ composeRef.current = grown;
660
+ setDraft(grown.seed);
661
+ setCaretOffset(grown.seed.length);
662
+ setCompose(grown);
663
+ }
664
+ };
665
+
666
+ const handleComposeKey = (key: KeyEvent, activeCompose: ComposeState): void => {
667
+ if (onInvoke && (key.name === "return" || key.name === "enter") && key.super) {
668
+ key.preventDefault();
669
+
670
+ return;
671
+ }
672
+ if (isAgentInvokeKey(key, onInvoke)) {
673
+ key.preventDefault();
674
+
675
+ return saveComment(readComposerText.current?.() ?? activeCompose.seed, true);
676
+ }
677
+ // the textarea owns every key while open; the view takes dismiss (which
678
+ // also releases the discussion focus), the slash palette, and pre-mount input
679
+ if (key.name === "escape") {
680
+ setFocusedDiscussion(null);
681
+
682
+ return closeCompose();
683
+ }
684
+ // backspace on an already empty draft undoes it: the card dissolves
685
+ // and the caret sits back on the still-held selection, ready to re-type
686
+ // (an edit of an existing comment is never deleted this way)
687
+ if (
688
+ key.name === "backspace" &&
689
+ composeTextRef.current.length === 0 &&
690
+ activeCompose.editAnnotationId === null
691
+ ) {
692
+ return closeCompose();
693
+ }
694
+ if (handleSlashKey(key, activeCompose)) return;
695
+ if (!composerReady.current) handlePremountKey(key, activeCompose);
696
+ };
697
+
698
+ /** Fold or unfold every discussion sitting on the caret's block (nav-mode z). */
699
+ const toggleFoldAtCursor = (): void => {
700
+ setFolded((current) => {
701
+ const next = new Set(current);
702
+
703
+ for (const discussion of discussions) {
704
+ if (discussion.blockIndex !== cursor) continue;
705
+ if (next.has(discussion.key)) next.delete(discussion.key);
706
+ else next.add(discussion.key);
707
+ }
708
+
709
+ return next;
710
+ });
711
+ };
712
+
713
+ /** m / cmd+[ / cmd+] / return - true when the key was a discussion primitive. */
714
+ const handleDiscussionVerb = (key: KeyEvent): boolean => {
715
+ // comment on selection: cmd+option+m (alt+m where cmd arrives ESC-prefixed)
716
+ if (key.name === "m" && (key.super || key.meta || key.option)) {
717
+ openNewCompose("");
718
+
719
+ return true;
720
+ }
721
+ if ((key.super || key.meta || key.ctrl) && (key.name === "[" || key.name === "]")) {
722
+ if (discussions.length === 0) return true;
723
+ const currentIndex = discussions.findIndex(
724
+ (discussion) => discussion.key === focusedDiscussion,
725
+ );
726
+ const nextIndex =
727
+ key.name === "]"
728
+ ? (currentIndex + 1) % discussions.length
729
+ : currentIndex <= 0
730
+ ? discussions.length - 1
731
+ : currentIndex - 1;
732
+
733
+ jumpToDiscussion(discussions[nextIndex]!.key);
734
+
735
+ return true;
736
+ }
737
+ // enter replies into the focused discussion, else the cursor block's discussion
738
+ if (key.name === "return") {
739
+ const replyTarget =
740
+ discussions.find((discussion) => discussion.key === focusedDiscussion) ??
741
+ discussions.findLast((discussion) => discussion.blockIndex === cursor);
742
+
743
+ if (replyTarget) {
744
+ openCompose({
745
+ kind: "comment",
746
+ blockIndex: replyTarget.blockIndex,
747
+ discussionKey: replyTarget.key,
748
+ span: null,
749
+ seed: "",
750
+ editAnnotationId: null,
751
+ });
752
+ }
753
+
754
+ return true;
755
+ }
756
+
757
+ return false;
758
+ };
759
+
760
+ /** Arrows step by word start; shift holds the anchor; the caret flows across blocks. */
761
+ const moveCaretHorizontal = (delta: 1 | -1, extend: boolean): void => {
762
+ const text = blockText(head.blockIndex);
763
+ const target =
764
+ delta === 1 ? nextWordStart(text, head.char) : previousWordStart(text, head.char);
765
+ const moveTo = (blockIndex: number, char: number): void => {
766
+ const next = { blockIndex, char };
767
+
768
+ setCursor(blockIndex);
769
+ setCaret({ head: next, anchor: extend ? caret.anchor : next });
770
+ };
771
+
772
+ if (target !== null) return moveTo(head.blockIndex, target);
773
+ if (extend && head.char !== (delta === 1 ? text.length : 0)) {
774
+ // the block edge first, then the next step crosses into the neighbour
775
+ return moveTo(head.blockIndex, delta === 1 ? text.length : 0);
776
+ }
777
+ // the neighbour is the next block the caret may rest on: headers are skipped
778
+ const neighbour = nearestAnnotatable(source, head.blockIndex, delta);
779
+
780
+ if (neighbour === head.blockIndex) return;
781
+ if (delta === 1) return moveTo(neighbour, 0);
782
+ const previousText = blockText(neighbour);
783
+
784
+ moveTo(
785
+ neighbour,
786
+ extend ? previousText.length : (previousWordStart(previousText, previousText.length) ?? 0),
787
+ );
788
+ };
789
+
790
+ const moveCaretVertical = (key: KeyEvent, vertical: -1 | 1): void => {
791
+ const nextBlock = nearestAnnotatable(source, cursor, vertical);
792
+ const visualTarget = verticalTextPosition(head, vertical);
793
+
794
+ if (!visualTarget && nextBlock === head.blockIndex) return;
795
+ const nextHead = visualTarget?.position ?? {
796
+ blockIndex: nextBlock,
797
+ char: key.shift ? Math.min(head.char, textLengthOf(nextBlock)) : 0,
798
+ };
799
+
800
+ if (onVerticalStep?.(head.blockIndex, nextHead.blockIndex, vertical, visualTarget?.y)) return;
801
+
802
+ setCaret({
803
+ head: nextHead,
804
+ anchor: key.shift ? caret.anchor : nextHead,
805
+ });
806
+ setFocusedDiscussion(null);
807
+ setCursor(nextHead.blockIndex);
808
+ };
809
+
810
+ const handleCaretKey = (key: KeyEvent): boolean => {
811
+ const vertical = verticalCaretDelta(key);
812
+
813
+ if (vertical !== 0) {
814
+ moveCaretVertical(key, vertical);
815
+
816
+ return true;
817
+ }
818
+ if (key.name === "right" || (key.ctrl && key.name === "l")) {
819
+ moveCaretHorizontal(1, Boolean(key.shift || key.meta || key.ctrl));
820
+
821
+ return true;
822
+ }
823
+ if (key.name === "left" || (key.ctrl && key.name === "h")) {
824
+ moveCaretHorizontal(-1, Boolean(key.shift || key.meta || key.ctrl));
825
+
826
+ return true;
827
+ }
828
+
829
+ return false;
830
+ };
831
+
832
+ /** A printable starts a comment: edit my trailing, reply, or a new discussion. */
833
+ const startTyping = (key: KeyEvent): void => {
834
+ const sequence = printableSequence(key);
835
+
836
+ if (!sequence) return;
837
+ const discussion = discussions.find((candidate) => candidate.key === focusedDiscussion);
838
+
839
+ if (discussion) {
840
+ const last = discussion.annotations.at(-1)!;
841
+ const editingOwn = last.author === undefined && !isAnnotationReadOnly?.(last.id);
842
+
843
+ return openCompose({
844
+ kind: "comment",
845
+ blockIndex: discussion.blockIndex,
846
+ discussionKey: discussion.key,
847
+ span: null,
848
+ seed: editingOwn ? `${last.body}${sequence}` : sequence,
849
+ editAnnotationId: editingOwn ? last.id : null,
850
+ });
851
+ }
852
+ openNewCompose(sequence);
853
+ };
854
+
855
+ useSharedKeyboard((key) => {
856
+ if (suspended) return;
857
+ if (key.ctrl && key.name === "q") return onExit();
858
+ const activeCompose = composeRef.current;
859
+
860
+ if (activeCompose) return handleComposeKey(key, activeCompose);
861
+ if (isAgentInvokeKey(key, onInvoke)) {
862
+ key.preventDefault();
863
+
864
+ return onInvoke?.();
865
+ }
866
+ if (key.name === "escape") {
867
+ // from type mode esc only enters nav, so a held mark survives for `c`
868
+ if (!navModeRef.current) return setNavMode(true);
869
+ if (focusedDiscussion !== null) setFocusedDiscussion(null);
870
+
871
+ return collapseCaret();
872
+ }
873
+ if (navModeRef.current) {
874
+ if (handleCaretKey(key)) return;
875
+ if (key.name === "z") return toggleFoldAtCursor();
876
+ if (onNavCommand?.(key, heldSpan)) return;
877
+ if (key.name === "c") {
878
+ setNavMode(false);
879
+
880
+ return openNewCompose("");
881
+ }
882
+ if (printableSequence(key)) {
883
+ setNavMode(false);
884
+ startTyping(key);
885
+ }
886
+
887
+ return;
888
+ }
889
+ if (handleDiscussionVerb(key)) return;
890
+ if (handleCaretKey(key)) return;
891
+ startTyping(key);
892
+ });
893
+
894
+ // ── the cards the view slots under its lines ──
895
+
896
+ // one composer element (keyed by seed so slash acceptance remounts it);
897
+ // the slash palette renders BELOW the whole card, not inside it
898
+ const composerNode = compose ? (
899
+ <Composer
900
+ key={compose.seed}
901
+ seed={compose.seed}
902
+ glyph={compose.kind === "prompt" ? null : "●"}
903
+ tokens={tokens}
904
+ onSave={saveComment}
905
+ agentEnabled={Boolean(onInvoke)}
906
+ onReady={(readText) => {
907
+ readComposerText.current = readText;
908
+ composerReady.current = true;
909
+ }}
910
+ onInput={(text, caret) => {
911
+ setDraft(text);
912
+ setCaretOffset(caret);
913
+ }}
914
+ />
915
+ ) : null;
916
+ // the palette list below the composer while the caret is on a "/word"; null otherwise, so it pushes
917
+ const paletteNode = (
918
+ <ComposerPalette
919
+ key="composer-palette"
920
+ slashActive={slashActive}
921
+ slashItems={slashItems}
922
+ slashIndex={slashIndex}
923
+ tokens={tokens}
924
+ />
925
+ );
926
+
927
+ const foldedSummaryFor = (discussion: Discussion): React.ReactNode => (
928
+ <box key={discussion.key} style={{ flexDirection: "row", marginTop: 1, marginLeft: 2 }}>
929
+ <text fg={tokens.textDim}>
930
+ {`○ ${discussion.annotations.length} comment${discussion.annotations.length === 1 ? "" : "s"} ›`}
931
+ </text>
932
+ </box>
933
+ );
934
+
935
+ const discussionCardFor = (discussion: Discussion, composingHere: boolean): React.ReactNode => {
936
+ const segments: EdgeSegment[] = discussion.annotations.map((annotation) => {
937
+ const editingHere =
938
+ composingHere && compose?.editAnnotationId === annotation.id && composerNode !== null;
939
+
940
+ return {
941
+ color: annotation.author === undefined ? palette.cardEdge : tokens.text,
942
+ node: editingHere ? (
943
+ composerNode
944
+ ) : (
945
+ <CommentRow
946
+ key={annotation.id}
947
+ annotation={annotation}
948
+ tokens={tokens}
949
+ authorLabel={resolveAuthorLabel?.(annotation)}
950
+ action={annotationAction?.(annotation.id)}
951
+ />
952
+ ),
953
+ };
954
+ });
955
+
956
+ if (composingHere && compose?.editAnnotationId === null && composerNode) {
957
+ segments.push({ color: palette.cardEdge, node: composerNode });
958
+ }
959
+
960
+ return (
961
+ <DiscussionCard
962
+ key={discussion.key}
963
+ segments={segments}
964
+ tokens={tokens}
965
+ focused={focusedDiscussion === discussion.key}
966
+ onFocus={() => {
967
+ if (composeRef.current?.discussionKey === discussion.key) return;
968
+ blurSaveCompose();
969
+ setFocusedDiscussion(discussion.key);
970
+ setCursor(discussion.blockIndex);
971
+ }}
972
+ />
973
+ );
974
+ };
975
+
976
+ const rangesFor = (blockIndex: number): MarkRange[] => {
977
+ const textLength = textLengthOf(blockIndex);
978
+ const ranges: MarkRange[] = [];
979
+ const pushSpan = (span: TextSpan | null): void => {
980
+ const range = span ? spanRangeInBlock(span, blockIndex, textLength) : null;
981
+
982
+ if (range) ranges.push(range);
983
+ };
984
+
985
+ for (const discussion of discussions) pushSpan(discussion.span);
986
+ if (compose) pushSpan(compose.span);
987
+ if (caretIsSelection && !compose) pushSpan(heldSpan);
988
+ // the idle caret cell sits at the head, also at the end of a held mark;
989
+ // an open card takes the cursor with it, so no cell is painted then
990
+ if (blockIndex === head.blockIndex && !compose && textLength > 0) {
991
+ const cell = Math.max(0, Math.min(textLength - 1, head.char));
992
+
993
+ ranges.push({ start: cell, end: cell + 1, caretOnly: true });
994
+ }
995
+
996
+ return ranges;
997
+ };
998
+
999
+ const cardsAfterLine = (
1000
+ blockIndex: number,
1001
+ line: VisualLine,
1002
+ isLastLine: boolean,
1003
+ ): React.ReactNode[] => {
1004
+ const endsInLine = (end: number): boolean =>
1005
+ (end === 0 && line.start === 0) || (end - 1 >= line.start && end - 1 < line.end);
1006
+ const nodes: React.ReactNode[] = [];
1007
+ const composeHere = compose && compose.blockIndex === blockIndex;
1008
+ const newComposeHere = Boolean(composeHere && compose.discussionKey === null && composerNode);
1009
+ const composeAnchoredHere =
1010
+ newComposeHere && (compose!.span ? endsInLine(compose!.span.end.char) : isLastLine);
1011
+ const composeStart = compose?.span?.start;
1012
+
1013
+ const pushComposeCard = (): void => {
1014
+ if (compose?.kind === "prompt") {
1015
+ nodes.push(
1016
+ <box key="compose-prompt" style={{ paddingLeft: 2 }}>
1017
+ {composerNode}
1018
+ </box>,
1019
+ );
1020
+ nodes.push(paletteNode);
1021
+
1022
+ return;
1023
+ }
1024
+ nodes.push(
1025
+ <DiscussionCard
1026
+ key="compose-new"
1027
+ tokens={tokens}
1028
+ segments={[{ color: palette.cardEdge, node: composerNode }]}
1029
+ />,
1030
+ );
1031
+ nodes.push(paletteNode);
1032
+ };
1033
+
1034
+ // the draft sorts in by its span start, the same order discussionsFrom lands it on save, so a
1035
+ // new comment sits in its final position while typing instead of appending to the stack
1036
+ let composePending = composeAnchoredHere;
1037
+
1038
+ for (const discussion of discussions) {
1039
+ if (discussion.blockIndex !== blockIndex || !endsInLine(discussion.span.end.char)) continue;
1040
+ if (
1041
+ composePending &&
1042
+ composeStart &&
1043
+ comparePositions(composeStart, discussion.span.start) < 0
1044
+ ) {
1045
+ pushComposeCard();
1046
+ composePending = false;
1047
+ }
1048
+ const composingHere = Boolean(composeHere && compose.discussionKey === discussion.key);
1049
+
1050
+ if (folded.has(discussion.key) && !composingHere) {
1051
+ nodes.push(foldedSummaryFor(discussion));
1052
+ continue;
1053
+ }
1054
+ nodes.push(discussionCardFor(discussion, composingHere));
1055
+ if (composingHere) nodes.push(paletteNode);
1056
+ }
1057
+ if (composePending) pushComposeCard();
1058
+
1059
+ return nodes;
1060
+ };
1061
+
1062
+ const registerLine =
1063
+ (blockIndex: number, lineIndex: number, line: VisualLine) =>
1064
+ (renderable: TextRenderable | null): void => {
1065
+ const key = `${blockIndex}:${lineIndex}`;
1066
+
1067
+ if (renderable) {
1068
+ lineRenderables.current.set(key, {
1069
+ blockIndex,
1070
+ start: line.start,
1071
+ end: line.end,
1072
+ renderable,
1073
+ });
1074
+ } else lineRenderables.current.delete(key);
1075
+ };
1076
+
1077
+ const focusPrompt = (blockIndex: number): void => {
1078
+ const position = { blockIndex, char: 0 };
1079
+
1080
+ setNavMode(false);
1081
+ setFocusedDiscussion(null);
1082
+ setCursor(blockIndex);
1083
+ setCaret({ head: position, anchor: position });
1084
+ openCompose({
1085
+ kind: "prompt",
1086
+ blockIndex,
1087
+ discussionKey: null,
1088
+ span: { start: position, end: position },
1089
+ seed: promptDraft.current,
1090
+ editAnnotationId: null,
1091
+ });
1092
+ promptDraft.current = "";
1093
+ };
1094
+ const movePromptDraft = useEffectEvent(() => {
1095
+ const active = composeRef.current;
1096
+ const blockIndex = source.count - 1;
1097
+
1098
+ if (
1099
+ active?.kind !== "prompt" ||
1100
+ !isPromptBlock?.(blockIndex) ||
1101
+ active.blockIndex === blockIndex
1102
+ )
1103
+ return;
1104
+ const position = { blockIndex, char: 0 };
1105
+
1106
+ openCompose({
1107
+ ...active,
1108
+ blockIndex,
1109
+ span: { start: position, end: position },
1110
+ seed: composeTextRef.current,
1111
+ });
1112
+ setCursor(blockIndex);
1113
+ setCaret({ head: position, anchor: position });
1114
+ });
1115
+
1116
+ useEffect(() => {
1117
+ movePromptDraft();
1118
+ }, [source.count]);
1119
+ const restoredPrompt = useRef<number | null>(null);
1120
+ const restorePrompt = useEffectEvent(() => {
1121
+ if (!promptRestoreRequest || suspended || !onInvoke) return;
1122
+ if (restoredPrompt.current === promptRestoreRequest.id) return;
1123
+ restoredPrompt.current = promptRestoreRequest.id;
1124
+ const active = composeRef.current;
1125
+ const newerDraft = active?.kind === "prompt" ? composeTextRef.current : promptDraft.current;
1126
+
1127
+ if (active?.kind !== "prompt") blurSaveCompose();
1128
+ promptDraft.current = [promptRestoreRequest.text, newerDraft].filter(Boolean).join("\n");
1129
+ focusPrompt(source.count - 1);
1130
+ });
1131
+
1132
+ useEffect(() => {
1133
+ restorePrompt();
1134
+ }, [promptRestoreRequest?.id, suspended]);
1135
+ const focusedReply = useRef<string | null>(null);
1136
+ const continueConversation = useEffectEvent(() => {
1137
+ if (!promptFocusRequest || suspended || !onInvoke) return;
1138
+ if (focusedReply.current === promptFocusRequest.replyId) return;
1139
+ focusedReply.current = promptFocusRequest.replyId;
1140
+ // An arriving answer cannot replace a draft or a marked passage under review.
1141
+ if (composeRef.current || heldSpan || dragging.current || promptDraft.current) return;
1142
+ focusPrompt(promptFocusRequest.blockIndex);
1143
+ });
1144
+
1145
+ useEffect(() => {
1146
+ continueConversation();
1147
+ }, [promptFocusRequest?.replyId, promptFocusRequest?.blockIndex, suspended]);
1148
+ const onPromptMouseDown = (event: TerminalMouseEvent): void => {
1149
+ if (!onInvoke || suspended) return;
1150
+ const blockIndex = source.count - 1;
1151
+ const geometry = allGeometry();
1152
+ const promptLine = geometry.find((entry) => isPromptBlock?.(entry.blockIndex));
1153
+ const lastReplyLine = geometry
1154
+ .filter((entry) => entry.blockIndex === blockIndex - 1)
1155
+ .sort((left, right) => right.y - left.y)[0];
1156
+ const promptStart = promptLine ? promptLine.y - 1 : lastReplyLine && lastReplyLine.y + 1;
1157
+ const viewport = dragViewport?.();
1158
+
1159
+ if (
1160
+ !isPromptBlock?.(blockIndex) ||
1161
+ promptStart === undefined ||
1162
+ event.y < promptStart ||
1163
+ (viewport && event.y >= viewport.bottom)
1164
+ )
1165
+ return;
1166
+ if (composeRef.current?.kind === "prompt") {
1167
+ promptDraft.current = composeTextRef.current;
1168
+ } else blurSaveCompose();
1169
+ endDrag();
1170
+ focusPrompt(blockIndex);
1171
+ };
1172
+
1173
+ const onLineMouseDown = (event: TerminalMouseEvent): void => {
1174
+ blurSaveCompose();
1175
+ const pressed = positionAt(allGeometry(), event.x, event.y);
1176
+
1177
+ if (!pressed || !source.annotatable(pressed.blockIndex)) return;
1178
+ setNavMode(false);
1179
+ const stamp = { time: Date.now(), x: event.x, y: event.y };
1180
+ const wordMode = isDoubleClick(lastClick.current, stamp);
1181
+
1182
+ lastClick.current = stamp;
1183
+ const word = wordMode ? wordRangeAt(blockText(pressed.blockIndex), pressed.char) : null;
1184
+ const anchor = word ? { blockIndex: pressed.blockIndex, char: word.start } : pressed;
1185
+ const pressHead = word ? { blockIndex: pressed.blockIndex, char: word.end } : pressed;
1186
+
1187
+ dragging.current = { wordMode, anchor, head: pressHead };
1188
+ setFocusedDiscussion(null);
1189
+ setCursor(pressed.blockIndex);
1190
+ setCaret({ head: pressHead, anchor });
1191
+ };
1192
+
1193
+ // an opening card shifts the layout, so the block it belongs to is revealed
1194
+ // again; a discussion focused from the rail is scrolled into view the same way
1195
+ const revealBlockIndex =
1196
+ compose?.blockIndex ??
1197
+ discussions.find((discussion) => discussion.key === focusedDiscussion)?.blockIndex ??
1198
+ cursor;
1199
+
1200
+ return {
1201
+ palette,
1202
+ discussions,
1203
+ cursor,
1204
+ head,
1205
+ compose,
1206
+ navMode,
1207
+ focusedDiscussion,
1208
+ revealBlockIndex,
1209
+ headVisualY,
1210
+ prepareViewportScroll,
1211
+ spanQuote,
1212
+ registerLine,
1213
+ onLineMouseDown,
1214
+ rootMouseProps: {
1215
+ onMouseDown: onPromptMouseDown,
1216
+ onMouseDrag: handleRootDrag,
1217
+ onMouseDragEnd: endDrag,
1218
+ onMouseUp: endDrag,
1219
+ },
1220
+ rangesFor,
1221
+ cardsAfterLine,
1222
+ jumpToDiscussion,
1223
+ blurSaveCompose,
1224
+ };
1225
+ }
1226
+
1227
+ function isAgentInvokeKey(
1228
+ key: { name: string; ctrl?: boolean; meta?: boolean; super?: boolean },
1229
+ onInvoke?: () => void,
1230
+ ): boolean {
1231
+ return Boolean(
1232
+ onInvoke &&
1233
+ (key.name === "return" || key.name === "enter") &&
1234
+ key.ctrl &&
1235
+ !key.meta &&
1236
+ !key.super,
1237
+ );
1238
+ }