@cueloop/client 0.1.0-alpha.8 → 0.1.0-alpha.80

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