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

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 +1406 -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 +787 -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 +29 -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 +78 -0
  241. package/src/launch-layout.ts +62 -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,210 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ isCollapsed,
4
+ isDoubleClick,
5
+ nextWordStart,
6
+ orderedSpan,
7
+ positionAt,
8
+ previousWordStart,
9
+ snapSpanToWords,
10
+ snapToWords,
11
+ spanRangeInBlock,
12
+ tightenSpan,
13
+ wordRangeAt,
14
+ type LineGeometry,
15
+ } from "./thread-selection";
16
+
17
+ const TEXT = "All persistence lives in a new module.";
18
+ const BULLET = "store.ts - the store";
19
+ // block 3 wraps onto two rows, a blank row, then bullet block 4 on one row
20
+ const LINES: LineGeometry[] = [
21
+ { blockIndex: 3, start: 0, end: 21, x: 4, y: 10 },
22
+ { blockIndex: 3, start: 22, end: 38, x: 4, y: 11 },
23
+ { blockIndex: 4, start: 0, end: 20, x: 6, y: 13 },
24
+ ];
25
+ const textLengthOf = (blockIndex: number): number =>
26
+ blockIndex === 3 ? TEXT.length : BULLET.length;
27
+
28
+ describe("positionAt", () => {
29
+ test("maps a cell inside a line to the character under it", () => {
30
+ // Act
31
+ const position = positionAt(LINES, 4 + 6, 10);
32
+
33
+ // Assert: column 6 of the first line is the "r" in "persistence"
34
+ expect(position).toEqual({ blockIndex: 3, char: 6 });
35
+ expect(TEXT[6]).toBe("r");
36
+ });
37
+
38
+ test("past the right edge resolves to the position after the line's last char", () => {
39
+ // Assert
40
+ expect(positionAt(LINES, 90, 10)).toEqual({ blockIndex: 3, char: 21 });
41
+ });
42
+
43
+ test("a row without text resolves to the end of the closest line above", () => {
44
+ // Assert: the blank row between the blocks, and a row below everything
45
+ expect(positionAt(LINES, 30, 12)).toEqual({ blockIndex: 3, char: 38 });
46
+ expect(positionAt(LINES, 30, 40)).toEqual({ blockIndex: 4, char: 20 });
47
+ });
48
+
49
+ test("rows above all text resolve to the first character", () => {
50
+ // Assert
51
+ expect(positionAt(LINES, 30, 2)).toEqual({ blockIndex: 3, char: 0 });
52
+ });
53
+
54
+ test("a row of another block maps into that block", () => {
55
+ // Act
56
+ const position = positionAt(LINES, 6 + 3, 13);
57
+
58
+ // Assert
59
+ expect(position).toEqual({ blockIndex: 4, char: 3 });
60
+ expect(positionAt([], 5, 10)).toBeNull();
61
+ });
62
+ });
63
+
64
+ describe("orderedSpan and tightenSpan", () => {
65
+ test("a backward drag normalizes to document order", () => {
66
+ // Act
67
+ const span = orderedSpan({
68
+ anchor: { blockIndex: 4, char: 5 },
69
+ head: { blockIndex: 3, char: 6 },
70
+ });
71
+
72
+ // Assert
73
+ expect(span).toEqual({
74
+ start: { blockIndex: 3, char: 6 },
75
+ end: { blockIndex: 4, char: 5 },
76
+ });
77
+ });
78
+
79
+ test("a collapsed selection is no span", () => {
80
+ // Arrange
81
+ const selection = {
82
+ anchor: { blockIndex: 3, char: 6 },
83
+ head: { blockIndex: 3, char: 6 },
84
+ };
85
+
86
+ // Assert
87
+ expect(isCollapsed(selection)).toBe(true);
88
+ expect(orderedSpan(selection)).toBeNull();
89
+ });
90
+
91
+ test("a span opening at a block's end moves onto the next block", () => {
92
+ // Act: the pointer sat on the blank row above the bullet when the press landed
93
+ const tightened = tightenSpan(
94
+ {
95
+ start: { blockIndex: 3, char: TEXT.length },
96
+ end: { blockIndex: 4, char: 5 },
97
+ },
98
+ textLengthOf,
99
+ );
100
+
101
+ // Assert
102
+ expect(tightened).toEqual({
103
+ start: { blockIndex: 4, char: 0 },
104
+ end: { blockIndex: 4, char: 5 },
105
+ });
106
+ });
107
+
108
+ test("a span closing at a block's start moves back onto the previous block", () => {
109
+ // Act
110
+ const tightened = tightenSpan(
111
+ { start: { blockIndex: 3, char: 10 }, end: { blockIndex: 4, char: 0 } },
112
+ textLengthOf,
113
+ );
114
+
115
+ // Assert
116
+ expect(tightened).toEqual({
117
+ start: { blockIndex: 3, char: 10 },
118
+ end: { blockIndex: 3, char: TEXT.length },
119
+ });
120
+ });
121
+
122
+ test("a span with nothing left in it is null", () => {
123
+ // Assert
124
+ expect(
125
+ tightenSpan(
126
+ {
127
+ start: { blockIndex: 3, char: TEXT.length },
128
+ end: { blockIndex: 4, char: 0 },
129
+ },
130
+ textLengthOf,
131
+ ),
132
+ ).toBeNull();
133
+ });
134
+ });
135
+
136
+ describe("spanRangeInBlock", () => {
137
+ const span = {
138
+ start: { blockIndex: 3, char: 22 },
139
+ end: { blockIndex: 5, char: 4 },
140
+ };
141
+
142
+ test("the first block runs from the start to its end, middles fully, the last to the end char", () => {
143
+ // Assert
144
+ expect(spanRangeInBlock(span, 3, TEXT.length)).toEqual({
145
+ start: 22,
146
+ end: TEXT.length,
147
+ });
148
+ expect(spanRangeInBlock(span, 4, BULLET.length)).toEqual({
149
+ start: 0,
150
+ end: BULLET.length,
151
+ });
152
+ expect(spanRangeInBlock(span, 5, 30)).toEqual({ start: 0, end: 4 });
153
+ });
154
+
155
+ test("blocks outside the span have no range", () => {
156
+ // Assert
157
+ expect(spanRangeInBlock(span, 2, 10)).toBeNull();
158
+ expect(spanRangeInBlock(span, 6, 10)).toBeNull();
159
+ });
160
+ });
161
+
162
+ describe("word rules", () => {
163
+ test("the boundary rule attributes trailing whitespace to the preceding word", () => {
164
+ // Assert: char 3 is the space after "All"
165
+ expect(wordRangeAt(TEXT, 3)).toEqual({ start: 0, end: 3 });
166
+ });
167
+
168
+ test("snapToWords grows a partial range to whole words on both sides", () => {
169
+ // Assert: "rsistence liv" -> "persistence lives"
170
+ expect(snapToWords(TEXT, { start: 6, end: 19 })).toEqual({
171
+ start: 4,
172
+ end: 21,
173
+ });
174
+ });
175
+
176
+ test("snapSpanToWords snaps each end of a cross-block span within its own block", () => {
177
+ // Act: "rsistence ... st" -> "persistence ... store.ts"
178
+ const snapped = snapSpanToWords(
179
+ { start: { blockIndex: 3, char: 6 }, end: { blockIndex: 4, char: 2 } },
180
+ (blockIndex) => (blockIndex === 3 ? TEXT : BULLET),
181
+ );
182
+
183
+ // Assert
184
+ expect(snapped).toEqual({
185
+ start: { blockIndex: 3, char: 4 },
186
+ end: { blockIndex: 4, char: 8 },
187
+ });
188
+ });
189
+
190
+ test("word starts step in both directions and stop at the ends", () => {
191
+ // Assert
192
+ expect(nextWordStart(TEXT, 0)).toBe(4);
193
+ expect(nextWordStart(TEXT, 31)).toBeNull();
194
+ expect(previousWordStart(TEXT, 22)).toBe(16);
195
+ expect(previousWordStart(TEXT, 0)).toBeNull();
196
+ });
197
+ });
198
+
199
+ describe("isDoubleClick", () => {
200
+ test("same cell within the window counts, a moved or late press does not", () => {
201
+ // Arrange
202
+ const first = { time: 1000, x: 5, y: 7 };
203
+
204
+ // Assert
205
+ expect(isDoubleClick(first, { time: 1300, x: 5, y: 7 })).toBe(true);
206
+ expect(isDoubleClick(first, { time: 1300, x: 6, y: 7 })).toBe(false);
207
+ expect(isDoubleClick(first, { time: 1500, x: 5, y: 7 })).toBe(false);
208
+ expect(isDoubleClick(null, { time: 1300, x: 5, y: 7 })).toBe(false);
209
+ });
210
+ });
@@ -0,0 +1,243 @@
1
+ /**
2
+ * The thread view's text-selection model: a pure, renderer-free description
3
+ * of where the caret is and what is marked, plus the hit-testing that maps a
4
+ * pointer cell onto a character. Character-precise (partial words), spanning
5
+ * wrapped lines and consecutive blocks, normalized for backward drags. Word snapping
6
+ * happens only where the grammar asks for it: the typing anchor for a bare
7
+ * caret, and double-click word mode.
8
+ *
9
+ * Isolated on purpose: the interaction can be unit-tested and benchmarked
10
+ * without a terminal, and the view layer only wires events to it.
11
+ */
12
+
13
+ export interface TextPosition {
14
+ blockIndex: number;
15
+ char: number;
16
+ }
17
+
18
+ /** Where one visual line of a block sits on screen, in cells. */
19
+ export interface LineGeometry {
20
+ blockIndex: number;
21
+ /** Char range of the block's text this line shows. */
22
+ start: number;
23
+ end: number;
24
+ x: number;
25
+ y: number;
26
+ }
27
+
28
+ /** The held selection: `anchor` is where the press landed, `head` follows the pointer. */
29
+ export interface Selection {
30
+ anchor: TextPosition;
31
+ head: TextPosition;
32
+ }
33
+
34
+ export interface CharRange {
35
+ start: number;
36
+ end: number;
37
+ }
38
+
39
+ /** An ordered stretch of text, possibly across consecutive blocks. */
40
+ export interface TextSpan {
41
+ start: TextPosition;
42
+ end: TextPosition;
43
+ }
44
+
45
+ /** Document order: block first, then character. */
46
+ export function comparePositions(left: TextPosition, right: TextPosition): number {
47
+ return left.blockIndex - right.blockIndex || left.char - right.char;
48
+ }
49
+
50
+ /** Word boundaries of a block's text. */
51
+ export function wordRanges(text: string): CharRange[] {
52
+ const ranges: CharRange[] = [];
53
+ const matcher = /\S+/g;
54
+ let match: RegExpExecArray | null;
55
+
56
+ while ((match = matcher.exec(text)) !== null) {
57
+ ranges.push({ start: match.index, end: match.index + match[0].length });
58
+ }
59
+
60
+ return ranges;
61
+ }
62
+
63
+ /**
64
+ * The marked word for a char offset: the word containing it, or - when the
65
+ * caret sits in the whitespace immediately after a word - that preceding
66
+ * word, never the next one.
67
+ */
68
+ export function wordIndexAt(text: string, char: number): number {
69
+ const words = wordRanges(text);
70
+
71
+ for (let index = 0; index < words.length; index++) {
72
+ if (char < words[index]!.start) return Math.max(0, index - 1);
73
+ if (char < words[index]!.end) return index;
74
+ }
75
+
76
+ return Math.max(0, words.length - 1);
77
+ }
78
+
79
+ /** The word range the boundary rule assigns to a char offset; null for empty text. */
80
+ export function wordRangeAt(text: string, char: number): CharRange | null {
81
+ const words = wordRanges(text);
82
+
83
+ return words[wordIndexAt(text, char)] ?? null;
84
+ }
85
+
86
+ /**
87
+ * Map a pointer cell to a text position over every registered line. A row
88
+ * without text (a blank row, a card) resolves to the end of the closest line
89
+ * above it, rows above all text to the first character, and a cell past a
90
+ * line's last cell to the position after that line's last character, so a
91
+ * drag that runs off the right edge still selects to the line end.
92
+ */
93
+ export function positionAt(lines: LineGeometry[], x: number, y: number): TextPosition | null {
94
+ if (lines.length === 0) return null;
95
+ const ordered = lines.toSorted((left, right) => left.y - right.y || left.x - right.x);
96
+ const onRow = ordered.find((line) => line.y === y);
97
+
98
+ if (onRow) {
99
+ const column = Math.max(0, Math.min(onRow.end - onRow.start, x - onRow.x));
100
+
101
+ return { blockIndex: onRow.blockIndex, char: onRow.start + column };
102
+ }
103
+ const above = ordered.findLast((line) => line.y < y);
104
+
105
+ if (above) return { blockIndex: above.blockIndex, char: above.end };
106
+ const first = ordered[0]!;
107
+
108
+ return { blockIndex: first.blockIndex, char: first.start };
109
+ }
110
+
111
+ /** True when nothing is held: the caret is a point, not a range. */
112
+ export function isCollapsed(selection: Selection): boolean {
113
+ return comparePositions(selection.anchor, selection.head) === 0;
114
+ }
115
+
116
+ /** The selection in document order, or null when collapsed. */
117
+ export function orderedSpan(selection: Selection): TextSpan | null {
118
+ if (isCollapsed(selection)) return null;
119
+
120
+ return comparePositions(selection.anchor, selection.head) < 0
121
+ ? { start: selection.anchor, end: selection.head }
122
+ : { start: selection.head, end: selection.anchor };
123
+ }
124
+
125
+ /**
126
+ * Pull a span's edges off block boundaries: opening at a block's end or
127
+ * closing at a block's start marks nothing there, so the edge moves onto the
128
+ * neighbouring block. Null when nothing remains.
129
+ */
130
+ export function tightenSpan(
131
+ span: TextSpan,
132
+ textLengthOf: (blockIndex: number) => number,
133
+ ): TextSpan | null {
134
+ let { start, end } = span;
135
+
136
+ while (start.blockIndex < end.blockIndex && start.char >= textLengthOf(start.blockIndex)) {
137
+ start = { blockIndex: start.blockIndex + 1, char: 0 };
138
+ }
139
+ while (end.blockIndex > start.blockIndex && end.char === 0) {
140
+ end = {
141
+ blockIndex: end.blockIndex - 1,
142
+ char: textLengthOf(end.blockIndex - 1),
143
+ };
144
+ }
145
+ const clampedEnd = {
146
+ blockIndex: end.blockIndex,
147
+ char: Math.min(end.char, textLengthOf(end.blockIndex)),
148
+ };
149
+
150
+ return comparePositions(start, clampedEnd) < 0 ? { start, end: clampedEnd } : null;
151
+ }
152
+
153
+ /** The part of a span that falls on one block, as a char range; null when none. */
154
+ export function spanRangeInBlock(
155
+ span: TextSpan,
156
+ blockIndex: number,
157
+ textLength: number,
158
+ ): CharRange | null {
159
+ if (blockIndex < span.start.blockIndex || blockIndex > span.end.blockIndex) return null;
160
+ const start = blockIndex === span.start.blockIndex ? span.start.char : 0;
161
+ const end = blockIndex === span.end.blockIndex ? Math.min(span.end.char, textLength) : textLength;
162
+
163
+ return end > start ? { start, end } : null;
164
+ }
165
+
166
+ /** Grow a char range outward to the word boundaries it touches (double-click word mode). */
167
+ export function snapToWords(text: string, range: CharRange): CharRange {
168
+ const words = wordRanges(text);
169
+ const first = words.find((word) => range.start < word.end) ?? words[words.length - 1];
170
+ const last = words.toReversed().find((word) => range.end > word.start) ?? words[0];
171
+
172
+ if (!first || !last) return range;
173
+
174
+ return {
175
+ start: Math.min(first.start, range.start),
176
+ end: Math.max(last.end, range.end),
177
+ };
178
+ }
179
+
180
+ /** Snap both ends of a span to word boundaries, each within its own block. */
181
+ export function snapSpanToWords(span: TextSpan, textOf: (blockIndex: number) => string): TextSpan {
182
+ if (span.start.blockIndex === span.end.blockIndex) {
183
+ const snapped = snapToWords(textOf(span.start.blockIndex), {
184
+ start: span.start.char,
185
+ end: span.end.char,
186
+ });
187
+
188
+ return {
189
+ start: { blockIndex: span.start.blockIndex, char: snapped.start },
190
+ end: { blockIndex: span.end.blockIndex, char: snapped.end },
191
+ };
192
+ }
193
+ const startText = textOf(span.start.blockIndex);
194
+ const endText = textOf(span.end.blockIndex);
195
+
196
+ return {
197
+ start: {
198
+ blockIndex: span.start.blockIndex,
199
+ char: snapToWords(startText, {
200
+ start: span.start.char,
201
+ end: startText.length,
202
+ }).start,
203
+ },
204
+ end: {
205
+ blockIndex: span.end.blockIndex,
206
+ char: snapToWords(endText, { start: 0, end: span.end.char }).end,
207
+ },
208
+ };
209
+ }
210
+
211
+ export interface ClickStamp {
212
+ time: number;
213
+ x: number;
214
+ y: number;
215
+ }
216
+
217
+ /** Two presses on the same cell inside this window read as a double-click. */
218
+ export const DOUBLE_CLICK_WINDOW_MS = 400;
219
+
220
+ export function isDoubleClick(previous: ClickStamp | null, current: ClickStamp): boolean {
221
+ return (
222
+ previous !== null &&
223
+ previous.x === current.x &&
224
+ previous.y === current.y &&
225
+ current.time - previous.time <= DOUBLE_CLICK_WINDOW_MS
226
+ );
227
+ }
228
+
229
+ /** Next word start after `char`, or null past the last word. */
230
+ export function nextWordStart(text: string, char: number): number | null {
231
+ const next = wordRanges(text).find((word) => word.start > char);
232
+
233
+ return next ? next.start : null;
234
+ }
235
+
236
+ /** Previous word start before `char`, or null before the first word. */
237
+ export function previousWordStart(text: string, char: number): number | null {
238
+ const previous = wordRanges(text)
239
+ .toReversed()
240
+ .find((word) => word.start < char);
241
+
242
+ return previous ? previous.start : null;
243
+ }
@@ -0,0 +1,128 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ appendEntry,
4
+ createBranch,
5
+ historyFromLinear,
6
+ labelTip,
7
+ navigateTo,
8
+ switchBranch,
9
+ tipOf,
10
+ type SessionHistory,
11
+ } from "@cueloop/schema";
12
+ import { entryTarget, treeRows } from "./tree-view";
13
+
14
+ const AT = "2026-09-01T10:00:00.000Z";
15
+ const LATER = "2026-09-01T11:00:00.000Z";
16
+
17
+ /** rev1 -> comment a1 on main; a branch "alt" off a1 with its own comment; main continues with rev2. */
18
+ function forked(): SessionHistory {
19
+ let history = historyFromLinear({
20
+ id: "ses_1",
21
+ revisions: [{ revision: 1, content: "v1", submittedAt: AT }],
22
+ annotations: [],
23
+ message: null,
24
+ createdAt: AT,
25
+ });
26
+
27
+ history = appendEntry(history, { type: "comment", annotationId: "a1", createdAt: AT }).history;
28
+ history = labelTip(history, "start");
29
+ history = createBranch(history, "alt");
30
+ history = appendEntry(history, { type: "comment", annotationId: "b1", createdAt: AT }).history;
31
+ history = switchBranch(history, "main");
32
+ history = appendEntry(history, {
33
+ type: "revision",
34
+ by: "agent",
35
+ content: "v2",
36
+ createdAt: LATER,
37
+ }).history;
38
+
39
+ return history;
40
+ }
41
+
42
+ describe("treeRows", () => {
43
+ test("the current branch is the trunk; other segments indent under the entry they grew from", () => {
44
+ // Act
45
+ const rows = treeRows(forked());
46
+
47
+ // Assert: rev1, a1 (labelled, alt hangs off it), the alt comment indented, then rev2 as main's tip
48
+ expect(rows.map((row) => [row.depth, row.text, row.onPath])).toEqual([
49
+ [0, "revision 1", true],
50
+ [0, "comment", true],
51
+ [1, "comment", false],
52
+ [0, "revision 2", true],
53
+ ]);
54
+ expect(rows[1]!.label).toBe("start");
55
+ expect(rows[2]!.tips).toEqual(["alt"]);
56
+ expect(rows[3]!.tips).toEqual(["main"]);
57
+ expect(rows[3]!.isCurrentTip).toBe(true);
58
+ });
59
+
60
+ test("on the other branch the trunk follows it and main's continuation indents", () => {
61
+ // Act
62
+ const rows = treeRows(switchBranch(forked(), "alt"));
63
+
64
+ // Assert
65
+ expect(rows.map((row) => [row.depth, row.text, row.onPath])).toEqual([
66
+ [0, "revision 1", true],
67
+ [0, "comment", true],
68
+ [1, "revision 2", false],
69
+ [0, "comment", true],
70
+ ]);
71
+ expect(rows[3]!.isCurrentTip).toBe(true);
72
+ });
73
+
74
+ test("a branch summary reads as a return with its text", () => {
75
+ // Arrange
76
+ const history = navigateTo(forked(), forked().entries[0]!.id, { summary: "too early" });
77
+
78
+ // Act
79
+ const rows = treeRows(history);
80
+
81
+ // Assert: the summary hangs under rev1 as the trunk, the abandoned segment indents
82
+ expect(rows.find((row) => row.glyph === "↩")?.text).toBe('"too early"');
83
+ expect(rows.find((row) => row.glyph === "↩")?.isCurrentTip).toBe(true);
84
+ });
85
+ });
86
+
87
+ describe("entryTarget", () => {
88
+ test("the tip is here, another tip is a switch, an ancestor is a navigate on its branch", () => {
89
+ // Arrange
90
+ const history = forked();
91
+ const [rev1, a1, altComment, rev2] = history.entries;
92
+
93
+ // Assert
94
+ expect(entryTarget(history, rev2!.id)).toEqual({ kind: "here" });
95
+ expect(entryTarget(history, altComment!.id)).toEqual({ kind: "switch", branch: "alt" });
96
+ expect(entryTarget(history, a1!.id)).toEqual({
97
+ kind: "navigate",
98
+ entryId: a1!.id,
99
+ branch: "main",
100
+ });
101
+ expect(entryTarget(history, rev1!.id)?.kind).toBe("navigate");
102
+ expect(tipOf(history)).toBe(rev2!.id);
103
+ });
104
+
105
+ test("an entry only another branch reaches is a navigate on that branch", () => {
106
+ // Arrange: alt gets a second comment so its first is no longer a tip
107
+ let history = switchBranch(forked(), "alt");
108
+
109
+ history = appendEntry(history, {
110
+ type: "comment",
111
+ annotationId: "b2",
112
+ createdAt: LATER,
113
+ }).history;
114
+ const altFirst = history.entries.find(
115
+ (entry) => entry.type === "comment" && entry.annotationId === "b1",
116
+ )!;
117
+
118
+ history = switchBranch(history, "main");
119
+
120
+ // Assert
121
+ expect(entryTarget(history, altFirst.id)).toEqual({
122
+ kind: "navigate",
123
+ entryId: altFirst.id,
124
+ branch: "alt",
125
+ });
126
+ expect(entryTarget(history, "e_nope")).toBeNull();
127
+ });
128
+ });