@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.71

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 (248) 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 +39 -32
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +76 -50
  10. package/src/App.diff.test.tsx +156 -42
  11. package/src/App.export.test.tsx +58 -40
  12. package/src/App.plan-review.test.tsx +363 -0
  13. package/src/App.prototype-markdown.test.tsx +92 -0
  14. package/src/App.prototype.test.tsx +176 -0
  15. package/src/App.readonly.test.tsx +128 -67
  16. package/src/App.rendering.test.tsx +61 -35
  17. package/src/App.submit-confirm.test.tsx +191 -0
  18. package/src/App.test.tsx +310 -114
  19. package/src/App.toast.test.tsx +89 -0
  20. package/src/App.tsx +1043 -798
  21. package/src/App.walk.test.tsx +298 -0
  22. package/src/App.workbench.test.tsx +495 -0
  23. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  24. package/src/annotation-palette.ts +61 -0
  25. package/src/app-key-state.ts +61 -0
  26. package/src/app-screens.tsx +432 -0
  27. package/src/app-view-model.ts +159 -0
  28. package/src/attribution.test.ts +31 -0
  29. package/src/attribution.ts +42 -0
  30. package/src/clipboard.test.ts +23 -0
  31. package/src/clipboard.ts +33 -0
  32. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  33. package/src/components/AnnotatableFileView.test.tsx +102 -0
  34. package/src/components/AnnotatableFileView.tsx +126 -0
  35. package/src/components/AnnotationCards.stories.tsx +87 -0
  36. package/src/components/AnnotationCards.tsx +303 -0
  37. package/src/components/AppHeader.stories.tsx +50 -0
  38. package/src/components/AppHeader.tsx +83 -0
  39. package/src/components/AppShell.stories.tsx +89 -0
  40. package/src/components/AppShell.tsx +290 -0
  41. package/src/components/AppShell.zoom.test.tsx +135 -0
  42. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  43. package/src/components/BareWorkbenchFileView.tsx +64 -0
  44. package/src/components/Breadcrumb.stories.tsx +19 -0
  45. package/src/components/Breadcrumb.tsx +45 -0
  46. package/src/components/ChangesColumn.stories.tsx +39 -0
  47. package/src/components/ChangesColumn.tsx +171 -0
  48. package/src/components/CodeBlock.stories.tsx +42 -0
  49. package/src/components/CodeBlock.tsx +84 -0
  50. package/src/components/CompletionOverlay.stories.tsx +23 -0
  51. package/src/components/CompletionOverlay.tsx +60 -0
  52. package/src/components/ConfirmCard.stories.tsx +35 -0
  53. package/src/components/ConfirmCard.tsx +128 -0
  54. package/src/components/ConfirmDialog.stories.tsx +21 -0
  55. package/src/components/ConfirmDialog.tsx +66 -0
  56. package/src/components/DiffContentView.scroll.test.tsx +121 -0
  57. package/src/components/DiffContentView.split.test.tsx +140 -0
  58. package/src/components/DiffContentView.stories.tsx +80 -0
  59. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  60. package/src/components/DiffContentView.tsx +859 -0
  61. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  62. package/src/components/DiscussionMarkerRail.tsx +181 -0
  63. package/src/components/EditorGrid.stories.tsx +59 -0
  64. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  65. package/src/components/EditorGrid.tsx +368 -0
  66. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  67. package/src/components/FileContentsView.stories.tsx +16 -0
  68. package/src/components/FileContentsView.test.tsx +29 -0
  69. package/src/components/FileContentsView.tsx +98 -0
  70. package/src/components/KeybindsDialog.stories.tsx +31 -0
  71. package/src/components/KeybindsDialog.tsx +103 -0
  72. package/src/components/MenuBar.stories.tsx +28 -0
  73. package/src/components/MenuBar.tsx +44 -0
  74. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  75. package/src/components/OverlayScrollbar.test.tsx +139 -0
  76. package/src/components/OverlayScrollbar.tsx +129 -0
  77. package/src/components/PaneGrid.stories.tsx +84 -0
  78. package/src/components/PaneGrid.tsx +128 -0
  79. package/src/components/PanelColumn.stories.tsx +25 -0
  80. package/src/components/PanelColumn.tsx +95 -0
  81. package/src/components/ProjectTreeView.stories.tsx +25 -0
  82. package/src/components/ProjectTreeView.test.tsx +32 -0
  83. package/src/components/ProjectTreeView.tsx +83 -0
  84. package/src/components/PromptDialog.stories.tsx +36 -0
  85. package/src/components/PromptDialog.tsx +87 -0
  86. package/src/components/PrototypeContentView.stories.tsx +19 -0
  87. package/src/components/PrototypeContentView.tsx +446 -0
  88. package/src/components/RootOverlay.stories.tsx +44 -0
  89. package/src/components/RootOverlay.tsx +35 -0
  90. package/src/components/ScrollArea.stories.tsx +20 -0
  91. package/src/components/ScrollArea.tsx +44 -0
  92. package/src/components/SettingsDialog.stories.tsx +117 -0
  93. package/src/components/SettingsDialog.tsx +195 -0
  94. package/src/components/SettingsRows.stories.tsx +18 -0
  95. package/src/components/SettingsRows.tsx +112 -0
  96. package/src/components/ShellHeader.stories.tsx +50 -0
  97. package/src/components/ShellHeader.tsx +137 -0
  98. package/src/components/ThreadFooter.stories.tsx +30 -0
  99. package/src/components/ThreadFooter.test.tsx +31 -0
  100. package/src/components/ThreadFooter.tsx +54 -0
  101. package/src/components/ThreadTree.stories.tsx +46 -0
  102. package/src/components/ThreadTree.tsx +219 -0
  103. package/src/components/ThreadView.selection.test.tsx +812 -0
  104. package/src/components/ThreadView.slash.test.tsx +158 -0
  105. package/src/components/ThreadView.stories.tsx +26 -0
  106. package/src/components/ThreadView.tsx +330 -0
  107. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  108. package/src/components/ThreadsSidebar.tsx +71 -0
  109. package/src/components/Toast.stories.tsx +12 -0
  110. package/src/components/Toast.tsx +45 -0
  111. package/src/components/Tooltip.stories.tsx +26 -0
  112. package/src/components/Tooltip.tsx +72 -0
  113. package/src/components/TreePane.stories.tsx +106 -0
  114. package/src/components/TreePane.tsx +157 -0
  115. package/src/components/WalkWizard.stories.tsx +100 -0
  116. package/src/components/WalkWizard.tsx +152 -0
  117. package/src/components/WelcomePlayground.stories.tsx +12 -0
  118. package/src/components/WelcomePlayground.test.tsx +47 -0
  119. package/src/components/WelcomePlayground.tsx +140 -0
  120. package/src/components/__snapshots__/stories.test.tsx.snap +1845 -0
  121. package/src/components/agent-launcher.stories.tsx +26 -0
  122. package/src/components/agent-launcher.test.tsx +50 -0
  123. package/src/components/agent-launcher.tsx +149 -0
  124. package/src/components/diff-content-view-layout.test.ts +198 -0
  125. package/src/components/diff-content-view-layout.ts +84 -0
  126. package/src/components/editor-grid.test.ts +86 -0
  127. package/src/components/editor-grid.ts +138 -0
  128. package/src/components/fade-title.test.ts +49 -0
  129. package/src/components/fade-title.ts +61 -0
  130. package/src/components/file-tree.test.ts +60 -0
  131. package/src/components/file-tree.ts +112 -0
  132. package/src/components/pages/Workspace.stories.tsx +181 -0
  133. package/src/components/primitives/Button.stories.tsx +32 -0
  134. package/src/components/primitives/Button.tsx +52 -0
  135. package/src/components/primitives/Card.stories.tsx +25 -0
  136. package/src/components/primitives/Card.tsx +60 -0
  137. package/src/components/primitives/Dialog.stories.tsx +24 -0
  138. package/src/components/primitives/Dialog.tsx +66 -0
  139. package/src/components/primitives/IconButton.stories.tsx +36 -0
  140. package/src/components/primitives/IconButton.tsx +62 -0
  141. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  142. package/src/components/primitives/StatusBar.tsx +23 -0
  143. package/src/components/primitives/Tabs.stories.tsx +29 -0
  144. package/src/components/primitives/Tabs.tsx +102 -0
  145. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  146. package/src/components/primitives/Toolbar.tsx +14 -0
  147. package/src/components/primitives/Tree.stories.tsx +124 -0
  148. package/src/components/primitives/Tree.tsx +96 -0
  149. package/src/components/primitives/frame.test.ts +12 -0
  150. package/src/components/primitives/frame.ts +14 -0
  151. package/src/components/primitives/icons.ts +61 -0
  152. package/src/components/primitives/tree-model.test.ts +99 -0
  153. package/src/components/primitives/tree-model.ts +137 -0
  154. package/src/components/quick-actions-editor.stories.tsx +54 -0
  155. package/src/components/quick-actions-editor.test.tsx +49 -0
  156. package/src/components/quick-actions-editor.tsx +150 -0
  157. package/src/components/session-tree.test.ts +134 -0
  158. package/src/components/session-tree.ts +108 -0
  159. package/src/components/stories-app.tsx +184 -0
  160. package/src/components/stories.test.tsx +66 -0
  161. package/src/components/story-fixtures.ts +102 -0
  162. package/src/components/story-tree.test.ts +100 -0
  163. package/src/components/story.ts +142 -0
  164. package/src/components/syntax-highlight.ts +110 -0
  165. package/src/components/terminal-pane.test.tsx +40 -0
  166. package/src/components/terminal-pane.ts +199 -0
  167. package/src/components/theme-context.stories.tsx +46 -0
  168. package/src/components/theme-context.tsx +37 -0
  169. package/src/components/truncate-text.ts +7 -0
  170. package/src/config.test.ts +461 -31
  171. package/src/config.ts +389 -57
  172. package/src/diff-fold.test.ts +78 -0
  173. package/src/diff-fold.ts +126 -0
  174. package/src/diff-hunk-curate.test.ts +315 -0
  175. package/src/diff-hunk-curate.ts +153 -0
  176. package/src/diff-intraline.test.ts +165 -0
  177. package/src/diff-intraline.ts +146 -0
  178. package/src/diff-syntax.test.ts +112 -0
  179. package/src/diff-syntax.ts +179 -0
  180. package/src/discussions.ts +79 -0
  181. package/src/editor.test.ts +149 -0
  182. package/src/editor.ts +139 -9
  183. package/src/ghostty-terminal.test.ts +85 -0
  184. package/src/ghostty-terminal.ts +199 -0
  185. package/src/herdr.test.ts +9 -34
  186. package/src/herdr.ts +25 -21
  187. package/src/index.ts +11 -0
  188. package/src/integrations.test.ts +41 -0
  189. package/src/integrations.ts +32 -0
  190. package/src/intent-dispatch.test.ts +698 -0
  191. package/src/intent-dispatch.ts +700 -0
  192. package/src/key-bindings.test.ts +156 -0
  193. package/src/key-bindings.ts +465 -0
  194. package/src/keymap.test.ts +611 -0
  195. package/src/keymap.ts +410 -0
  196. package/src/kitty-image.test.ts +66 -0
  197. package/src/kitty-image.ts +138 -0
  198. package/src/mark-runs.ts +120 -0
  199. package/src/prototype-browser.test.ts +56 -0
  200. package/src/prototype-browser.ts +307 -0
  201. package/src/prototype-pixels.tsx +35 -0
  202. package/src/pty.test.ts +81 -0
  203. package/src/pty.ts +245 -0
  204. package/src/ready-signal.test.ts +85 -0
  205. package/src/ready-signal.ts +52 -0
  206. package/src/run.ts +30 -9
  207. package/src/serve.test.ts +24 -12
  208. package/src/serve.ts +20 -9
  209. package/src/share.ts +215 -0
  210. package/src/skills.test.ts +74 -0
  211. package/src/skills.ts +68 -0
  212. package/src/slash-palette.test.ts +54 -0
  213. package/src/slash-palette.ts +104 -0
  214. package/src/split-diff.test.ts +110 -0
  215. package/src/split-diff.ts +149 -0
  216. package/src/test-support.test.ts +264 -0
  217. package/src/test-support.ts +355 -0
  218. package/src/theme-presets.test.ts +51 -0
  219. package/src/theme-presets.ts +214 -0
  220. package/src/theme.ts +84 -19
  221. package/src/thread-chords.test.ts +170 -0
  222. package/src/thread-chords.ts +169 -0
  223. package/src/thread-controller.curate.test.ts +438 -0
  224. package/src/thread-controller.live-diff.test.ts +199 -0
  225. package/src/thread-controller.share.test.ts +472 -0
  226. package/src/thread-controller.tree.test.ts +255 -0
  227. package/src/thread-controller.ts +1509 -0
  228. package/src/thread-selection.test.ts +210 -0
  229. package/src/thread-selection.ts +243 -0
  230. package/src/tree-view.test.ts +128 -0
  231. package/src/tree-view.ts +148 -0
  232. package/src/use-annotation-surface.tsx +851 -0
  233. package/src/use-changes-workbench.ts +153 -0
  234. package/src/use-frame-measure.ts +38 -0
  235. package/src/use-settings-dialog.tsx +274 -0
  236. package/src/use-terminal-virtualizer.ts +233 -0
  237. package/src/version.ts +5 -0
  238. package/src/view-diff.test.ts +66 -9
  239. package/src/view-diff.ts +231 -23
  240. package/src/view-plan.regression.test.ts +60 -0
  241. package/src/view-plan.test.ts +406 -0
  242. package/src/view-plan.ts +570 -0
  243. package/src/walk.test.ts +121 -0
  244. package/src/walk.ts +74 -0
  245. package/src/syntax.test.ts +0 -41
  246. package/src/syntax.ts +0 -88
  247. package/src/view.test.ts +0 -157
  248. package/src/view.ts +0 -312
