@cueloop/client 0.1.0-alpha.9 → 0.1.0-alpha.90

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