@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,545 @@
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 {
23
+ applyTextCuts,
24
+ cutBlock,
25
+ mergeTextCut,
26
+ parseBlocks,
27
+ restoreBlock,
28
+ restoreLine,
29
+ type Annotation,
30
+ } from "@cueloop/schema";
31
+
32
+ const BASE = `# Plan
33
+
34
+ ## Context
35
+
36
+ The daemon persists sessions to disk atomically.
37
+
38
+ - first item
39
+ - second item
40
+ `;
41
+
42
+ describe("buildDisplay reconciliation", () => {
43
+ test("no working copy: everything is same", () => {
44
+ // Act
45
+ const display = buildDisplay(BASE);
46
+
47
+ // Assert
48
+ expect(display.every((block) => block.type === "same")).toBe(true);
49
+ });
50
+
51
+ test("reworded block pairs as mod (similar)", () => {
52
+ // Arrange
53
+ const working = BASE.replace("persists sessions to disk", "writes sessions to disk");
54
+
55
+ // Act
56
+ const display = buildDisplay(BASE, working);
57
+
58
+ // Assert
59
+ const mod = display.find((block) => block.type === "mod")!;
60
+
61
+ expect(mod.base!.text).toContain("persists");
62
+ expect(mod.work!.text).toContain("writes");
63
+ });
64
+
65
+ test("a partial Cut stays one modified block even when only a short prefix survives", () => {
66
+ const base = "A seeded plan so cueloop dev always has a thread to open.";
67
+ const display = buildDisplay(base, "A seed");
68
+
69
+ expect(display).toHaveLength(1);
70
+ expect(display[0]!.type).toBe("mod");
71
+ });
72
+
73
+ test("unrelated cut + insert do NOT merge into a mod", () => {
74
+ // Arrange
75
+ const working = BASE.replace("- second item\n", "") + "\nA totally new closing note.\n";
76
+
77
+ // Act
78
+ const display = buildDisplay(BASE, working);
79
+
80
+ // Assert
81
+ expect(
82
+ display.some((block) => block.type === "del" && block.base!.text === "second item"),
83
+ ).toBe(true);
84
+ expect(
85
+ display.some((block) => block.type === "add" && block.work!.text.includes("closing note")),
86
+ ).toBe(true);
87
+ expect(display.some((block) => block.type === "mod")).toBe(false);
88
+ });
89
+
90
+ test("ordered list items get running numbers", () => {
91
+ // Act
92
+ const display = buildDisplay("1. one\n2. two\n3. three\n");
93
+
94
+ // Assert
95
+ expect(display.map((block) => block.orderedItemNumber)).toEqual([1, 2, 3]);
96
+ });
97
+ });
98
+
99
+ describe("blockRuns + overlayMarks", () => {
100
+ test("a partial-word Cut leaves surviving characters plain instead of inserted", () => {
101
+ const base = "It lands under unin Threads then in the sidebar rather than a project.";
102
+ const firstStart = base.indexOf("under");
103
+ const firstEnd = firstStart + "under".length;
104
+ const secondStart = base.indexOf("Threads");
105
+ const secondEnd = secondStart + "Threads".length;
106
+ const cuts = mergeTextCut(
107
+ base,
108
+ mergeTextCut(base, [], firstStart, firstEnd),
109
+ secondStart,
110
+ secondEnd,
111
+ );
112
+ const [block] = buildDisplay(base, applyTextCuts(base, cuts), cuts);
113
+ const runs = blockRuns(block!, true);
114
+
115
+ expect(runs.filter((run) => run.role === "del").map((run) => run.text)).toEqual([
116
+ "under",
117
+ "Threads",
118
+ ]);
119
+ expect(
120
+ runs
121
+ .filter((run) => run.role !== "del")
122
+ .map((run) => run.text)
123
+ .join(""),
124
+ ).toBe("It lands unin then in the sidebar rather than a project.");
125
+ expect(runs.some((run) => run.role === "ins")).toBe(false);
126
+ });
127
+
128
+ test("a cross-block Cut keeps every survivor plain and strikes only selected characters", () => {
129
+ const base = "Keep before remove this.\n\n- remove that keep after";
130
+ const cuts = mergeTextCut(base, [], 12, 40);
131
+ const display = buildDisplay(base, applyTextCuts(base, cuts), cuts);
132
+ const runs = display.flatMap((block) => blockRuns(block, true));
133
+
134
+ expect(runs.filter((run) => run.role === "ins")).toEqual([]);
135
+ expect(
136
+ runs
137
+ .filter((run) => run.role === "del")
138
+ .map((run) => run.text)
139
+ .join(""),
140
+ ).toBe(base.slice(12, 40).replace("\n\n- ", ""));
141
+ expect(
142
+ runs
143
+ .filter((run) => run.role !== "del")
144
+ .map((run) => run.text)
145
+ .join(""),
146
+ ).toBe("Keep before keep after");
147
+ });
148
+
149
+ test("mod blocks emit word-diff runs that reconstruct both sides", () => {
150
+ // Arrange
151
+ const block = buildDisplay("plain old text here\n", "plain new text here\n")[0]!;
152
+
153
+ // Act
154
+ const runs = blockRuns(block, true);
155
+
156
+ // Assert
157
+ const oldSide = runs
158
+ .filter((run) => run.role !== "ins")
159
+ .map((run) => run.text)
160
+ .join("");
161
+ const newSide = runs
162
+ .filter((run) => run.role !== "del")
163
+ .map((run) => run.text)
164
+ .join("");
165
+
166
+ expect(oldSide).toBe("plain old text here");
167
+ expect(newSide).toBe("plain new text here");
168
+ });
169
+
170
+ test("marks bind to working-text offsets and skip del runs", () => {
171
+ // Arrange
172
+ const block = buildDisplay("old words linger\n", "new words linger\n")[0]!;
173
+ const runs = blockRuns(block, true);
174
+
175
+ // Act
176
+ const marked = overlayMarks(runs, [{ start: 4, end: 9, role: "mark-comment" }]); // "words"
177
+
178
+ // Assert
179
+ const markedText = marked
180
+ .filter((run) => run.role === "mark-comment")
181
+ .map((run) => run.text)
182
+ .join("");
183
+
184
+ expect(markedText).toBe("words");
185
+ // del run text untouched
186
+ expect(marked.some((run) => run.role === "del" && run.text === "old")).toBe(true);
187
+ });
188
+
189
+ test("marks never override ins emphasis", () => {
190
+ // Arrange
191
+ const block = buildDisplay("keep\n", "keep added\n")[0]!;
192
+
193
+ // Act
194
+ const runs = overlayMarks(blockRuns(block, true), [
195
+ { start: 0, end: 10, role: "mark-comment" },
196
+ ]);
197
+
198
+ // Assert
199
+ expect(runs.some((run) => run.role === "ins")).toBe(true);
200
+ });
201
+ });
202
+
203
+ describe("M4: link hrefs are scheme-validated for OSC 8 (#236)", () => {
204
+ test("trusts http(s) and mailto, drops everything else", () => {
205
+ // Assert
206
+ expect(safeLinkHref("https://example.com")).toBe("https://example.com");
207
+ expect(safeLinkHref("http://x")).toBe("http://x");
208
+ expect(safeLinkHref("mailto:a@b.c")).toBe("mailto:a@b.c");
209
+ expect(safeLinkHref("javascript:alert(1)")).toBeUndefined();
210
+ expect(safeLinkHref("./relative/path")).toBeUndefined();
211
+ expect(safeLinkHref(undefined)).toBeUndefined();
212
+ });
213
+
214
+ test("a control byte or space in the target drops the link", () => {
215
+ // Assert - ESC and BEL could splice into the OSC 8 escape stream
216
+ expect(safeLinkHref("https://x\x1b]8;;evil\x07")).toBeUndefined();
217
+ expect(safeLinkHref("https://x\x07")).toBeUndefined();
218
+ expect(safeLinkHref("https://x y")).toBeUndefined();
219
+ });
220
+
221
+ test("blockRuns carries the link href through to the link run", () => {
222
+ // Act
223
+ const block = buildDisplay("see [docs](https://example.com) now\n")[0]!;
224
+ const link = blockRuns(block, true).find((run) => run.role === "link")!;
225
+
226
+ // Assert
227
+ expect(link.text).toBe("docs");
228
+ expect(link.href).toBe("https://example.com");
229
+ });
230
+ });
231
+
232
+ describe("M3: inline emphasis composes with word-diff (#235)", () => {
233
+ test("context emphasis survives; added text keeps the green diff role; markers concealed", () => {
234
+ // Arrange - "**this**" is unchanged (context, stays emphasized); "safe"->"sound" is the edit
235
+ const block = buildDisplay("keep **this** safe\n", "keep **this** sound\n")[0]!;
236
+
237
+ // Act
238
+ const runs = blockRuns(block, true);
239
+
240
+ // Assert - no marker text renders anywhere
241
+ expect(runs.every((run) => !run.text.includes("*"))).toBe(true);
242
+ // the unchanged emphasized word keeps its strong role (context, not diffed)
243
+ expect(runs.find((run) => run.text === "this")!.role).toBe("strong");
244
+ // the added word is green (ins wins over any emphasis), the removed one opaque
245
+ expect(runs.find((run) => run.text.includes("sound"))!.role).toBe("ins");
246
+ expect(runs.some((run) => run.role === "del" && run.start === null)).toBe(true);
247
+ });
248
+
249
+ test("markers inside added text conceal while the run stays a positioned ins", () => {
250
+ // Arrange - an emphasized word is added into an otherwise unchanged line
251
+ const block = buildDisplay("keep it safe\n", "keep it **very** safe\n")[0]!;
252
+
253
+ // Act
254
+ const runs = blockRuns(block, true);
255
+ const added = runs.find((run) => run.text === "very")!;
256
+
257
+ // Assert - concealed markers, green role, and an exact work offset back into work text
258
+ expect(added.role).toBe("ins");
259
+ expect(added.start).not.toBeNull();
260
+ expect(block.work!.text.slice(added.start!, added.start! + "very".length)).toBe("very");
261
+ expect(runs.every((run) => !run.text.includes("*"))).toBe(true);
262
+ });
263
+
264
+ test("an anchor over an emphasized context word round-trips to its work offsets", () => {
265
+ // Arrange
266
+ const block = buildDisplay("hold **steady** now\n", "hold **steady** later\n")[0]!;
267
+ const runs = blockRuns(block, true);
268
+ const work = block.work!.text;
269
+ const start = work.indexOf("steady");
270
+
271
+ // Act
272
+ const rendered = renderedOffsetFor(runs, start);
273
+
274
+ // Assert
275
+ expect(rendered).not.toBeNull();
276
+ expect(workRangeForRendered(runs, rendered!, rendered! + "steady".length)).toEqual({
277
+ start,
278
+ end: start + "steady".length,
279
+ });
280
+ });
281
+ });
282
+
283
+ describe("marksByDisplay", () => {
284
+ test("annotations resolve into display coordinates", () => {
285
+ // Arrange
286
+ const working = BASE.replace("first item", "first item edited beyond recognition of it");
287
+ const display = buildDisplay(BASE, working);
288
+ const ann: Annotation = {
289
+ id: "a1",
290
+ kind: "comment",
291
+ anchor: { quote: "atomically", prefix: "to disk ", suffix: "." },
292
+ body: "define atomically",
293
+ createdAt: "",
294
+ };
295
+
296
+ // Act
297
+ const map = marksByDisplay([ann], display);
298
+
299
+ // Assert
300
+ const [displayIndex, marks] = [...map.entries()][0]!;
301
+
302
+ expect(displayText(display[displayIndex]!)).toContain("atomically");
303
+ expect(marks[0]!.annotationId).toBe("a1");
304
+ });
305
+
306
+ test("orphaned annotations produce no marks", () => {
307
+ // Arrange
308
+ const display = buildDisplay(BASE);
309
+ const ann: Annotation = {
310
+ id: "a1",
311
+ kind: "comment",
312
+ anchor: { quote: "text that is gone", prefix: "", suffix: "" },
313
+ body: "",
314
+ createdAt: "",
315
+ };
316
+
317
+ // Assert
318
+ expect(marksByDisplay([ann], display).size).toBe(0);
319
+ });
320
+ });
321
+
322
+ describe("rendered/work offset mapping", () => {
323
+ test("plain blocks map 1:1 in both directions", () => {
324
+ // Arrange
325
+ const block = buildDisplay("alpha beta gamma\n")[0]!;
326
+ const runs = blockRuns(block, true);
327
+
328
+ // Assert
329
+ expect(renderedOffsetFor(runs, 6)).toBe(6);
330
+ expect(workRangeForRendered(runs, 6, 10)).toEqual({ start: 6, end: 10 });
331
+ });
332
+
333
+ test("inline del runs shift rendered offsets in word-diffed blocks", () => {
334
+ // Arrange
335
+ // rendered: "old new words linger" - "old " is a del run without offsets
336
+ const block = buildDisplay("old words linger\n", "new words linger\n")[0]!;
337
+ const runs = blockRuns(block, true);
338
+ const workText = displayText(block);
339
+ const wordsAt = workText.indexOf("words");
340
+
341
+ // Act
342
+ const rendered = renderedOffsetFor(runs, wordsAt)!;
343
+
344
+ // Assert
345
+ const renderedString = runs.map((run) => run.text).join("");
346
+
347
+ expect(renderedString.slice(rendered, rendered + "words".length)).toBe("words");
348
+ // reading a rendered selection over "words" recovers the work range
349
+ expect(workRangeForRendered(runs, rendered, rendered + "words".length)).toEqual({
350
+ start: wordsAt,
351
+ end: wordsAt + "words".length,
352
+ });
353
+ });
354
+
355
+ test("a rendered selection spanning a del run keeps only positioned text", () => {
356
+ // Arrange
357
+ const block = buildDisplay("old words\n", "new words\n")[0]!;
358
+ const runs = blockRuns(block, true);
359
+ const renderedString = runs.map((run) => run.text).join("");
360
+
361
+ // Act
362
+ // select everything rendered: the work range is the whole work text
363
+ const range = workRangeForRendered(runs, 0, renderedString.length)!;
364
+
365
+ // Assert
366
+ expect(displayText(block).slice(range.start, range.end)).toBe(displayText(block));
367
+ });
368
+
369
+ test("offsets outside any positioned run map to null", () => {
370
+ // Arrange
371
+ const runs = blockRuns(buildDisplay("short\n")[0]!, true);
372
+
373
+ // Assert
374
+ expect(renderedOffsetFor(runs, 99)).toBeNull();
375
+ expect(workRangeForRendered(runs, 90, 99)).toBeNull();
376
+ });
377
+
378
+ test("a span starting/ending on concealed markers snaps to the visible word", () => {
379
+ // Arrange - the word span over "**very**" covers the markers, which have
380
+ // no rendered cells; the clamped lookups land on "very" itself
381
+ const text = "keep it **very** safe\n";
382
+ const runs = blockRuns(buildDisplay(text)[0]!, true);
383
+ const spanStart = text.indexOf("**very**");
384
+ const spanEnd = spanStart + "**very**".length;
385
+
386
+ // Act
387
+ const renderedStart = renderedOffsetAtOrAfter(runs, spanStart)!;
388
+ const renderedEnd = renderedOffsetAtOrBefore(runs, spanEnd - 1)!;
389
+
390
+ // Assert
391
+ const renderedString = runs.map((run) => run.text).join("");
392
+
393
+ expect(renderedString.slice(renderedStart, renderedEnd + 1)).toBe("very");
394
+ // exact hits behave like renderedOffsetFor
395
+ expect(renderedOffsetAtOrAfter(runs, 0)).toBe(0);
396
+ expect(renderedOffsetAtOrBefore(runs, 0)).toBe(0);
397
+ // nothing positioned beyond the text end
398
+ expect(renderedOffsetAtOrAfter(runs, 99)).toBeNull();
399
+ });
400
+ });
401
+
402
+ describe("span mode", () => {
403
+ const text = "one two three four";
404
+
405
+ test("v selects the first word; l grows; w slides; $ to end", () => {
406
+ // Act
407
+ let span = startSpan(0, text)!;
408
+
409
+ // Assert
410
+ expect(text.slice(span.start, span.end)).toBe("one");
411
+
412
+ // Act
413
+ span = spanKey(span, "l", text);
414
+
415
+ // Assert
416
+ expect(text.slice(span.start, span.end)).toBe("one two");
417
+
418
+ // Act
419
+ span = spanKey(span, "w", text);
420
+
421
+ // Assert
422
+ expect(text.slice(span.start, span.end)).toBe("two three");
423
+
424
+ // Act
425
+ span = spanKey(span, "$", text);
426
+
427
+ // Assert
428
+ expect(text.slice(span.start, span.end)).toBe("two three four");
429
+ });
430
+
431
+ test("spanFromRange snaps a mid-word mouse drag to whole words", () => {
432
+ // Arrange - a drag from inside "two" to inside "three"
433
+ const dragStart = text.indexOf("two") + 1;
434
+ const dragEnd = text.indexOf("three") + 2;
435
+
436
+ // Act
437
+ const span = spanFromRange(0, text, dragStart, dragEnd)!;
438
+
439
+ // Assert - snaps outward to cover both touched words, ready for w/b/l/h
440
+ expect(text.slice(span.start, span.end)).toBe("two three");
441
+ expect(spanKey(span, "l", text)).toMatchObject({ wordEnd: 3 });
442
+ });
443
+
444
+ test("spanFromRange returns null when the range covers no word", () => {
445
+ // Assert - a range past the last word has nothing to select
446
+ expect(spanFromRange(0, text, text.length, text.length)).toBeNull();
447
+ });
448
+ });
449
+
450
+ describe("cut / restore round-trip", () => {
451
+ test("cut removes the block lines; restore puts them back", () => {
452
+ // Arrange
453
+ const blocks = parseBlocks(BASE);
454
+ const listItem = blocks.find((candidate) => candidate.text === "second item")!;
455
+
456
+ // Act
457
+ const cut = cutBlock(BASE, listItem);
458
+
459
+ // Assert
460
+ expect(cut).not.toContain("second item");
461
+
462
+ // Arrange
463
+ const display = buildDisplay(BASE, cut);
464
+ const deletedIndex = display.findIndex((block) => block.type === "del");
465
+ const line = restoreLine(nextWorkBlock(display, deletedIndex), cut.split("\n").length);
466
+
467
+ // Act
468
+ // undefined = the working copy round-tripped back to the submitted revision
469
+ const restored = restoreBlock(BASE, cut, listItem, line);
470
+
471
+ // Assert
472
+ expect(restored).toBeUndefined();
473
+ expect(buildDisplay(BASE, restored).every((block) => block.type === "same")).toBe(true);
474
+ });
475
+ });
476
+
477
+ describe("rendered-text projection (markers concealed)", () => {
478
+ const linkedBlock = () => buildDisplay("Use the `flag` and see [docs](https://x.dev).")[0]!;
479
+
480
+ test("renderedText drops inline markers, keeping visible characters", () => {
481
+ expect(renderedText(linkedBlock())).toBe("Use the flag and see docs.");
482
+ });
483
+
484
+ test("renderedText leaves a table block's source verbatim", () => {
485
+ const table = buildDisplay("| a | b |\n| --- | --- |\n| 1 | 2 |")[0]!;
486
+
487
+ expect(table.kind).toBe("table");
488
+ expect(renderedText(table)).toBe("| a | b |\n| --- | --- |\n| 1 | 2 |");
489
+ });
490
+
491
+ test("renderedSpanToWork maps a rendered link label back to its work offsets", () => {
492
+ const display = buildDisplay("Use the `flag` and see [docs](https://x.dev).");
493
+ // "docs" sits at rendered offset 21 ("Use the flag and see ".length)
494
+ const work = renderedSpanToWork(display, 0, 0, 21, 25);
495
+ const workText = display[0]!.work!.text;
496
+
497
+ expect(workText.slice(work.start, work.end)).toBe("docs");
498
+ });
499
+
500
+ test("renderedSpanToWork maps rendered inline code back to the backtick content", () => {
501
+ const display = buildDisplay("Use the `flag` and see docs.");
502
+ // "flag" sits at rendered offset 8 ("Use the ".length)
503
+ const work = renderedSpanToWork(display, 0, 0, 8, 12);
504
+
505
+ expect(display[0]!.work!.text.slice(work.start, work.end)).toBe("flag");
506
+ });
507
+
508
+ test("styledRunsFor colors a link run and marks the covered stretch", () => {
509
+ const linkRow = buildDisplay("see [docs](https://x.dev) now")[0]!;
510
+ const roleRuns = renderedStyleRuns(linkRow);
511
+ // "see docs now"; mark the whole "docs" label (rendered offsets 4..8)
512
+ const runs = styledRunsFor(roleRuns, { start: 0, end: renderedText(linkRow).length }, [
513
+ { start: 4, end: 8 },
514
+ ]);
515
+ const link = runs.find((run) => run.role === "link")!;
516
+
517
+ expect(link.text).toBe("docs");
518
+ expect(link.marked).toBe(true);
519
+ expect(runs.find((run) => run.text === "see ")!.marked).toBe(false);
520
+ });
521
+
522
+ test("syntax tokens split painted code runs without moving annotation marks", () => {
523
+ const roles = [{ text: "export value", role: "plain" as const, start: 0 }];
524
+ const runs = styledRunsFor(
525
+ roles,
526
+ { start: 0, end: 12 },
527
+ [{ start: 5, end: 9 }],
528
+ [{ start: 0, end: 6, group: "keyword" }],
529
+ );
530
+
531
+ expect(runs.map((run) => run.text).join("")).toBe("export value");
532
+ expect(
533
+ runs
534
+ .filter((run) => run.syntaxGroup === "keyword")
535
+ .map((run) => run.text)
536
+ .join(""),
537
+ ).toBe("export");
538
+ expect(
539
+ runs
540
+ .filter((run) => run.marked)
541
+ .map((run) => run.text)
542
+ .join(""),
543
+ ).toBe("t va");
544
+ });
545
+ });