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

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 (356) 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 +369 -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 +1243 -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 +20 -0
  36. package/src/app/App.toast.test.tsx +124 -0
  37. package/src/app/App.tsx +1667 -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.agent.test.tsx +50 -0
  119. package/src/integrations/components/ShareDialog.stories.tsx +121 -0
  120. package/src/integrations/components/ShareDialog.tsx +574 -0
  121. package/src/integrations/components/share-dialog-store.ts +59 -0
  122. package/src/integrations/editor.test.ts +149 -0
  123. package/src/integrations/editor.ts +160 -0
  124. package/src/integrations/github-identity.test.ts +58 -0
  125. package/src/integrations/github-identity.ts +41 -0
  126. package/src/integrations/herdr.test.ts +43 -0
  127. package/src/integrations/herdr.ts +38 -0
  128. package/src/integrations/integrations.test.ts +41 -0
  129. package/src/integrations/integrations.ts +32 -0
  130. package/src/integrations/pasted-image.test.ts +67 -0
  131. package/src/integrations/pasted-image.ts +60 -0
  132. package/src/integrations/share.ts +238 -0
  133. package/src/keyboard/components/CompletionOverlay.stories.tsx +37 -0
  134. package/src/keyboard/components/CompletionOverlay.tsx +75 -0
  135. package/src/keyboard/components/HandleChipsInput.stories.tsx +24 -0
  136. package/src/keyboard/components/HandleChipsInput.test.tsx +35 -0
  137. package/src/keyboard/components/HandleChipsInput.tsx +143 -0
  138. package/src/keyboard/components/KeybindsDialog.stories.tsx +31 -0
  139. package/src/keyboard/components/KeybindsDialog.tsx +103 -0
  140. package/src/keyboard/components/NavModeHint.stories.tsx +40 -0
  141. package/src/keyboard/components/NavModeHint.tsx +31 -0
  142. package/src/keyboard/components/SlashComposer.stories.tsx +37 -0
  143. package/src/keyboard/components/SlashComposer.tsx +87 -0
  144. package/src/keyboard/key-bindings.test.ts +156 -0
  145. package/src/keyboard/key-bindings.ts +459 -0
  146. package/src/keyboard/keymap.test.ts +611 -0
  147. package/src/keyboard/keymap.ts +395 -0
  148. package/src/keyboard/skills.test.ts +74 -0
  149. package/src/keyboard/skills.ts +75 -0
  150. package/src/keyboard/slash-palette.test.ts +103 -0
  151. package/src/keyboard/slash-palette.ts +129 -0
  152. package/src/keyboard/thread-chords.test.ts +79 -0
  153. package/src/keyboard/thread-chords.ts +163 -0
  154. package/src/keyboard/use-shared-keyboard.ts +55 -0
  155. package/src/markdown/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  156. package/src/markdown/components/MarkdownGridBlock.stories.tsx +33 -0
  157. package/src/markdown/components/MarkdownGridBlock.tsx +72 -0
  158. package/src/markdown/components/MarkdownThreadEditor.stories.tsx +15 -0
  159. package/src/markdown/components/MarkdownThreadEditor.test.tsx +98 -0
  160. package/src/markdown/components/MarkdownThreadEditor.tsx +160 -0
  161. package/src/markdown/components/ThreadView.selection.test.tsx +951 -0
  162. package/src/markdown/components/ThreadView.slash.test.tsx +146 -0
  163. package/src/markdown/components/ThreadView.stories.tsx +26 -0
  164. package/src/markdown/components/ThreadView.tsx +560 -0
  165. package/src/markdown/mark-runs.ts +121 -0
  166. package/src/markdown/markdown-editor-syntax-style.ts +50 -0
  167. package/src/markdown/markdown-grid.test.ts +101 -0
  168. package/src/markdown/markdown-grid.ts +203 -0
  169. package/src/markdown/markdown-highlight.test.ts +61 -0
  170. package/src/markdown/markdown-highlight.ts +124 -0
  171. package/src/markdown/thread-code-syntax.ts +45 -0
  172. package/src/markdown/use-thread-body-editing.tsx +54 -0
  173. package/src/markdown/view-plan.regression.test.ts +60 -0
  174. package/src/markdown/view-plan.test.ts +545 -0
  175. package/src/markdown/view-plan.ts +1009 -0
  176. package/src/performance/otlp-trace.test.ts +119 -0
  177. package/src/performance/otlp-trace.ts +134 -0
  178. package/src/performance/perf-report.ts +18 -0
  179. package/src/performance/perf-timings.test.ts +18 -0
  180. package/src/performance/perf-timings.ts +39 -0
  181. package/src/prototype/components/PrototypeContentView.stories.tsx +19 -0
  182. package/src/prototype/components/PrototypeContentView.tsx +442 -0
  183. package/src/prototype/prototype-browser.test.ts +81 -0
  184. package/src/prototype/prototype-browser.ts +321 -0
  185. package/src/prototype/prototype-pixels.tsx +35 -0
  186. package/src/settings/components/SettingsDialog.stories.tsx +117 -0
  187. package/src/settings/components/SettingsDialog.tsx +183 -0
  188. package/src/settings/components/SettingsRows.stories.tsx +18 -0
  189. package/src/settings/components/SettingsRows.tsx +112 -0
  190. package/src/settings/components/quick-actions-editor.stories.tsx +58 -0
  191. package/src/settings/components/quick-actions-editor.test.tsx +120 -0
  192. package/src/settings/components/quick-actions-editor.tsx +222 -0
  193. package/src/settings/config-identity.test.ts +78 -0
  194. package/src/settings/config.test.ts +676 -0
  195. package/src/settings/config.ts +628 -0
  196. package/src/settings/use-settings-dialog.tsx +351 -0
  197. package/src/stories/__snapshots__/stories.test.tsx.snap +2358 -0
  198. package/src/stories/stories-app.tsx +196 -0
  199. package/src/stories/stories.test.tsx +74 -0
  200. package/src/stories/story-fixtures.ts +102 -0
  201. package/src/stories/story-tree.test.ts +108 -0
  202. package/src/stories/story.ts +154 -0
  203. package/src/terminal/ansi-screen.test.ts +28 -0
  204. package/src/terminal/ansi-screen.ts +72 -0
  205. package/src/terminal/components/AnsiScreen.stories.tsx +12 -0
  206. package/src/terminal/components/AnsiScreen.tsx +28 -0
  207. package/src/terminal/components/terminal-pane.test.tsx +40 -0
  208. package/src/terminal/components/terminal-pane.ts +196 -0
  209. package/src/terminal/ghostty-terminal.test.ts +85 -0
  210. package/src/terminal/ghostty-terminal.ts +197 -0
  211. package/src/terminal/kitty-image.test.ts +66 -0
  212. package/src/terminal/kitty-image.ts +137 -0
  213. package/src/terminal/pty.test.ts +81 -0
  214. package/src/terminal/pty.ts +246 -0
  215. package/src/testing/test-support.test.ts +264 -0
  216. package/src/testing/test-support.ts +367 -0
  217. package/src/thread/agent-continuation.test.ts +32 -0
  218. package/src/thread/agent-continuation.ts +13 -0
  219. package/src/thread/agent-transcript.test.ts +161 -0
  220. package/src/thread/agent-transcript.ts +336 -0
  221. package/src/thread/components/AgentConfigControls.stories.tsx +39 -0
  222. package/src/thread/components/AgentConfigControls.test.tsx +112 -0
  223. package/src/thread/components/AgentConfigControls.tsx +114 -0
  224. package/src/thread/components/AgentThreadPane.stories.tsx +105 -0
  225. package/src/thread/components/AgentThreadPane.test.tsx +935 -0
  226. package/src/thread/components/AgentThreadPane.tsx +377 -0
  227. package/src/thread/components/ThreadFooter.stories.tsx +30 -0
  228. package/src/thread/components/ThreadFooter.test.tsx +49 -0
  229. package/src/thread/components/ThreadFooter.tsx +77 -0
  230. package/src/thread/components/ThreadTree.stories.tsx +46 -0
  231. package/src/thread/components/ThreadTree.test.tsx +41 -0
  232. package/src/thread/components/ThreadTree.tsx +267 -0
  233. package/src/thread/components/thread-tree.test.ts +137 -0
  234. package/src/thread/components/thread-tree.ts +112 -0
  235. package/src/thread/thread-controller.curate.test.ts +502 -0
  236. package/src/thread/thread-controller.derivation.test.ts +62 -0
  237. package/src/thread/thread-controller.live-diff.test.ts +357 -0
  238. package/src/thread/thread-controller.optimistic.test.ts +69 -0
  239. package/src/thread/thread-controller.share.test.ts +573 -0
  240. package/src/thread/thread-controller.switch-cache.test.ts +174 -0
  241. package/src/thread/thread-controller.tree.test.ts +263 -0
  242. package/src/thread/thread-controller.ts +2026 -0
  243. package/src/thread/tree-view.test.ts +128 -0
  244. package/src/thread/tree-view.ts +146 -0
  245. package/src/thread/use-thread-agent.test.tsx +208 -0
  246. package/src/thread/use-thread-agent.ts +117 -0
  247. package/src/ui/components/CodeBlock.stories.tsx +42 -0
  248. package/src/ui/components/CodeBlock.tsx +80 -0
  249. package/src/ui/components/ConfirmCard.stories.tsx +37 -0
  250. package/src/ui/components/ConfirmCard.tsx +151 -0
  251. package/src/ui/components/ConfirmDialog.stories.tsx +21 -0
  252. package/src/ui/components/ConfirmDialog.tsx +72 -0
  253. package/src/ui/components/OverlayScrollbar.stories.tsx +33 -0
  254. package/src/ui/components/OverlayScrollbar.test.tsx +139 -0
  255. package/src/ui/components/OverlayScrollbar.tsx +129 -0
  256. package/src/ui/components/PromptDialog.stories.tsx +36 -0
  257. package/src/ui/components/PromptDialog.test.tsx +65 -0
  258. package/src/ui/components/PromptDialog.tsx +99 -0
  259. package/src/ui/components/RootOverlay.stories.tsx +45 -0
  260. package/src/ui/components/RootOverlay.tsx +49 -0
  261. package/src/ui/components/ScrollArea.stories.tsx +20 -0
  262. package/src/ui/components/ScrollArea.tsx +61 -0
  263. package/src/ui/components/Toast.stories.tsx +19 -0
  264. package/src/ui/components/Toast.tsx +54 -0
  265. package/src/ui/components/Tooltip.stories.tsx +26 -0
  266. package/src/ui/components/Tooltip.tsx +72 -0
  267. package/src/ui/components/menu-control.stories.tsx +35 -0
  268. package/src/ui/components/menu-control.tsx +43 -0
  269. package/src/ui/components/primitives/Button.stories.tsx +32 -0
  270. package/src/ui/components/primitives/Button.tsx +57 -0
  271. package/src/ui/components/primitives/Card.stories.tsx +25 -0
  272. package/src/ui/components/primitives/Card.tsx +62 -0
  273. package/src/ui/components/primitives/Dialog.stories.tsx +24 -0
  274. package/src/ui/components/primitives/Dialog.test.tsx +38 -0
  275. package/src/ui/components/primitives/Dialog.tsx +69 -0
  276. package/src/ui/components/primitives/DialogActions.stories.tsx +20 -0
  277. package/src/ui/components/primitives/DialogActions.tsx +39 -0
  278. package/src/ui/components/primitives/IconButton.stories.tsx +36 -0
  279. package/src/ui/components/primitives/IconButton.tsx +58 -0
  280. package/src/ui/components/primitives/StatusBar.stories.tsx +11 -0
  281. package/src/ui/components/primitives/StatusBar.tsx +23 -0
  282. package/src/ui/components/primitives/Switch.stories.tsx +17 -0
  283. package/src/ui/components/primitives/Switch.tsx +32 -0
  284. package/src/ui/components/primitives/Tabs.stories.tsx +29 -0
  285. package/src/ui/components/primitives/Tabs.tsx +101 -0
  286. package/src/ui/components/primitives/Toolbar.stories.tsx +17 -0
  287. package/src/ui/components/primitives/Toolbar.tsx +14 -0
  288. package/src/ui/components/primitives/Tree.stories.tsx +124 -0
  289. package/src/ui/components/primitives/Tree.tsx +133 -0
  290. package/src/ui/components/primitives/frame.test.ts +12 -0
  291. package/src/ui/components/primitives/frame.ts +17 -0
  292. package/src/ui/components/primitives/icons.ts +61 -0
  293. package/src/ui/components/primitives/tree-model.test.ts +99 -0
  294. package/src/ui/components/primitives/tree-model.ts +137 -0
  295. package/src/ui/components/syntax-highlight.ts +133 -0
  296. package/src/ui/components/truncate-text.ts +7 -0
  297. package/src/ui/components/truncate-title.test.ts +31 -0
  298. package/src/ui/components/truncate-title.ts +15 -0
  299. package/src/ui/use-frame-measure.ts +74 -0
  300. package/src/ui/use-terminal-virtualizer.ts +259 -0
  301. package/src/workbench/components/ChangesColumn.stories.tsx +44 -0
  302. package/src/workbench/components/ChangesColumn.tsx +203 -0
  303. package/src/workbench/components/ChangesFileTree.test.tsx +181 -0
  304. package/src/workbench/components/EditorGrid.split.test.tsx +118 -0
  305. package/src/workbench/components/EditorGrid.stories.tsx +86 -0
  306. package/src/workbench/components/EditorGrid.tabs.test.tsx +118 -0
  307. package/src/workbench/components/EditorGrid.tsx +544 -0
  308. package/src/workbench/components/EditorGrid.zoom.test.tsx +132 -0
  309. package/src/workbench/components/FileContentsView.stories.tsx +16 -0
  310. package/src/workbench/components/FileContentsView.test.tsx +29 -0
  311. package/src/workbench/components/FileContentsView.tsx +100 -0
  312. package/src/workbench/components/GridTabContent.actions.test.tsx +151 -0
  313. package/src/workbench/components/GridTabContent.stories.tsx +59 -0
  314. package/src/workbench/components/GridTabContent.tsx +193 -0
  315. package/src/workbench/components/ProjectTreeView.stories.tsx +25 -0
  316. package/src/workbench/components/ProjectTreeView.test.tsx +236 -0
  317. package/src/workbench/components/ProjectTreeView.tsx +151 -0
  318. package/src/workbench/components/TreePane.stories.tsx +106 -0
  319. package/src/workbench/components/TreePane.tsx +155 -0
  320. package/src/workbench/components/WelcomePlayground.stories.tsx +12 -0
  321. package/src/workbench/components/WelcomePlayground.test.tsx +83 -0
  322. package/src/workbench/components/WelcomePlayground.tsx +138 -0
  323. package/src/workbench/components/editor-grid.test.ts +184 -0
  324. package/src/workbench/components/editor-grid.ts +281 -0
  325. package/src/workbench/components/file-tree.test.ts +60 -0
  326. package/src/workbench/components/file-tree.ts +112 -0
  327. package/src/workbench/use-changes-workbench.ts +205 -0
  328. package/src/workbench/use-remember-layout.ts +31 -0
  329. package/src/workbench/use-repo-changes.ts +91 -0
  330. package/src/workbench/workbench-snapshot.test.ts +99 -0
  331. package/src/workbench/workbench-snapshot.ts +59 -0
  332. package/src/App.diff.test.tsx +0 -99
  333. package/src/App.export.test.tsx +0 -88
  334. package/src/App.plan-review.test.tsx +0 -259
  335. package/src/App.readonly.test.tsx +0 -148
  336. package/src/App.rendering.test.tsx +0 -108
  337. package/src/App.submit-confirm.test.tsx +0 -183
  338. package/src/App.test.tsx +0 -218
  339. package/src/App.tsx +0 -1249
  340. package/src/config.test.ts +0 -116
  341. package/src/config.ts +0 -150
  342. package/src/editor.ts +0 -34
  343. package/src/herdr.test.ts +0 -68
  344. package/src/herdr.ts +0 -34
  345. package/src/keymap.test.ts +0 -270
  346. package/src/keymap.ts +0 -173
  347. package/src/run.ts +0 -30
  348. package/src/serve.ts +0 -75
  349. package/src/session-controller.ts +0 -398
  350. package/src/syntax.test.ts +0 -41
  351. package/src/syntax.ts +0 -88
  352. package/src/theme.ts +0 -46
  353. package/src/view-diff.test.ts +0 -55
  354. package/src/view-diff.ts +0 -86
  355. package/src/view.test.ts +0 -157
  356. package/src/view.ts +0 -364
