@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,560 @@
1
+ /**
2
+ * The thread view: the plan surface where discussions live inline in the
3
+ * document instead of the rail. The caret, marks, composer, palette, and cards
4
+ * come from the shared annotation surface (the diff sheet drives the same hook);
5
+ * this view paints the plan's blocks - headings, lists, quotes, tracked changes -
6
+ * and the right-edge scroll markers that navigate between discussions.
7
+ *
8
+ * Grammar: arrows move (caret flows across blocks) · shift+arrows select ·
9
+ * click/drag mark · typing comments (or edits my trailing comment / replies
10
+ * in a focused discussion) · "/" opens the quick-action palette · cmd+option+m
11
+ * comments on selection · cmd+enter sends, enter breaks the line · esc
12
+ * dismisses · tab folds · cmd+[ / cmd+] cycle discussions · blur-save on click.
13
+ */
14
+
15
+ import React, { useEffect, useRef, useState } from "react";
16
+ import { useTerminalDimensions } from "@opentui/react";
17
+ import type { KeyEvent, ScrollBoxRenderable } from "@opentui/core";
18
+ import type { Annotation, Thread } from "@cueloop/schema";
19
+ import {
20
+ displayText,
21
+ renderedText,
22
+ renderedStyleRuns,
23
+ styledRunsFor,
24
+ type DisplayBlock,
25
+ type Mark,
26
+ type RenderedRun,
27
+ type RunRole,
28
+ type StyledRun,
29
+ } from "../view-plan";
30
+ import type { TextSpan } from "../../annotations/thread-selection";
31
+ import type { QuickAction } from "../../settings/config";
32
+ import type { CheatsheetSection } from "../../keyboard/key-bindings";
33
+ import type { Theme } from "../../appearance/theme";
34
+ import { BOLD, CUT, ITALIC, UNDERLINE } from "../../annotations/annotation-palette";
35
+ import { wrapLines, type MarkRange, type VisualLine } from "../mark-runs";
36
+ import { MarkdownGridBlock } from "./MarkdownGridBlock";
37
+ import { useFrameMeasure } from "../../ui/use-frame-measure";
38
+ import { scrollBoxDragViewport, useTerminalVirtualizer } from "../../ui/use-terminal-virtualizer";
39
+ import {
40
+ useAnnotationSurface,
41
+ type LineSource,
42
+ type PromptFocusRequest,
43
+ type PromptRestoreRequest,
44
+ } from "../../annotations/use-annotation-surface";
45
+ import { NavModeHint } from "../../keyboard/components/NavModeHint";
46
+ import { DiscussionMarkerRail } from "../../annotations/components/DiscussionMarkerRail";
47
+ import { useComponentTheme } from "../../appearance/components/theme-context";
48
+ import { colorForSyntaxGroup } from "../../ui/components/syntax-highlight";
49
+ import { highlightThreadCodeBlocks, type SyntaxSpan } from "../thread-code-syntax";
50
+
51
+ /** Blocks kept mounted beyond the viewport, so a scroll step or a drag past the edge never shows a gap. */
52
+ const OVERSCAN_BLOCKS = 8;
53
+
54
+ export { lighten } from "../../annotations/annotation-palette";
55
+
56
+ interface CodeSyntaxState {
57
+ display: DisplayBlock[];
58
+ byBlock: Map<number, SyntaxSpan[]>;
59
+ }
60
+
61
+ function syntaxSpansForBlock(
62
+ block: DisplayBlock,
63
+ blockIndex: number,
64
+ display: DisplayBlock[],
65
+ codeSyntax: CodeSyntaxState | null,
66
+ ): readonly SyntaxSpan[] {
67
+ if (block.type === "del" || codeSyntax?.display !== display) return [];
68
+
69
+ return codeSyntax.byBlock.get(blockIndex) ?? [];
70
+ }
71
+
72
+ /**
73
+ * How a block's rows are painted: heading weight, muted kinds, and the list or
74
+ * quote marker. A cut block reads dim and struck through; other edits are
75
+ * tracked into the working copy but not tagged in the read-only view.
76
+ */
77
+ interface BlockStyle {
78
+ baseFg: string;
79
+ baseAttributes: number;
80
+ marker: string;
81
+ }
82
+
83
+ function blockStyle(block: DisplayBlock, tokens: Theme): BlockStyle {
84
+ const isHeading = block.kind === "h1" || block.kind === "h2" || block.kind === "h3";
85
+ const isCut = block.type === "del";
86
+ const muted = block.kind === "h2" || block.kind === "h3" || block.kind === "code";
87
+ const marker =
88
+ block.kind === "li"
89
+ ? "· "
90
+ : block.kind === "oli"
91
+ ? `${block.orderedItemNumber ?? 1}. `
92
+ : block.kind === "quote"
93
+ ? "▏ "
94
+ : "";
95
+
96
+ return {
97
+ baseFg: isCut ? tokens.textDim : muted ? tokens.textMuted : tokens.text,
98
+ baseAttributes: (isHeading ? BOLD : 0) | (isCut ? CUT : 0),
99
+ marker,
100
+ };
101
+ }
102
+
103
+ /** The read-only color of an inline markdown role: links accent, code and removals dim, insertions green. */
104
+ function roleForeground(role: RunRole, baseFg: string, tokens: Theme): string {
105
+ if (role === "link") return tokens.blue;
106
+ if (role === "ins") return tokens.green;
107
+ if (role === "code" || role === "del") return tokens.textDim;
108
+
109
+ return baseFg;
110
+ }
111
+
112
+ /** The text attributes an inline role adds over a block's base attributes. */
113
+ function roleAttributes(role: RunRole, baseAttributes: number): number {
114
+ if (role === "strong") return baseAttributes | BOLD;
115
+ if (role === "em") return baseAttributes | ITALIC;
116
+ if (role === "strike" || role === "del") return baseAttributes | CUT;
117
+
118
+ return baseAttributes;
119
+ }
120
+
121
+ function isGridKind(kind: DisplayBlock["kind"]): boolean {
122
+ return kind === "table" || kind === "frontmatter";
123
+ }
124
+
125
+ /* ------------------------------------------------------------------ view */
126
+
127
+ /** The grammar as the keybinds dialog lists it; the view owns these keys, so they are not rebindable. */
128
+ export const THREAD_VIEW_CHEATSHEET: CheatsheetSection[] = [
129
+ {
130
+ title: "Thread (type)",
131
+ entries: [
132
+ { keys: "click", label: "place the caret" },
133
+ { keys: "drag", label: "mark text, across blocks" },
134
+ { keys: "dbl-click", label: "mark the word" },
135
+ { keys: "← / →", label: "move by word" },
136
+ { keys: "⇧← / ⇧→", label: "hold a mark" },
137
+ { keys: "↑ / ↓", label: "move by block" },
138
+ { keys: "type", label: "comment on the mark" },
139
+ { keys: "enter", label: "reply to the comment" },
140
+ { keys: "esc", label: "nav mode" },
141
+ { keys: "tab", label: "cycle panes" },
142
+ { keys: "⌃q", label: "quit" },
143
+ ],
144
+ },
145
+ {
146
+ title: "Comment",
147
+ entries: [
148
+ { keys: "⌘enter", label: "send (⌃enter as well)" },
149
+ { keys: "enter", label: "new line" },
150
+ { keys: "backspace", label: "dismiss an empty draft" },
151
+ { keys: "/", label: "actions" },
152
+ { keys: "click away", label: "save" },
153
+ ],
154
+ },
155
+ ];
156
+
157
+ export interface ThreadViewProps {
158
+ canAnnotateBlock?: (blockIndex: number) => boolean;
159
+ isAnnotationReadOnly?: (id: string) => boolean;
160
+ annotationAction?: (id: string) => { label: string; run: () => void } | undefined;
161
+ requestedBlock?: { blockIndex: number };
162
+ onInvoke?: () => void;
163
+ isPromptBlock?: (blockIndex: number) => boolean;
164
+ promptFocusRequest?: PromptFocusRequest;
165
+ promptRestoreRequest?: PromptRestoreRequest;
166
+ renderBlock?: (index: number) => React.ReactNode | undefined;
167
+ session: Thread;
168
+ display: DisplayBlock[];
169
+ marks: Map<number, Mark[]>;
170
+ quickActions: QuickAction[];
171
+ observer: boolean;
172
+ suspended?: boolean;
173
+ editOrphanCount?: number;
174
+ onComposingChange?: (composing: boolean) => void;
175
+ resolved?: boolean;
176
+ onObserverBlocked?: (reason: "observer" | "resolved") => void;
177
+ onCursorChange?: (blockIndex: number) => void;
178
+ focusedAnnotationId?: string;
179
+ onFocusAnnotation?: (annotationId: string | undefined) => void;
180
+ onAnnotate: (span: TextSpan, body: string) => void;
181
+ onReply: (rootAnnotationId: string, body: string) => void;
182
+ onUpdateAnnotation: (id: string, body: string) => void;
183
+ resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
184
+ onNavCommand?: (key: KeyEvent, selection: TextSpan | null) => boolean;
185
+ onExit: () => void;
186
+ theme?: Theme;
187
+ }
188
+
189
+ export function ThreadView({
190
+ session,
191
+ display,
192
+ marks,
193
+ quickActions,
194
+ observer,
195
+ resolved = false,
196
+ suspended = false,
197
+ editOrphanCount = 0,
198
+ onComposingChange,
199
+ onObserverBlocked,
200
+ onCursorChange,
201
+ focusedAnnotationId,
202
+ onFocusAnnotation,
203
+ onAnnotate,
204
+ onReply,
205
+ onUpdateAnnotation,
206
+ resolveAuthorLabel,
207
+ onNavCommand,
208
+ onExit,
209
+ theme,
210
+ canAnnotateBlock,
211
+ isAnnotationReadOnly,
212
+ annotationAction,
213
+ requestedBlock,
214
+ onInvoke,
215
+ isPromptBlock,
216
+ promptFocusRequest,
217
+ promptRestoreRequest,
218
+ renderBlock,
219
+ }: ThreadViewProps): React.ReactNode {
220
+ const tokens = useComponentTheme(theme);
221
+ const source: LineSource = {
222
+ count: display.length,
223
+ // the surface hit-tests and paints in rendered text (inline markers concealed); a grid anchors on its raw source
224
+ textAt: (blockIndex) => renderedText(display[blockIndex]!),
225
+ annotatable: canAnnotateBlock ?? (() => true),
226
+ };
227
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null);
228
+ const surface = useAnnotationSurface({
229
+ source,
230
+ session,
231
+ marks,
232
+ quickActions,
233
+ tokens,
234
+ observer,
235
+ resolved,
236
+ suspended,
237
+ onComposingChange,
238
+ onObserverBlocked,
239
+ onCursorChange,
240
+ focusedAnnotationId,
241
+ onFocusAnnotation,
242
+ onAnnotate,
243
+ onReply,
244
+ onUpdateAnnotation,
245
+ isAnnotationReadOnly,
246
+ annotationAction,
247
+ requestedBlock,
248
+ onInvoke,
249
+ isPromptBlock,
250
+ promptFocusRequest,
251
+ promptRestoreRequest,
252
+ dragViewport: () => scrollBoxDragViewport(scrollRef.current),
253
+ resolveAuthorLabel,
254
+ onNavCommand,
255
+ onExit,
256
+ });
257
+ const { palette, discussions } = surface;
258
+ const { height: terminalHeight } = useTerminalDimensions();
259
+ const viewWidth = useFrameMeasure(
260
+ () => scrollRef.current?.content?.width ?? 0,
261
+ (left, right) => left === right,
262
+ 0,
263
+ );
264
+ const [codeSyntax, setCodeSyntax] = useState<CodeSyntaxState | null>(null);
265
+
266
+ useEffect(() => {
267
+ let active = true;
268
+
269
+ void highlightThreadCodeBlocks(display).then(
270
+ (byBlock) => {
271
+ if (active) setCodeSyntax({ display, byBlock });
272
+ },
273
+ (error) => {
274
+ if (active) console.error("thread code syntax highlighting failed", error);
275
+ },
276
+ );
277
+
278
+ return () => {
279
+ active = false;
280
+ };
281
+ }, [display]);
282
+
283
+ // only the viewport's blocks (plus an overscan) mount; a block is estimated at its wrapped-line
284
+ // count until its box, cards included, is measured. The initial viewport seeds the first render so a
285
+ // freshly mounted thread (a switch from a diff, another project) windows its first paint rather than
286
+ // building every block, and never flashes blank.
287
+ const estimateBlockRows = (index: number): number => {
288
+ const block = display[index];
289
+
290
+ if (!block) return 1;
291
+ const usable = viewWidth > 0 ? Math.max(1, viewWidth - 6) : 40;
292
+ const lines = Math.max(1, Math.ceil(displayText(block).length / usable));
293
+
294
+ return lines + (index === 0 ? 0 : 1);
295
+ };
296
+ const virtual = useTerminalVirtualizer({
297
+ scrollbox: scrollRef,
298
+ count: display.length,
299
+ estimateSize: estimateBlockRows,
300
+ overscan: OVERSCAN_BLOCKS,
301
+ });
302
+
303
+ useEffect(() => {
304
+ virtual.scrollToIndex(surface.revealBlockIndex, surface.compose ? "end" : "auto");
305
+ // eslint-disable-next-line react-hooks/exhaustive-deps
306
+ }, [surface.revealBlockIndex, surface.compose]);
307
+
308
+ interface LineContext {
309
+ blockIndex: number;
310
+ roleRuns: RenderedRun[];
311
+ line: VisualLine;
312
+ lineIndex: number;
313
+ ranges: MarkRange[];
314
+ marker: string;
315
+ baseFg: string;
316
+ baseAttributes: number;
317
+ syntaxSpans: readonly SyntaxSpan[];
318
+ }
319
+
320
+ const paintedSpan = (
321
+ run: StyledRun,
322
+ runIndex: number,
323
+ baseFg: string,
324
+ baseAttributes: number,
325
+ ) => (
326
+ <span
327
+ key={runIndex}
328
+ fg={
329
+ run.role === "plain" && run.syntaxGroup
330
+ ? (colorForSyntaxGroup(run.syntaxGroup, tokens) ?? baseFg)
331
+ : roleForeground(run.role, baseFg, tokens)
332
+ }
333
+ bg={run.caretOnly ? palette.caretCell : run.marked ? palette.markBackdrop : undefined}
334
+ attributes={roleAttributes(run.role, baseAttributes) | (run.marked ? UNDERLINE : 0)}
335
+ >
336
+ {run.text}
337
+ </span>
338
+ );
339
+
340
+ const lineRowFor = (context: LineContext, hasComposer = false): React.ReactNode => {
341
+ const {
342
+ blockIndex,
343
+ roleRuns,
344
+ line,
345
+ lineIndex,
346
+ ranges,
347
+ marker,
348
+ baseFg,
349
+ baseAttributes,
350
+ syntaxSpans,
351
+ } = context;
352
+
353
+ // The prompt composer occupies its blank line instead of adding a row below it.
354
+ if (isPromptBlock?.(blockIndex) && hasComposer) return null;
355
+
356
+ return (
357
+ <box key={`line-${lineIndex}`} style={{ flexDirection: "row" }}>
358
+ <text selectable={false}>
359
+ <span fg={tokens.textDim}>{" "}</span>
360
+ <span fg={tokens.textDim}>{lineIndex === 0 ? marker : " ".repeat(marker.length)}</span>
361
+ </text>
362
+ <text
363
+ selectable={false}
364
+ style={{ flexGrow: 1, flexShrink: 1 }}
365
+ ref={surface.registerLine(blockIndex, lineIndex, line)}
366
+ onMouseDown={surface.onLineMouseDown}
367
+ >
368
+ {styledRunsFor(roleRuns, line, ranges, syntaxSpans).map((run, runIndex) =>
369
+ paintedSpan(run, runIndex, baseFg, baseAttributes),
370
+ )}
371
+ </text>
372
+ </box>
373
+ );
374
+ };
375
+
376
+ // an h1 or h2 sits over a dim rule; h3 gets weight only. The row is
377
+ // always present for those kinds (only its width tracks the measured viewport) so a late width
378
+ // measurement never adds a row and shifts the blocks below it out from under a pending click
379
+ const headingRule = (block: DisplayBlock): React.ReactNode => {
380
+ if (block.kind !== "h1" && block.kind !== "h2") return null;
381
+ const width = Math.max(1, (viewWidth > 6 ? viewWidth : 46) - 6);
382
+
383
+ return (
384
+ <box key="heading-rule" style={{ flexDirection: "row" }}>
385
+ <text selectable={false} fg={tokens.textDim}>
386
+ {" " + "─".repeat(width)}
387
+ </text>
388
+ </box>
389
+ );
390
+ };
391
+
392
+ const blockNodeFor = (blockIndex: number): React.ReactNode => {
393
+ const custom = renderBlock?.(blockIndex);
394
+
395
+ if (custom !== undefined)
396
+ return (
397
+ <box key={`custom-${blockIndex}`} ref={virtual.measureRef(blockIndex)}>
398
+ {custom}
399
+ </box>
400
+ );
401
+ const block = display[blockIndex]!;
402
+ const { baseFg, baseAttributes, marker } = blockStyle(block, tokens);
403
+ // list items of one list stay tight; every other block sits a blank row below its neighbour
404
+ const previous = display[blockIndex - 1];
405
+ const tight =
406
+ previous !== undefined &&
407
+ block.kind === previous.kind &&
408
+ (block.kind === "li" || block.kind === "oli");
409
+ // the leading gap rides inside the measured box (not marginTop, which sits outside it) so the
410
+ // virtualizer's spacer math counts it
411
+ const leadingGap =
412
+ blockIndex === 0 || tight ? null : <box key="lead-gap" style={{ height: 1 }} />;
413
+ const measuredBox = (children: React.ReactNode): React.ReactNode => (
414
+ <box
415
+ key={`discussion-block-${blockIndex}`}
416
+ id={`discussion-block-${blockIndex}`}
417
+ ref={virtual.measureRef(blockIndex)}
418
+ style={{ flexDirection: "column" }}
419
+ >
420
+ {leadingGap}
421
+ {children}
422
+ </box>
423
+ );
424
+
425
+ if (isGridKind(block.kind)) {
426
+ // a grid annotates as one unit: every row hit-tests to the same block span, a mark tints the grid,
427
+ // and the block's discussion cards slot under it, so annotations resolving into a grid stay visible
428
+ const gridLine: VisualLine = { start: 0, end: renderedText(block).length };
429
+ const marked = surface.rangesFor(blockIndex).some((range) => !range.caretOnly);
430
+
431
+ return measuredBox(
432
+ <>
433
+ <MarkdownGridBlock
434
+ block={block}
435
+ theme={tokens}
436
+ contentWidth={viewWidth > 0 ? viewWidth - 6 : 40}
437
+ marked={marked}
438
+ markBackdrop={palette.markBackdrop}
439
+ registerRow={(rowIndex) => surface.registerLine(blockIndex, rowIndex, gridLine)}
440
+ onRowMouseDown={surface.onLineMouseDown}
441
+ />
442
+ {surface.cardsAfterLine(blockIndex, gridLine, true)}
443
+ </>,
444
+ );
445
+ }
446
+
447
+ const roleRuns = renderedStyleRuns(block);
448
+ const ranges = surface.rangesFor(blockIndex);
449
+ const lines = wrapLines(renderedText(block), viewWidth > 0 ? viewWidth - marker.length - 6 : 0);
450
+ const syntaxSpans = syntaxSpansForBlock(block, blockIndex, display, codeSyntax);
451
+ const lineRows: React.ReactNode[] = [];
452
+
453
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
454
+ const line = lines[lineIndex]!;
455
+ const isLastLine = lineIndex === lines.length - 1;
456
+
457
+ const cards = surface.cardsAfterLine(blockIndex, line, isLastLine);
458
+
459
+ lineRows.push(
460
+ lineRowFor(
461
+ {
462
+ blockIndex,
463
+ roleRuns,
464
+ line,
465
+ lineIndex,
466
+ ranges,
467
+ marker,
468
+ baseFg,
469
+ baseAttributes,
470
+ syntaxSpans,
471
+ },
472
+ cards.length > 0,
473
+ ),
474
+ );
475
+ if (isLastLine) lineRows.push(headingRule(block));
476
+ lineRows.push(...cards);
477
+ if (cards.length > 0 && !isLastLine) {
478
+ lineRows.push(<box key={`gap-${lineIndex}`} style={{ height: 1 }} />);
479
+ }
480
+ }
481
+
482
+ // a code block announces its language first
483
+ const languageRow =
484
+ block.kind === "code" ? (
485
+ <text key="language" fg={tokens.textDim} selectable={false}>
486
+ {` ${block.work?.lang ?? block.base?.lang ?? "code"}`}
487
+ </text>
488
+ ) : null;
489
+
490
+ return measuredBox(
491
+ <>
492
+ {languageRow}
493
+ {lineRows}
494
+ </>,
495
+ );
496
+ };
497
+
498
+ const virtualBlocks = (): React.ReactNode[] => {
499
+ const nodes: React.ReactNode[] = [];
500
+ const firstItem = virtual.items[0];
501
+ const lastItem = virtual.items.at(-1);
502
+
503
+ // the very first render has no measured viewport yet, so the virtualizer's window is empty; paint a
504
+ // viewport-worth from the top rather than blank or the whole document, then it takes over next frame
505
+ if (!firstItem || !lastItem) {
506
+ let mountedRows = 0;
507
+
508
+ for (let index = 0; index < display.length; index++) {
509
+ nodes.push(blockNodeFor(index));
510
+ mountedRows += estimateBlockRows(index);
511
+ if (mountedRows > terminalHeight + OVERSCAN_BLOCKS) break;
512
+ }
513
+
514
+ return nodes;
515
+ }
516
+ if (firstItem.start > 0) {
517
+ nodes.push(<box key="spacer-above" style={{ height: firstItem.start }} />);
518
+ }
519
+ for (const item of virtual.items) nodes.push(blockNodeFor(item.index));
520
+ const below = virtual.totalSize - lastItem.end;
521
+
522
+ if (below > 0) nodes.push(<box key="spacer-below" style={{ height: below }} />);
523
+
524
+ return nodes;
525
+ };
526
+
527
+ return (
528
+ <box style={{ flexGrow: 1, flexDirection: "row" }} {...surface.rootMouseProps}>
529
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
530
+ {editOrphanCount > 0 ? (
531
+ <box style={{ height: 1, backgroundColor: palette.markBackdrop, paddingLeft: 2 }}>
532
+ <text fg={tokens.red}>
533
+ {`${editOrphanCount} annotation${editOrphanCount === 1 ? "" : "s"} no longer match - the passage was removed.`}
534
+ </text>
535
+ </box>
536
+ ) : null}
537
+ {/* the rail draws this box's scrollbar past the dots, so the built-in one stays hidden */}
538
+ <scrollbox
539
+ ref={scrollRef}
540
+ style={{ flexGrow: 1, paddingTop: 1 }}
541
+ focused={false}
542
+ verticalScrollbarOptions={{ visible: false }}
543
+ >
544
+ {virtualBlocks()}
545
+ </scrollbox>
546
+ {suspended ? null : (
547
+ <NavModeHint navMode={surface.navMode} surface="thread" theme={tokens} />
548
+ )}
549
+ </box>
550
+ <DiscussionMarkerRail
551
+ discussions={discussions}
552
+ spanQuote={surface.spanQuote}
553
+ focusedKey={surface.focusedDiscussion}
554
+ onJump={surface.jumpToDiscussion}
555
+ scrollbox={scrollRef}
556
+ theme={theme}
557
+ />
558
+ </box>
559
+ );
560
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Mark geometry for an annotated line of text: which stretches are marked
3
+ * (a discussion span, a held selection) or carry the caret cell, the greedy
4
+ * word wrap into visual lines, and the printable-key test that starts a
5
+ * comment. Renderer-free, shared by the plan thread view and the diff sheet.
6
+ */
7
+
8
+ import type { KeyEvent } from "@opentui/core";
9
+ import { wordRanges } from "../annotations/thread-selection";
10
+
11
+ export interface MarkRange {
12
+ start: number;
13
+ end: number;
14
+ caretOnly?: boolean;
15
+ }
16
+
17
+ export interface Run {
18
+ text: string;
19
+ marked: boolean;
20
+ caretOnly: boolean;
21
+ }
22
+
23
+ /** Split a block's text into plain/marked runs from the union of ranges. */
24
+ export function runsFor(text: string, ranges: MarkRange[]): Run[] {
25
+ const cuts = new Set<number>([0, text.length]);
26
+
27
+ for (const range of ranges) {
28
+ cuts.add(Math.max(0, range.start));
29
+ cuts.add(Math.min(text.length, range.end));
30
+ }
31
+ const edges = [...cuts].toSorted((left, right) => left - right);
32
+ const runs: Run[] = [];
33
+
34
+ for (let index = 0; index < edges.length - 1; index++) {
35
+ const start = edges[index]!;
36
+ const end = edges[index + 1]!;
37
+
38
+ if (end <= start) continue;
39
+ const covering = ranges.filter((range) => range.start <= start && end <= range.end);
40
+
41
+ // the caret cell shows through a mark, so the head of a selection is visible
42
+ runs.push({
43
+ text: text.slice(start, end),
44
+ marked: covering.some((range) => range.caretOnly !== true),
45
+ caretOnly: covering.some((range) => range.caretOnly === true),
46
+ });
47
+ }
48
+
49
+ return runs.length > 0 ? runs : [{ text, marked: false, caretOnly: false }];
50
+ }
51
+
52
+ /** A visual line of a block: the char range of the block's text it shows. */
53
+ export interface VisualLine {
54
+ start: number;
55
+ end: number;
56
+ }
57
+
58
+ /**
59
+ * Greedy word wrap into visual-line char ranges, so a comment card can slot
60
+ * in directly below the line its mark ends on (mid-paragraph when needed).
61
+ */
62
+ export function wrapLines(text: string, width: number): VisualLine[] {
63
+ const lines: VisualLine[] = [];
64
+ let segmentStart = 0;
65
+
66
+ // hard breaks first: every visual line must be exactly one terminal row,
67
+ // or hit-testing and the mark geometry drift from what is painted
68
+ for (const segment of text.split("\n")) {
69
+ const segmentEnd = segmentStart + segment.length;
70
+
71
+ if (width <= 0 || segment.length <= width) {
72
+ lines.push({ start: segmentStart, end: segmentEnd });
73
+ } else {
74
+ // greedy word wrap inside the segment; the first line keeps its
75
+ // leading indentation, continuation lines start at a word
76
+ let lineStart = segmentStart;
77
+ let lineEnd = segmentStart;
78
+
79
+ for (const word of wordRanges(segment)) {
80
+ const wordStart = segmentStart + word.start;
81
+ const wordEnd = segmentStart + word.end;
82
+
83
+ if (wordEnd - lineStart > width && lineEnd > lineStart) {
84
+ lines.push({ start: lineStart, end: lineEnd });
85
+ lineStart = wordStart;
86
+ }
87
+ // a token wider than the line (a long identifier, a URL) breaks at the cell edge, so
88
+ // every visual line stays one terminal row and nothing is clipped away
89
+ while (wordEnd - lineStart > width) {
90
+ lines.push({ start: lineStart, end: lineStart + width });
91
+ lineStart += width;
92
+ }
93
+ lineEnd = wordEnd;
94
+ }
95
+ lines.push({ start: lineStart, end: Math.max(lineEnd, lineStart) });
96
+ }
97
+ segmentStart = segmentEnd + 1;
98
+ }
99
+
100
+ return lines;
101
+ }
102
+
103
+ /** The block's mark ranges clipped to one visual line, in that line's coordinates. */
104
+ export function lineMarkRanges(ranges: MarkRange[], line: VisualLine): MarkRange[] {
105
+ return ranges
106
+ .map((range) => ({
107
+ start: Math.max(range.start, line.start) - line.start,
108
+ end: Math.min(range.end, line.end) - line.start,
109
+ caretOnly: range.caretOnly,
110
+ }))
111
+ .filter((range) => range.end > range.start);
112
+ }
113
+
114
+ /** Printable input that should reach a composer: one key, or a whole pasted run in one event. */
115
+ export function printableSequence(key: KeyEvent): string | null {
116
+ if (!key.sequence || key.ctrl || key.meta) return null;
117
+ // reject any run carrying a control byte, so escape sequences and \r\t\b never seed a draft
118
+ for (const character of key.sequence) if (character < " ") return null;
119
+
120
+ return key.sequence.length > 0 ? key.sequence : null;
121
+ }