@@ -0,0 +1,406 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ blockRuns,
4
+ buildDisplay,
5
+ displayText,
6
+ marksByDisplay,
7
+ nextWorkBlock,
8
+ overlayMarks,
9
+ renderedOffsetAtOrAfter,
10
+ renderedOffsetAtOrBefore,
11
+ renderedOffsetFor,
12
+ safeLinkHref,
13
+ spanFromRange,
14
+ spanKey,
15
+ startSpan,
16
+ workRangeForRendered,
17
+ } from "./view-plan";
18
+ import { cutBlock, parseBlocks, restoreBlock, restoreLine, type Annotation } from "@cueloop/schema";
19
+
20
+ const BASE = `# Plan
21
+
22
+ ## Context
23
+
24
+ The daemon persists sessions to disk atomically.
25
+
26
+ - first item
27
+ - second item
28
+ `;
29
+
30
+ describe("buildDisplay reconciliation", () => {
31
+ test("no working copy: everything is same", () => {
32
+ // Act
33
+ const display = buildDisplay(BASE);
34
+
35
+ // Assert
36
+ expect(display.every((block) => block.type === "same")).toBe(true);
37
+ });
38
+
39
+ test("reworded block pairs as mod (similar)", () => {
40
+ // Arrange
41
+ const working = BASE.replace("persists sessions to disk", "writes sessions to disk");
42
+
43
+ // Act
44
+ const display = buildDisplay(BASE, working);
45
+
46
+ // Assert
47
+ const mod = display.find((block) => block.type === "mod")!;
48
+
49
+ expect(mod.base!.text).toContain("persists");
50
+ expect(mod.work!.text).toContain("writes");
51
+ });
52
+
53
+ test("unrelated cut + insert do NOT merge into a mod", () => {
54
+ // Arrange
55
+ const working = BASE.replace("- second item\n", "") + "\nA totally new closing note.\n";
56
+
57
+ // Act
58
+ const display = buildDisplay(BASE, working);
59
+
60
+ // Assert
61
+ expect(
62
+ display.some((block) => block.type === "del" && block.base!.text === "second item"),
63
+ ).toBe(true);
64
+ expect(
65
+ display.some((block) => block.type === "add" && block.work!.text.includes("closing note")),
66
+ ).toBe(true);
67
+ expect(display.some((block) => block.type === "mod")).toBe(false);
68
+ });
69
+
70
+ test("ordered list items get running numbers", () => {
71
+ // Act
72
+ const display = buildDisplay("1. one\n2. two\n3. three\n");
73
+
74
+ // Assert
75
+ expect(display.map((block) => block.orderedItemNumber)).toEqual([1, 2, 3]);
76
+ });
77
+ });
78
+
79
+ describe("blockRuns + overlayMarks", () => {
80
+ test("mod blocks emit word-diff runs that reconstruct both sides", () => {
81
+ // Arrange
82
+ const block = buildDisplay("plain old text here\n", "plain new text here\n")[0]!;
83
+
84
+ // Act
85
+ const runs = blockRuns(block, true);
86
+
87
+ // Assert
88
+ const oldSide = runs
89
+ .filter((run) => run.role !== "ins")
90
+ .map((run) => run.text)
91
+ .join("");
92
+ const newSide = runs
93
+ .filter((run) => run.role !== "del")
94
+ .map((run) => run.text)
95
+ .join("");
96
+
97
+ expect(oldSide).toBe("plain old text here");
98
+ expect(newSide).toBe("plain new text here");
99
+ });
100
+
101
+ test("marks bind to working-text offsets and skip del runs", () => {
102
+ // Arrange
103
+ const block = buildDisplay("old words linger\n", "new words linger\n")[0]!;
104
+ const runs = blockRuns(block, true);
105
+
106
+ // Act
107
+ const marked = overlayMarks(runs, [{ start: 4, end: 9, role: "mark-comment" }]); // "words"
108
+
109
+ // Assert
110
+ const markedText = marked
111
+ .filter((run) => run.role === "mark-comment")
112
+ .map((run) => run.text)
113
+ .join("");
114
+
115
+ expect(markedText).toBe("words");
116
+ // del run text untouched
117
+ expect(marked.some((run) => run.role === "del" && run.text === "old")).toBe(true);
118
+ });
119
+
120
+ test("marks never override ins emphasis", () => {
121
+ // Arrange
122
+ const block = buildDisplay("keep\n", "keep added\n")[0]!;
123
+
124
+ // Act
125
+ const runs = overlayMarks(blockRuns(block, true), [
126
+ { start: 0, end: 10, role: "mark-comment" },
127
+ ]);
128
+
129
+ // Assert
130
+ expect(runs.some((run) => run.role === "ins")).toBe(true);
131
+ });
132
+ });
133
+
134
+ describe("M4: link hrefs are scheme-validated for OSC 8 (#236)", () => {
135
+ test("trusts http(s) and mailto, drops everything else", () => {
136
+ // Assert
137
+ expect(safeLinkHref("https://example.com")).toBe("https://example.com");
138
+ expect(safeLinkHref("http://x")).toBe("http://x");
139
+ expect(safeLinkHref("mailto:a@b.c")).toBe("mailto:a@b.c");
140
+ expect(safeLinkHref("javascript:alert(1)")).toBeUndefined();
141
+ expect(safeLinkHref("./relative/path")).toBeUndefined();
142
+ expect(safeLinkHref(undefined)).toBeUndefined();
143
+ });
144
+
145
+ test("a control byte or space in the target drops the link", () => {
146
+ // Assert - ESC and BEL could splice into the OSC 8 escape stream
147
+ expect(safeLinkHref("https://x\x1b]8;;evil\x07")).toBeUndefined();
148
+ expect(safeLinkHref("https://x\x07")).toBeUndefined();
149
+ expect(safeLinkHref("https://x y")).toBeUndefined();
150
+ });
151
+
152
+ test("blockRuns carries the link href through to the link run", () => {
153
+ // Act
154
+ const block = buildDisplay("see [docs](https://example.com) now\n")[0]!;
155
+ const link = blockRuns(block, true).find((run) => run.role === "link")!;
156
+
157
+ // Assert
158
+ expect(link.text).toBe("docs");
159
+ expect(link.href).toBe("https://example.com");
160
+ });
161
+ });
162
+
163
+ describe("M3: inline emphasis composes with word-diff (#235)", () => {
164
+ test("context emphasis survives; added text keeps the green diff role; markers concealed", () => {
165
+ // Arrange - "**this**" is unchanged (context, stays emphasized); "safe"->"sound" is the edit
166
+ const block = buildDisplay("keep **this** safe\n", "keep **this** sound\n")[0]!;
167
+
168
+ // Act
169
+ const runs = blockRuns(block, true);
170
+
171
+ // Assert - no marker text renders anywhere
172
+ expect(runs.every((run) => !run.text.includes("*"))).toBe(true);
173
+ // the unchanged emphasized word keeps its strong role (context, not diffed)
174
+ expect(runs.find((run) => run.text === "this")!.role).toBe("strong");
175
+ // the added word is green (ins wins over any emphasis), the removed one opaque
176
+ expect(runs.find((run) => run.text.includes("sound"))!.role).toBe("ins");
177
+ expect(runs.some((run) => run.role === "del" && run.start === null)).toBe(true);
178
+ });
179
+
180
+ test("markers inside added text conceal while the run stays a positioned ins", () => {
181
+ // Arrange - an emphasized word is added into an otherwise unchanged line
182
+ const block = buildDisplay("keep it safe\n", "keep it **very** safe\n")[0]!;
183
+
184
+ // Act
185
+ const runs = blockRuns(block, true);
186
+ const added = runs.find((run) => run.text === "very")!;
187
+
188
+ // Assert - concealed markers, green role, and an exact work offset back into work text
189
+ expect(added.role).toBe("ins");
190
+ expect(added.start).not.toBeNull();
191
+ expect(block.work!.text.slice(added.start!, added.start! + "very".length)).toBe("very");
192
+ expect(runs.every((run) => !run.text.includes("*"))).toBe(true);
193
+ });
194
+
195
+ test("an anchor over an emphasized context word round-trips to its work offsets", () => {
196
+ // Arrange
197
+ const block = buildDisplay("hold **steady** now\n", "hold **steady** later\n")[0]!;
198
+ const runs = blockRuns(block, true);
199
+ const work = block.work!.text;
200
+ const start = work.indexOf("steady");
201
+
202
+ // Act
203
+ const rendered = renderedOffsetFor(runs, start);
204
+
205
+ // Assert
206
+ expect(rendered).not.toBeNull();
207
+ expect(workRangeForRendered(runs, rendered!, rendered! + "steady".length)).toEqual({
208
+ start,
209
+ end: start + "steady".length,
210
+ });
211
+ });
212
+ });
213
+
214
+ describe("marksByDisplay", () => {
215
+ test("annotations resolve into display coordinates", () => {
216
+ // Arrange
217
+ const working = BASE.replace("first item", "first item edited beyond recognition of it");
218
+ const display = buildDisplay(BASE, working);
219
+ const ann: Annotation = {
220
+ id: "a1",
221
+ kind: "comment",
222
+ anchor: { quote: "atomically", prefix: "to disk ", suffix: "." },
223
+ body: "define atomically",
224
+ createdAt: "",
225
+ };
226
+
227
+ // Act
228
+ const map = marksByDisplay([ann], display);
229
+
230
+ // Assert
231
+ const [displayIndex, marks] = [...map.entries()][0]!;
232
+
233
+ expect(displayText(display[displayIndex]!)).toContain("atomically");
234
+ expect(marks[0]!.annotationId).toBe("a1");
235
+ });
236
+
237
+ test("orphaned annotations produce no marks", () => {
238
+ // Arrange
239
+ const display = buildDisplay(BASE);
240
+ const ann: Annotation = {
241
+ id: "a1",
242
+ kind: "comment",
243
+ anchor: { quote: "text that is gone", prefix: "", suffix: "" },
244
+ body: "",
245
+ createdAt: "",
246
+ };
247
+
248
+ // Assert
249
+ expect(marksByDisplay([ann], display).size).toBe(0);
250
+ });
251
+ });
252
+
253
+ describe("rendered/work offset mapping", () => {
254
+ test("plain blocks map 1:1 in both directions", () => {
255
+ // Arrange
256
+ const block = buildDisplay("alpha beta gamma\n")[0]!;
257
+ const runs = blockRuns(block, true);
258
+
259
+ // Assert
260
+ expect(renderedOffsetFor(runs, 6)).toBe(6);
261
+ expect(workRangeForRendered(runs, 6, 10)).toEqual({ start: 6, end: 10 });
262
+ });
263
+
264
+ test("inline del runs shift rendered offsets in word-diffed blocks", () => {
265
+ // Arrange
266
+ // rendered: "old new words linger" - "old " is a del run without offsets
267
+ const block = buildDisplay("old words linger\n", "new words linger\n")[0]!;
268
+ const runs = blockRuns(block, true);
269
+ const workText = displayText(block);
270
+ const wordsAt = workText.indexOf("words");
271
+
272
+ // Act
273
+ const rendered = renderedOffsetFor(runs, wordsAt)!;
274
+
275
+ // Assert
276
+ const renderedText = runs.map((run) => run.text).join("");
277
+
278
+ expect(renderedText.slice(rendered, rendered + "words".length)).toBe("words");
279
+ // reading a rendered selection over "words" recovers the work range
280
+ expect(workRangeForRendered(runs, rendered, rendered + "words".length)).toEqual({
281
+ start: wordsAt,
282
+ end: wordsAt + "words".length,
283
+ });
284
+ });
285
+
286
+ test("a rendered selection spanning a del run keeps only positioned text", () => {
287
+ // Arrange
288
+ const block = buildDisplay("old words\n", "new words\n")[0]!;
289
+ const runs = blockRuns(block, true);
290
+ const renderedText = runs.map((run) => run.text).join("");
291
+
292
+ // Act
293
+ // select everything rendered: the work range is the whole work text
294
+ const range = workRangeForRendered(runs, 0, renderedText.length)!;
295
+
296
+ // Assert
297
+ expect(displayText(block).slice(range.start, range.end)).toBe(displayText(block));
298
+ });
299
+
300
+ test("offsets outside any positioned run map to null", () => {
301
+ // Arrange
302
+ const runs = blockRuns(buildDisplay("short\n")[0]!, true);
303
+
304
+ // Assert
305
+ expect(renderedOffsetFor(runs, 99)).toBeNull();
306
+ expect(workRangeForRendered(runs, 90, 99)).toBeNull();
307
+ });
308
+
309
+ test("a span starting/ending on concealed markers snaps to the visible word", () => {
310
+ // Arrange - the word span over "**very**" covers the markers, which have
311
+ // no rendered cells; the clamped lookups land on "very" itself
312
+ const text = "keep it **very** safe\n";
313
+ const runs = blockRuns(buildDisplay(text)[0]!, true);
314
+ const spanStart = text.indexOf("**very**");
315
+ const spanEnd = spanStart + "**very**".length;
316
+
317
+ // Act
318
+ const renderedStart = renderedOffsetAtOrAfter(runs, spanStart)!;
319
+ const renderedEnd = renderedOffsetAtOrBefore(runs, spanEnd - 1)!;
320
+
321
+ // Assert
322
+ const renderedText = runs.map((run) => run.text).join("");
323
+
324
+ expect(renderedText.slice(renderedStart, renderedEnd + 1)).toBe("very");
325
+ // exact hits behave like renderedOffsetFor
326
+ expect(renderedOffsetAtOrAfter(runs, 0)).toBe(0);
327
+ expect(renderedOffsetAtOrBefore(runs, 0)).toBe(0);
328
+ // nothing positioned beyond the text end
329
+ expect(renderedOffsetAtOrAfter(runs, 99)).toBeNull();
330
+ });
331
+ });
332
+
333
+ describe("span mode", () => {
334
+ const text = "one two three four";
335
+
336
+ test("v selects the first word; l grows; w slides; $ to end", () => {
337
+ // Act
338
+ let span = startSpan(0, text)!;
339
+
340
+ // Assert
341
+ expect(text.slice(span.start, span.end)).toBe("one");
342
+
343
+ // Act
344
+ span = spanKey(span, "l", text);
345
+
346
+ // Assert
347
+ expect(text.slice(span.start, span.end)).toBe("one two");
348
+
349
+ // Act
350
+ span = spanKey(span, "w", text);
351
+
352
+ // Assert
353
+ expect(text.slice(span.start, span.end)).toBe("two three");
354
+
355
+ // Act
356
+ span = spanKey(span, "$", text);
357
+
358
+ // Assert
359
+ expect(text.slice(span.start, span.end)).toBe("two three four");
360
+ });
361
+
362
+ test("spanFromRange snaps a mid-word mouse drag to whole words", () => {
363
+ // Arrange - a drag from inside "two" to inside "three"
364
+ const dragStart = text.indexOf("two") + 1;
365
+ const dragEnd = text.indexOf("three") + 2;
366
+
367
+ // Act
368
+ const span = spanFromRange(0, text, dragStart, dragEnd)!;
369
+
370
+ // Assert - snaps outward to cover both touched words, ready for w/b/l/h
371
+ expect(text.slice(span.start, span.end)).toBe("two three");
372
+ expect(spanKey(span, "l", text)).toMatchObject({ wordEnd: 3 });
373
+ });
374
+
375
+ test("spanFromRange returns null when the range covers no word", () => {
376
+ // Assert - a range past the last word has nothing to select
377
+ expect(spanFromRange(0, text, text.length, text.length)).toBeNull();
378
+ });
379
+ });
380
+
381
+ describe("cut / restore round-trip", () => {
382
+ test("cut removes the block lines; restore puts them back", () => {
383
+ // Arrange
384
+ const blocks = parseBlocks(BASE);
385
+ const listItem = blocks.find((candidate) => candidate.text === "second item")!;
386
+
387
+ // Act
388
+ const cut = cutBlock(BASE, listItem);
389
+
390
+ // Assert
391
+ expect(cut).not.toContain("second item");
392
+
393
+ // Arrange
394
+ const display = buildDisplay(BASE, cut);
395
+ const deletedIndex = display.findIndex((block) => block.type === "del");
396
+ const line = restoreLine(nextWorkBlock(display, deletedIndex), cut.split("\n").length);
397
+
398
+ // Act
399
+ // undefined = the working copy round-tripped back to the submitted revision
400
+ const restored = restoreBlock(BASE, cut, listItem, line);
401
+
402
+ // Assert
403
+ expect(restored).toBeUndefined();
404
+ expect(buildDisplay(BASE, restored).every((block) => block.type === "same")).toBe(true);
405
+ });
406
+ });