@@ -0,0 +1,141 @@
1
+ // A project file shown as its own annotatable surface: the file's lines render through the diff
2
+ // sheet as context rows (one line-number gutter, no +/- sign), so selecting text leaves a comment
3
+ // anchored to the file exactly as a plan or diff comment is. Used where a thread exists to
4
+ // hold the notes; the bare-launch welcome shell uses the read-only FileContentsView instead.
5
+
6
+ import React, { useEffect, useRef, useState } from "react";
7
+ import {
8
+ makeAnchor,
9
+ annotationTarget,
10
+ type Anchor,
11
+ type Annotation,
12
+ type Thread,
13
+ } from "@cueloop/schema";
14
+ import type { Theme } from "../../appearance/theme";
15
+ import type { QuickAction } from "../../settings/config";
16
+ import { useComponentTheme } from "../../appearance/components/theme-context";
17
+ import { DiffContentView } from "./DiffContentView";
18
+ import { diffRowBlocks, fileContentsRows, marksByRows } from "../view-diff";
19
+
20
+ export interface AnnotatableFileViewProps {
21
+ path: string;
22
+ loadContents: (path: string) => Promise<string | null>;
23
+ session: Thread;
24
+ quickActions: QuickAction[];
25
+ observer: boolean;
26
+ resolved?: boolean;
27
+ suspended?: boolean;
28
+ focusedAnnotationId?: string;
29
+ onFocusAnnotation?: (annotationId: string | undefined) => void;
30
+ onComposingChange?: (composing: boolean) => void;
31
+ onCursorChange?: (rowIndex: number) => void;
32
+ onObserverBlocked?: (reason: "observer" | "resolved") => void;
33
+ onAddComment: (anchor: Anchor, body: string) => void;
34
+ onReply: (rootAnnotationId: string, body: string) => void;
35
+ onUpdateAnnotation: (id: string, body: string) => void;
36
+ onInvoke?: () => void;
37
+ annotationAction?: (id: string) => { label: string; run: () => void } | undefined;
38
+ isAnnotationReadOnly?: (id: string) => boolean;
39
+ resolveAuthorLabel?: (annotation: Annotation) => string | undefined;
40
+ onExit: () => void;
41
+ theme?: Theme;
42
+ }
43
+
44
+ /** A finished read for a specific path; `lines` is null when the file could not be read. */
45
+ interface FileLoad {
46
+ path: string;
47
+ lines: string[] | null;
48
+ }
49
+
50
+ export function AnnotatableFileView(props: AnnotatableFileViewProps): React.ReactNode {
51
+ const tokens = useComponentTheme(props.theme);
52
+ const [loaded, setLoaded] = useState<FileLoad | null>(null);
53
+ const loadRef = useRef(props.loadContents);
54
+
55
+ useEffect(() => {
56
+ loadRef.current = props.loadContents;
57
+ });
58
+
59
+ useEffect(() => {
60
+ let alive = true;
61
+
62
+ void loadRef.current(props.path).then(
63
+ (contents) => {
64
+ if (alive)
65
+ setLoaded({ path: props.path, lines: contents === null ? null : contents.split("\n") });
66
+ },
67
+ () => {
68
+ if (alive) setLoaded({ path: props.path, lines: null });
69
+ },
70
+ );
71
+
72
+ return () => {
73
+ alive = false;
74
+ };
75
+ }, [props.path]);
76
+
77
+ if (loaded === null || loaded.path !== props.path) {
78
+ return (
79
+ <box style={{ flexGrow: 1, paddingLeft: 1, paddingTop: 1 }}>
80
+ <text fg={tokens.textDim}>loading...</text>
81
+ </box>
82
+ );
83
+ }
84
+ if (loaded.lines === null) {
85
+ return (
86
+ <box
87
+ style={{ flexGrow: 1, alignItems: "center", justifyContent: "center", paddingBottom: 2 }}
88
+ >
89
+ <text fg={tokens.textDim}>File deleted</text>
90
+ </box>
91
+ );
92
+ }
93
+
94
+ const rows = fileContentsRows(props.path, loaded.lines.join("\n"));
95
+ // the file's worktree notes: these rows are the current file, so a head-side (deleted-line) note
96
+ // must stay orphaned here rather than rebind to similar surviving text
97
+ const fileNotes = props.session.annotations.filter((annotation) => {
98
+ const target = annotationTarget(annotation);
99
+
100
+ return target.kind === "file" && target.path === props.path && target.rev === "worktree";
101
+ });
102
+
103
+ return (
104
+ <DiffContentView
105
+ rows={rows}
106
+ session={props.session}
107
+ marks={marksByRows(fileNotes, rows, props.focusedAnnotationId)}
108
+ quickActions={props.quickActions}
109
+ observer={props.observer}
110
+ commentsEnabled
111
+ resolved={props.resolved}
112
+ suspended={props.suspended}
113
+ fileView
114
+ onComposingChange={props.onComposingChange}
115
+ onObserverBlocked={props.onObserverBlocked}
116
+ onCursorChange={props.onCursorChange}
117
+ focusedAnnotationId={props.focusedAnnotationId}
118
+ onFocusAnnotation={props.onFocusAnnotation}
119
+ onAnnotate={(span, body) =>
120
+ props.onAddComment(
121
+ makeAnchor(
122
+ diffRowBlocks(rows),
123
+ span.start.blockIndex,
124
+ span.start.char,
125
+ span.end.char,
126
+ span.end.blockIndex,
127
+ ),
128
+ body,
129
+ )
130
+ }
131
+ onReply={props.onReply}
132
+ onUpdateAnnotation={props.onUpdateAnnotation}
133
+ onInvoke={props.onInvoke}
134
+ isAnnotationReadOnly={props.isAnnotationReadOnly}
135
+ annotationAction={props.annotationAction}
136
+ resolveAuthorLabel={props.resolveAuthorLabel}
137
+ onExit={props.onExit}
138
+ theme={props.theme}
139
+ />
140
+ );
141
+ }
@@ -0,0 +1,27 @@
1
+ import React from "react";
2
+ import { DARK } from "../../appearance/theme";
3
+ import type { Story, StoryMeta } from "../../stories/story";
4
+ import { BareWorkbenchFileView, type WorkbenchCommenter } from "./BareWorkbenchFileView";
5
+
6
+ export const meta: StoryMeta = { title: "Surfaces/BareWorkbenchFileView" };
7
+
8
+ const SAMPLE = `export function add(a: number, b: number): number {\n return a + b;\n}\n`;
9
+
10
+ const controller: WorkbenchCommenter = {
11
+ repoReadFile: () => Promise.resolve(SAMPLE),
12
+ commentOnWorkbench: () => Promise.resolve(),
13
+ };
14
+
15
+ export const Loaded: Story = {
16
+ render: () => (
17
+ <BareWorkbenchFileView
18
+ path="src/add.ts"
19
+ controller={controller}
20
+ quickActions={[]}
21
+ onExit={() => {}}
22
+ theme={DARK}
23
+ />
24
+ ),
25
+ expectedColors: [DARK.textDim],
26
+ size: { width: 60, height: 12 },
27
+ };
@@ -0,0 +1,63 @@
1
+ // A project file in the bare launch shell: annotatable like any file, but no thread exists yet. The
2
+ // first comment find-or-creates the per-repo workbench thread (controller.commentOnWorkbench), which
3
+ // opens it - so this ephemeral view lives only until that first note promotes it to a real thread.
4
+
5
+ import React from "react";
6
+ import { SCHEMA_VERSION, type Anchor, type AnnotationTarget, type Thread } from "@cueloop/schema";
7
+ import type { Theme } from "../../appearance/theme";
8
+ import type { QuickAction } from "../../settings/config";
9
+ import { AnnotatableFileView } from "./AnnotatableFileView";
10
+
11
+ /** The controller slice this view needs: read a file, and route a comment to the workbench thread. */
12
+ export interface WorkbenchCommenter {
13
+ repoReadFile(path: string): Promise<string | null>;
14
+ commentOnWorkbench(anchor: Anchor, target: AnnotationTarget, body: string): Promise<void>;
15
+ }
16
+
17
+ /** An empty, unsaved thread: it holds no notes, just enough shape for the annotation surface. */
18
+ export function draftThread(): Thread {
19
+ return {
20
+ schemaVersion: SCHEMA_VERSION,
21
+ id: "workbench-draft",
22
+ workspace: { repoRoot: "", branch: "main" },
23
+ artifact: { type: "diff", content: "", meta: {} },
24
+ revisions: [],
25
+ annotations: [],
26
+ message: null,
27
+ status: "pending",
28
+ createdAt: "1970-01-01T00:00:00.000Z",
29
+ };
30
+ }
31
+
32
+ export interface BareWorkbenchFileViewProps {
33
+ path: string;
34
+ controller: WorkbenchCommenter;
35
+ quickActions: QuickAction[];
36
+ onComposingChange?: (composing: boolean) => void;
37
+ onExit: () => void;
38
+ theme?: Theme;
39
+ }
40
+
41
+ export function BareWorkbenchFileView(props: BareWorkbenchFileViewProps): React.ReactNode {
42
+ return (
43
+ <AnnotatableFileView
44
+ path={props.path}
45
+ loadContents={(path) => props.controller.repoReadFile(path)}
46
+ session={draftThread()}
47
+ quickActions={props.quickActions}
48
+ observer={false}
49
+ onComposingChange={props.onComposingChange}
50
+ onAddComment={(anchor: Anchor, body: string) =>
51
+ void props.controller.commentOnWorkbench(
52
+ anchor,
53
+ { kind: "file", path: props.path, rev: "worktree" },
54
+ body,
55
+ )
56
+ }
57
+ onReply={() => {}}
58
+ onUpdateAnnotation={() => {}}
59
+ onExit={props.onExit}
60
+ theme={props.theme}
61
+ />
62
+ );
63
+ }
@@ -0,0 +1,434 @@
1
+ /** Keyboard scroll is smooth: the caret-follow reveal never scrolls the caret off screen, nor rebounds. */
2
+
3
+ import { expect, test } from "bun:test";
4
+ import React from "react";
5
+ import { testRender } from "@opentui/react/test-utils";
6
+ import { ScrollBoxRenderable, type Renderable } from "@opentui/core";
7
+ import type { Annotation } from "@cueloop/schema";
8
+ import { DiffContentView } from "./DiffContentView";
9
+ import { diffRows, marksByRows } from "../view-diff";
10
+ import { DARK } from "../../appearance/theme";
11
+ import { annotationPaletteFor } from "../../annotations/annotation-palette";
12
+ import { press, settle, typeText } from "../../testing/test-support";
13
+ import { fixtureDiffSession } from "../../stories/story-fixtures";
14
+
15
+ const noop = (): void => {};
16
+
17
+ /** Find a renderable by id anywhere in the tree (getRenderable is not recursive). */
18
+ function findById(node: Renderable, id: string): Renderable | undefined {
19
+ if (node.id === id) return node;
20
+ for (const child of node.getChildren()) {
21
+ const found = findById(child, id);
22
+
23
+ if (found) return found;
24
+ }
25
+
26
+ return undefined;
27
+ }
28
+
29
+ function hex(color: { toInts(): [number, number, number, number] }): string {
30
+ const [red, green, blue] = color.toInts();
31
+
32
+ return "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
33
+ }
34
+
35
+ /** A single-hunk patch of `count` added lines, taller than any test viewport. */
36
+ function tallPatch(count: number): string {
37
+ const adds = Array.from(
38
+ { length: count },
39
+ (_, index) => `+line ${String(index).padStart(2, "0")}`,
40
+ );
41
+
42
+ return [
43
+ "diff --git a/f.ts b/f.ts",
44
+ "--- a/f.ts",
45
+ "+++ b/f.ts",
46
+ `@@ -0,0 +1,${count} @@`,
47
+ ...adds,
48
+ "",
49
+ ].join("\n");
50
+ }
51
+
52
+ test("walking the caret down past wrapped discussion cards keeps it on screen and the scroll monotonic", async () => {
53
+ // Arrange - a tall diff whose comment bodies wrap at this width, so the cards
54
+ // between rows are taller than one line
55
+ const rows = diffRows(tallPatch(40));
56
+ const longBody =
57
+ "this is a deliberately long annotation body that would wrap across the narrow test viewport";
58
+ const annotations: Annotation[] = [8, 16, 24].map((rowIndex) => ({
59
+ id: `a${rowIndex}`,
60
+ kind: "comment",
61
+ body: longBody,
62
+ anchor: { quote: rows[rowIndex]!.text.replace(/\n$/, ""), prefix: "", suffix: "" },
63
+ createdAt: "2026-01-01T00:00:00Z",
64
+ }));
65
+ const setup = await testRender(
66
+ <DiffContentView
67
+ rows={rows}
68
+ session={fixtureDiffSession({ annotations })}
69
+ marks={marksByRows(annotations, rows)}
70
+ quickActions={[]}
71
+ observer={false}
72
+ onAnnotate={noop}
73
+ onReply={noop}
74
+ onUpdateAnnotation={noop}
75
+ onExit={noop}
76
+ />,
77
+ { width: 60, height: 12 },
78
+ );
79
+
80
+ await setup.waitForVisualIdle();
81
+ const found = findById(setup.renderer.root, "diff-scroll");
82
+
83
+ if (!(found instanceof ScrollBoxRenderable)) throw new Error("diff-scroll is not a scrollbox");
84
+ const scrollbox = found;
85
+ const caretCell = annotationPaletteFor(DARK).caretCell;
86
+
87
+ // the caret row is the one painting the caret cell
88
+ const caretScreenRow = (): number => {
89
+ const lines = setup.captureSpans().lines;
90
+
91
+ for (let index = 0; index < lines.length; index++) {
92
+ if (lines[index]!.spans.some((span) => hex(span.bg) === caretCell)) return index;
93
+ }
94
+
95
+ return -1;
96
+ };
97
+
98
+ // Act - walk the caret down through every code row, sampling the scroll each step
99
+ const scrollTops: number[] = [];
100
+ const screenRows: number[] = [];
101
+ const codeRowCount = rows.filter((row) => row.kind === "add").length;
102
+
103
+ for (let step = 0; step < codeRowCount; step++) {
104
+ await setup.waitForVisualIdle();
105
+ scrollTops.push(scrollbox.scrollTop);
106
+ screenRows.push(caretScreenRow());
107
+ await press(setup, "down");
108
+ }
109
+
110
+ // A key advances the viewport by at most one visual line, even across a tall card.
111
+ for (let step = 1; step < scrollTops.length; step++) {
112
+ expect(scrollTops[step]!).toBeGreaterThanOrEqual(scrollTops[step - 1]!);
113
+ expect(scrollTops[step]! - scrollTops[step - 1]!).toBeLessThanOrEqual(1);
114
+ }
115
+
116
+ // Walk back through the same cards: upward reveal must also stay one row at a time.
117
+ const upwardTops: number[] = [];
118
+
119
+ for (let step = 0; step < codeRowCount + 20; step++) {
120
+ upwardTops.push(scrollbox.scrollTop);
121
+ screenRows.push(caretScreenRow());
122
+ await press(setup, "up");
123
+ }
124
+
125
+ for (let step = 1; step < upwardTops.length; step++) {
126
+ expect(upwardTops[step]!).toBeLessThanOrEqual(upwardTops[step - 1]!);
127
+ expect(upwardTops[step - 1]! - upwardTops[step]!).toBeLessThanOrEqual(1);
128
+ }
129
+
130
+ // Assert - the caret never scrolls off screen while it walks past each wrapped card
131
+ expect(screenRows.every((row) => row >= 0)).toBe(true);
132
+ // and the walk did scroll: the tall diff does not fit the viewport
133
+ expect(scrollTops[scrollTops.length - 1]!).toBeGreaterThan(0);
134
+
135
+ setup.renderer.destroy();
136
+ }, 25000);
137
+
138
+ test("wheel input accelerates a continuous gesture and settles back to precise steps", async () => {
139
+ const rows = diffRows(tallPatch(80));
140
+ const setup = await testRender(
141
+ <DiffContentView
142
+ rows={rows}
143
+ session={fixtureDiffSession()}
144
+ marks={marksByRows([], rows)}
145
+ quickActions={[]}
146
+ observer={false}
147
+ onAnnotate={noop}
148
+ onReply={noop}
149
+ onUpdateAnnotation={noop}
150
+ onExit={noop}
151
+ />,
152
+ { width: 60, height: 12 },
153
+ );
154
+
155
+ await settle(setup);
156
+ const found = findById(setup.renderer.root, "diff-scroll");
157
+
158
+ if (!(found instanceof ScrollBoxRenderable)) throw new Error("diff-scroll is not a scrollbox");
159
+ const scrollbox = found;
160
+ const acceleration = scrollbox.scrollAcceleration;
161
+ const first = acceleration.tick(1000);
162
+ const burst = Array.from({ length: 10 }, (_, index) => acceleration.tick(1016 + index * 16));
163
+ const tail = acceleration.tick(1400);
164
+
165
+ expect(scrollbox.verticalScrollBar.scrollStep).toBe(1);
166
+ expect(first).toBe(1);
167
+ expect(Math.max(...burst)).toBeGreaterThan(first);
168
+ expect(tail).toBe(1);
169
+
170
+ acceleration.reset();
171
+ await setup.mockMouse.scroll(20, 5, "down");
172
+ await settle(setup);
173
+ expect(scrollbox.scrollTop).toBeGreaterThan(0);
174
+
175
+ setup.renderer.destroy();
176
+ });
177
+
178
+ test("wheel scrolling up keeps the caret on the bottom visible code row", async () => {
179
+ const rows = diffRows(tallPatch(80));
180
+ const setup = await testRender(
181
+ <DiffContentView
182
+ rows={rows}
183
+ session={fixtureDiffSession()}
184
+ marks={marksByRows([], rows)}
185
+ quickActions={[]}
186
+ observer={false}
187
+ onAnnotate={noop}
188
+ onReply={noop}
189
+ onUpdateAnnotation={noop}
190
+ onExit={noop}
191
+ />,
192
+ { width: 60, height: 12 },
193
+ );
194
+
195
+ await settle(setup);
196
+ const found = findById(setup.renderer.root, "diff-scroll");
197
+
198
+ if (!(found instanceof ScrollBoxRenderable)) throw new Error("diff-scroll is not a scrollbox");
199
+ const scrollbox = found;
200
+
201
+ for (const key of Array.from({ length: 30 }, () => "down" as const)) {
202
+ // eslint-disable-next-line no-await-in-loop
203
+ await press(setup, key);
204
+ }
205
+ const before = scrollbox.scrollTop;
206
+ const markerRows = (): number[] =>
207
+ setup
208
+ .captureCharFrame()
209
+ .split("\n")
210
+ .flatMap((line, row) => (line.includes("▎") ? [row] : []));
211
+ const top = scrollbox.viewport.screenY;
212
+ const bottom = top + scrollbox.viewport.height - 1;
213
+
214
+ for (const direction of Array.from({ length: 8 }, () => "up" as const)) {
215
+ // eslint-disable-next-line no-await-in-loop
216
+ await setup.mockMouse.scroll(20, 5, direction);
217
+ // eslint-disable-next-line no-await-in-loop
218
+ await settle(setup);
219
+ expect(markerRows()).toEqual([bottom]);
220
+ }
221
+ expect(scrollbox.scrollTop).toBeLessThan(before);
222
+ for (const direction of Array.from({ length: 20 }, () => "down" as const)) {
223
+ // eslint-disable-next-line no-await-in-loop
224
+ await setup.mockMouse.scroll(20, 5, direction);
225
+ // eslint-disable-next-line no-await-in-loop
226
+ await settle(setup);
227
+ expect(markerRows()).toHaveLength(1);
228
+ }
229
+ expect(markerRows()).toEqual([top]);
230
+ setup.renderer.destroy();
231
+ });
232
+
233
+ test("rapid wheel scrolling through a large wrapped diff does not loop React updates", async () => {
234
+ const filePatches = Array.from({ length: 80 }, (_, fileIndex) => [
235
+ `diff --git a/file-${fileIndex}.ts b/file-${fileIndex}.ts`,
236
+ `--- a/file-${fileIndex}.ts`,
237
+ `+++ b/file-${fileIndex}.ts`,
238
+ "@@ -0,0 +1,12 @@",
239
+ ...Array.from(
240
+ { length: 12 },
241
+ (_, lineIndex) => `+line ${lineIndex} ${"dense source text ".repeat(80)}`,
242
+ ),
243
+ ]).flat();
244
+ const rows = diffRows([...filePatches, ""].join("\n"));
245
+ const StatefulDiff = (): React.ReactNode => {
246
+ const [, setCursor] = React.useState(0);
247
+
248
+ return (
249
+ <DiffContentView
250
+ rows={rows}
251
+ session={fixtureDiffSession()}
252
+ marks={marksByRows([], rows)}
253
+ quickActions={[]}
254
+ observer={false}
255
+ onCursorChange={setCursor}
256
+ onAnnotate={noop}
257
+ onReply={noop}
258
+ onUpdateAnnotation={noop}
259
+ onExit={noop}
260
+ />
261
+ );
262
+ };
263
+ const setup = await testRender(<StatefulDiff />, { width: 60, height: 12 });
264
+
265
+ await settle(setup);
266
+ const found = findById(setup.renderer.root, "diff-scroll");
267
+
268
+ if (!(found instanceof ScrollBoxRenderable)) throw new Error("diff-scroll is not a scrollbox");
269
+
270
+ for (const [index, direction] of Array.from({ length: 240 }, () => "down" as const).entries()) {
271
+ // eslint-disable-next-line no-await-in-loop
272
+ await setup.mockMouse.scroll(20, 5, direction);
273
+ if (index % 12 === 0) {
274
+ // eslint-disable-next-line no-await-in-loop
275
+ await settle(setup);
276
+ }
277
+ }
278
+ await settle(setup);
279
+ expect(found.scrollTop).toBeGreaterThan(0);
280
+ setup.renderer.destroy();
281
+ }, 60000);
282
+
283
+ test("walking long wrapped code lines advances the viewport one visual row per key", async () => {
284
+ const lines = Array.from(
285
+ { length: 36 },
286
+ (_, index) =>
287
+ `+line ${String(index).padStart(2, "0")} ${"a paragraph with several wrapped phrases ".repeat(5)}`,
288
+ );
289
+ const rows = diffRows(
290
+ [
291
+ "diff --git a/long.md b/long.md",
292
+ "--- a/long.md",
293
+ "+++ b/long.md",
294
+ "@@ -0,0 +1,36 @@",
295
+ ...lines,
296
+ "",
297
+ ].join("\n"),
298
+ );
299
+ const setup = await testRender(
300
+ <DiffContentView
301
+ rows={rows}
302
+ session={fixtureDiffSession()}
303
+ marks={marksByRows([], rows)}
304
+ quickActions={[]}
305
+ observer={false}
306
+ onAnnotate={noop}
307
+ onReply={noop}
308
+ onUpdateAnnotation={noop}
309
+ onExit={noop}
310
+ />,
311
+ { width: 60, height: 12 },
312
+ );
313
+
314
+ await settle(setup);
315
+ const found = findById(setup.renderer.root, "diff-scroll");
316
+
317
+ if (!(found instanceof ScrollBoxRenderable)) throw new Error("diff-scroll is not a scrollbox");
318
+ const scrollbox = found;
319
+ const steps: number[] = [];
320
+ const caretCell = annotationPaletteFor(DARK).caretCell;
321
+
322
+ for (let index = 0; index < 45; index++) {
323
+ const before = scrollbox.scrollTop;
324
+
325
+ await press(setup, "down");
326
+ steps.push(scrollbox.scrollTop - before);
327
+ const caretRow = setup
328
+ .captureSpans()
329
+ .lines.findIndex((line) => line.spans.some((span) => hex(span.bg) === caretCell));
330
+ const markerRows = setup
331
+ .captureCharFrame()
332
+ .split("\n")
333
+ .flatMap((line, row) => (line.includes("▎") ? [row] : []));
334
+
335
+ expect(caretRow).toBeGreaterThanOrEqual(0);
336
+ expect(markerRows).toEqual([caretRow]);
337
+ }
338
+
339
+ expect(Math.max(...steps)).toBeLessThanOrEqual(1);
340
+ expect(steps.some((step) => step > 0)).toBe(true);
341
+ await setup.mockMouse.scroll(20, 5, "up");
342
+ await settle(setup);
343
+ const wrappedMarkerRows = setup
344
+ .captureCharFrame()
345
+ .split("\n")
346
+ .flatMap((line, row) => (line.includes("▎") ? [row] : []));
347
+
348
+ expect(wrappedMarkerRows).toEqual([scrollbox.viewport.screenY + scrollbox.viewport.height - 1]);
349
+ setup.renderer.destroy();
350
+ });
351
+
352
+ test("opening a comment on the diff's last line reveals its composer", async () => {
353
+ const rows = diffRows(tallPatch(24));
354
+ const setup = await testRender(
355
+ <DiffContentView
356
+ rows={rows}
357
+ session={fixtureDiffSession()}
358
+ marks={marksByRows([], rows)}
359
+ quickActions={[]}
360
+ observer={false}
361
+ onAnnotate={noop}
362
+ onReply={noop}
363
+ onUpdateAnnotation={noop}
364
+ onExit={noop}
365
+ />,
366
+ { width: 60, height: 10 },
367
+ );
368
+
369
+ await settle(setup);
370
+ for (let step = 0; step < rows.length + 2; step++) {
371
+ // eslint-disable-next-line no-await-in-loop
372
+ await press(setup, "down");
373
+ }
374
+ await typeText(setup, "bottom comment");
375
+
376
+ expect(setup.captureCharFrame()).toContain("● bottom comment");
377
+ setup.renderer.destroy();
378
+ }, 25000);
379
+
380
+ test("Up keeps the caret on the first screen row while wrapped lines scroll", async () => {
381
+ const lines = Array.from({ length: 24 }, (_, index) =>
382
+ index % 3 === 2 ? "-" : `-line ${index} ${"wrapped words here ".repeat(5)}`,
383
+ );
384
+ const rows = diffRows(
385
+ [
386
+ "diff --git a/long.md b/long.md",
387
+ "--- a/long.md",
388
+ "+++ /dev/null",
389
+ "@@ -1,24 +0,0 @@",
390
+ ...lines,
391
+ "",
392
+ ].join("\n"),
393
+ );
394
+ const setup = await testRender(
395
+ <DiffContentView
396
+ rows={rows}
397
+ session={fixtureDiffSession()}
398
+ marks={marksByRows([], rows)}
399
+ quickActions={[]}
400
+ observer={false}
401
+ onAnnotate={noop}
402
+ onReply={noop}
403
+ onUpdateAnnotation={noop}
404
+ onExit={noop}
405
+ />,
406
+ { width: 60, height: 12 },
407
+ );
408
+
409
+ await settle(setup);
410
+ const found = findById(setup.renderer.root, "diff-scroll");
411
+
412
+ if (!(found instanceof ScrollBoxRenderable)) throw new Error("diff-scroll is not a scrollbox");
413
+ const scrollbox = found;
414
+
415
+ for (let index = 0; index < 40; index++) await press(setup, "down");
416
+ let previousTop = scrollbox.scrollTop;
417
+ let scrolled = false;
418
+
419
+ for (let index = 0; index < 80; index++) {
420
+ await press(setup, "up");
421
+ const frame = setup.captureCharFrame().split("\n");
422
+ const marker = frame.findIndex((line) => line.includes("▎"));
423
+ const top = scrollbox.scrollTop;
424
+
425
+ expect(previousTop - top).toBeLessThanOrEqual(1);
426
+ if (top < previousTop) {
427
+ expect(marker).toBe(0);
428
+ scrolled = true;
429
+ }
430
+ previousTop = top;
431
+ }
432
+ expect(scrolled).toBe(true);
433
+ setup.renderer.destroy();
434
+ });