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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (316) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +42 -34
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +77 -51
  10. package/src/App.diff.test.tsx +259 -45
  11. package/src/App.export.test.tsx +58 -41
  12. package/src/App.focus.test.ts +89 -0
  13. package/src/App.identity.test.tsx +78 -0
  14. package/src/App.plan-render.test.tsx +209 -0
  15. package/src/App.plan-review.test.tsx +330 -161
  16. package/src/App.prototype-markdown.test.tsx +128 -0
  17. package/src/App.prototype.test.tsx +176 -0
  18. package/src/App.readonly.test.tsx +129 -67
  19. package/src/App.regression.test.tsx +94 -0
  20. package/src/App.rendering.test.tsx +61 -35
  21. package/src/App.share-dialog.test.tsx +172 -0
  22. package/src/App.submit-confirm.test.tsx +264 -0
  23. package/src/App.test.tsx +402 -126
  24. package/src/App.toast.test.tsx +98 -0
  25. package/src/App.tsx +1402 -1060
  26. package/src/App.visual.test.tsx +57 -0
  27. package/src/App.walk.test.tsx +299 -0
  28. package/src/App.workbench.test.tsx +774 -0
  29. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  30. package/src/annotation-palette.ts +62 -0
  31. package/src/ansi-screen.test.ts +28 -0
  32. package/src/ansi-screen.ts +72 -0
  33. package/src/app-key-state.ts +61 -0
  34. package/src/app-screens.tsx +577 -0
  35. package/src/app-view-model.ts +168 -0
  36. package/src/attribution.test.ts +31 -0
  37. package/src/attribution.ts +42 -0
  38. package/src/client-extension-registry.test.tsx +61 -0
  39. package/src/client-extension-registry.ts +159 -0
  40. package/src/clipboard.test.ts +23 -0
  41. package/src/clipboard.ts +33 -0
  42. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  43. package/src/components/AnnotatableFileView.test.tsx +102 -0
  44. package/src/components/AnnotatableFileView.tsx +134 -0
  45. package/src/components/AnnotationCards.stories.tsx +86 -0
  46. package/src/components/AnnotationCards.tsx +341 -0
  47. package/src/components/AnsiScreen.stories.tsx +12 -0
  48. package/src/components/AnsiScreen.tsx +28 -0
  49. package/src/components/AppHeader.stories.tsx +50 -0
  50. package/src/components/AppHeader.tsx +83 -0
  51. package/src/components/AppShell.brand.test.tsx +58 -0
  52. package/src/components/AppShell.extensions.test.tsx +191 -0
  53. package/src/components/AppShell.stories.tsx +89 -0
  54. package/src/components/AppShell.tsx +362 -0
  55. package/src/components/AppShell.zoom.test.tsx +135 -0
  56. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  57. package/src/components/BareWorkbenchFileView.tsx +64 -0
  58. package/src/components/Breadcrumb.stories.tsx +19 -0
  59. package/src/components/Breadcrumb.tsx +45 -0
  60. package/src/components/ChangesColumn.stories.tsx +44 -0
  61. package/src/components/ChangesColumn.tsx +199 -0
  62. package/src/components/ChangesFileTree.test.tsx +61 -0
  63. package/src/components/CodeBlock.stories.tsx +42 -0
  64. package/src/components/CodeBlock.tsx +84 -0
  65. package/src/components/CompletionOverlay.stories.tsx +37 -0
  66. package/src/components/CompletionOverlay.tsx +77 -0
  67. package/src/components/ConfirmCard.stories.tsx +37 -0
  68. package/src/components/ConfirmCard.tsx +150 -0
  69. package/src/components/ConfirmDialog.stories.tsx +21 -0
  70. package/src/components/ConfirmDialog.tsx +72 -0
  71. package/src/components/DiffContentView.scroll.test.tsx +149 -0
  72. package/src/components/DiffContentView.split.test.tsx +140 -0
  73. package/src/components/DiffContentView.stories.tsx +80 -0
  74. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  75. package/src/components/DiffContentView.tsx +906 -0
  76. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  77. package/src/components/DiscussionMarkerRail.tsx +186 -0
  78. package/src/components/EditorGrid.stories.tsx +59 -0
  79. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  80. package/src/components/EditorGrid.tsx +426 -0
  81. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  82. package/src/components/ExtensionZone.stories.tsx +28 -0
  83. package/src/components/ExtensionZone.tsx +145 -0
  84. package/src/components/FileContentsView.stories.tsx +16 -0
  85. package/src/components/FileContentsView.test.tsx +29 -0
  86. package/src/components/FileContentsView.tsx +98 -0
  87. package/src/components/GridTabContent.stories.tsx +59 -0
  88. package/src/components/GridTabContent.tsx +182 -0
  89. package/src/components/HandleChipsInput.stories.tsx +24 -0
  90. package/src/components/HandleChipsInput.test.tsx +35 -0
  91. package/src/components/HandleChipsInput.tsx +145 -0
  92. package/src/components/KeybindsDialog.stories.tsx +31 -0
  93. package/src/components/KeybindsDialog.tsx +103 -0
  94. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  95. package/src/components/MarkdownGridBlock.tsx +72 -0
  96. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  97. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  98. package/src/components/MarkdownThreadEditor.tsx +162 -0
  99. package/src/components/MenuBar.stories.tsx +28 -0
  100. package/src/components/MenuBar.tsx +44 -0
  101. package/src/components/NavModeHint.stories.tsx +40 -0
  102. package/src/components/NavModeHint.tsx +31 -0
  103. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  104. package/src/components/OverlayScrollbar.test.tsx +139 -0
  105. package/src/components/OverlayScrollbar.tsx +129 -0
  106. package/src/components/PaneGrid.stories.tsx +84 -0
  107. package/src/components/PaneGrid.tsx +128 -0
  108. package/src/components/PanelColumn.stories.tsx +25 -0
  109. package/src/components/PanelColumn.tsx +98 -0
  110. package/src/components/ProjectTreeView.stories.tsx +25 -0
  111. package/src/components/ProjectTreeView.test.tsx +75 -0
  112. package/src/components/ProjectTreeView.tsx +125 -0
  113. package/src/components/PromptDialog.stories.tsx +36 -0
  114. package/src/components/PromptDialog.test.tsx +61 -0
  115. package/src/components/PromptDialog.tsx +99 -0
  116. package/src/components/PrototypeContentView.stories.tsx +19 -0
  117. package/src/components/PrototypeContentView.tsx +446 -0
  118. package/src/components/RootOverlay.stories.tsx +45 -0
  119. package/src/components/RootOverlay.tsx +48 -0
  120. package/src/components/ScrollArea.stories.tsx +20 -0
  121. package/src/components/ScrollArea.tsx +44 -0
  122. package/src/components/SettingsDialog.stories.tsx +117 -0
  123. package/src/components/SettingsDialog.tsx +186 -0
  124. package/src/components/SettingsRows.stories.tsx +18 -0
  125. package/src/components/SettingsRows.tsx +112 -0
  126. package/src/components/ShareDialog.stories.tsx +121 -0
  127. package/src/components/ShareDialog.tsx +552 -0
  128. package/src/components/ShellHeader.stories.tsx +50 -0
  129. package/src/components/ShellHeader.tsx +137 -0
  130. package/src/components/SlashComposer.stories.tsx +37 -0
  131. package/src/components/SlashComposer.tsx +87 -0
  132. package/src/components/ThreadFooter.stories.tsx +30 -0
  133. package/src/components/ThreadFooter.test.tsx +47 -0
  134. package/src/components/ThreadFooter.tsx +76 -0
  135. package/src/components/ThreadTree.stories.tsx +46 -0
  136. package/src/components/ThreadTree.tsx +255 -0
  137. package/src/components/ThreadView.selection.test.tsx +951 -0
  138. package/src/components/ThreadView.slash.test.tsx +146 -0
  139. package/src/components/ThreadView.stories.tsx +26 -0
  140. package/src/components/ThreadView.tsx +525 -0
  141. package/src/components/Toast.stories.tsx +19 -0
  142. package/src/components/Toast.tsx +52 -0
  143. package/src/components/Tooltip.stories.tsx +26 -0
  144. package/src/components/Tooltip.tsx +72 -0
  145. package/src/components/TreePane.stories.tsx +106 -0
  146. package/src/components/TreePane.tsx +157 -0
  147. package/src/components/WalkWizard.stories.tsx +100 -0
  148. package/src/components/WalkWizard.tsx +150 -0
  149. package/src/components/WelcomePlayground.stories.tsx +12 -0
  150. package/src/components/WelcomePlayground.test.tsx +49 -0
  151. package/src/components/WelcomePlayground.tsx +139 -0
  152. package/src/components/WorkspacePanels.stories.tsx +36 -0
  153. package/src/components/WorkspacePanels.tsx +116 -0
  154. package/src/components/__snapshots__/stories.test.tsx.snap +2264 -0
  155. package/src/components/diff-content-view-layout.test.ts +198 -0
  156. package/src/components/diff-content-view-layout.ts +84 -0
  157. package/src/components/editor-grid.test.ts +86 -0
  158. package/src/components/editor-grid.ts +138 -0
  159. package/src/components/file-tree.test.ts +60 -0
  160. package/src/components/file-tree.ts +112 -0
  161. package/src/components/menu-control.stories.tsx +35 -0
  162. package/src/components/menu-control.tsx +43 -0
  163. package/src/components/pages/Workspace.stories.tsx +181 -0
  164. package/src/components/primitives/Button.stories.tsx +32 -0
  165. package/src/components/primitives/Button.tsx +59 -0
  166. package/src/components/primitives/Card.stories.tsx +25 -0
  167. package/src/components/primitives/Card.tsx +63 -0
  168. package/src/components/primitives/Dialog.stories.tsx +24 -0
  169. package/src/components/primitives/Dialog.test.tsx +37 -0
  170. package/src/components/primitives/Dialog.tsx +71 -0
  171. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  172. package/src/components/primitives/DialogActions.tsx +39 -0
  173. package/src/components/primitives/IconButton.stories.tsx +36 -0
  174. package/src/components/primitives/IconButton.tsx +62 -0
  175. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  176. package/src/components/primitives/StatusBar.tsx +23 -0
  177. package/src/components/primitives/Switch.stories.tsx +17 -0
  178. package/src/components/primitives/Switch.tsx +32 -0
  179. package/src/components/primitives/Tabs.stories.tsx +29 -0
  180. package/src/components/primitives/Tabs.tsx +102 -0
  181. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  182. package/src/components/primitives/Toolbar.tsx +14 -0
  183. package/src/components/primitives/Tree.stories.tsx +124 -0
  184. package/src/components/primitives/Tree.tsx +100 -0
  185. package/src/components/primitives/frame.test.ts +12 -0
  186. package/src/components/primitives/frame.ts +17 -0
  187. package/src/components/primitives/icons.ts +61 -0
  188. package/src/components/primitives/tree-model.test.ts +99 -0
  189. package/src/components/primitives/tree-model.ts +137 -0
  190. package/src/components/quick-actions-editor.stories.tsx +58 -0
  191. package/src/components/quick-actions-editor.test.tsx +117 -0
  192. package/src/components/quick-actions-editor.tsx +226 -0
  193. package/src/components/share-dialog-store.ts +58 -0
  194. package/src/components/stories-app.tsx +193 -0
  195. package/src/components/stories.test.tsx +74 -0
  196. package/src/components/story-fixtures.ts +102 -0
  197. package/src/components/story-tree.test.ts +108 -0
  198. package/src/components/story.ts +159 -0
  199. package/src/components/syntax-highlight.ts +133 -0
  200. package/src/components/terminal-pane.test.tsx +40 -0
  201. package/src/components/terminal-pane.ts +199 -0
  202. package/src/components/theme-context.stories.tsx +46 -0
  203. package/src/components/theme-context.tsx +37 -0
  204. package/src/components/thread-tree.test.ts +134 -0
  205. package/src/components/thread-tree.ts +109 -0
  206. package/src/components/truncate-text.ts +7 -0
  207. package/src/components/truncate-title.test.ts +31 -0
  208. package/src/components/truncate-title.ts +15 -0
  209. package/src/config-identity.test.ts +78 -0
  210. package/src/config.test.ts +579 -34
  211. package/src/config.ts +533 -57
  212. package/src/diff-fold.test.ts +78 -0
  213. package/src/diff-fold.ts +126 -0
  214. package/src/diff-hunk-curate.test.ts +315 -0
  215. package/src/diff-hunk-curate.ts +153 -0
  216. package/src/diff-intraline.test.ts +238 -0
  217. package/src/diff-intraline.ts +236 -0
  218. package/src/diff-syntax.test.ts +112 -0
  219. package/src/diff-syntax.ts +179 -0
  220. package/src/discussions.ts +79 -0
  221. package/src/editor.test.ts +149 -0
  222. package/src/editor.ts +139 -9
  223. package/src/ghostty-terminal.test.ts +85 -0
  224. package/src/ghostty-terminal.ts +199 -0
  225. package/src/github-identity.test.ts +58 -0
  226. package/src/github-identity.ts +41 -0
  227. package/src/herdr.test.ts +9 -34
  228. package/src/herdr.ts +25 -21
  229. package/src/index.ts +28 -0
  230. package/src/integrations.test.ts +41 -0
  231. package/src/integrations.ts +32 -0
  232. package/src/intent-dispatch.test.ts +724 -0
  233. package/src/intent-dispatch.ts +690 -0
  234. package/src/key-bindings.test.ts +156 -0
  235. package/src/key-bindings.ts +459 -0
  236. package/src/keymap.test.ts +456 -115
  237. package/src/keymap.ts +315 -81
  238. package/src/kitty-image.test.ts +66 -0
  239. package/src/kitty-image.ts +138 -0
  240. package/src/launch-layout.test.ts +67 -0
  241. package/src/launch-layout.ts +47 -0
  242. package/src/mark-runs.ts +121 -0
  243. package/src/markdown-editor-syntax-style.ts +50 -0
  244. package/src/markdown-grid.test.ts +101 -0
  245. package/src/markdown-grid.ts +203 -0
  246. package/src/markdown-highlight.test.ts +61 -0
  247. package/src/markdown-highlight.ts +122 -0
  248. package/src/pasted-image.test.ts +67 -0
  249. package/src/pasted-image.ts +60 -0
  250. package/src/perf/otlp-trace.test.ts +116 -0
  251. package/src/perf/otlp-trace.ts +130 -0
  252. package/src/perf/perf-report.ts +17 -0
  253. package/src/perf/perf-timings.test.ts +18 -0
  254. package/src/perf/perf-timings.ts +38 -0
  255. package/src/prototype-browser.test.ts +81 -0
  256. package/src/prototype-browser.ts +329 -0
  257. package/src/prototype-pixels.tsx +35 -0
  258. package/src/pty.test.ts +81 -0
  259. package/src/pty.ts +245 -0
  260. package/src/ready-signal.test.ts +85 -0
  261. package/src/ready-signal.ts +52 -0
  262. package/src/run.ts +85 -12
  263. package/src/serve.test.ts +24 -12
  264. package/src/serve.ts +52 -10
  265. package/src/share.ts +237 -0
  266. package/src/skills.test.ts +74 -0
  267. package/src/skills.ts +75 -0
  268. package/src/slash-palette.test.ts +103 -0
  269. package/src/slash-palette.ts +129 -0
  270. package/src/split-diff.test.ts +110 -0
  271. package/src/split-diff.ts +149 -0
  272. package/src/test-support.test.ts +264 -0
  273. package/src/test-support.ts +369 -0
  274. package/src/theme-presets.test.ts +51 -0
  275. package/src/theme-presets.ts +219 -0
  276. package/src/theme.ts +88 -19
  277. package/src/thread-chords.test.ts +79 -0
  278. package/src/thread-chords.ts +163 -0
  279. package/src/thread-code-syntax.ts +45 -0
  280. package/src/thread-controller.curate.test.ts +476 -0
  281. package/src/thread-controller.derivation.test.ts +62 -0
  282. package/src/thread-controller.live-diff.test.ts +322 -0
  283. package/src/thread-controller.optimistic.test.ts +69 -0
  284. package/src/thread-controller.share.test.ts +561 -0
  285. package/src/thread-controller.switch-cache.test.ts +176 -0
  286. package/src/thread-controller.tree.test.ts +261 -0
  287. package/src/thread-controller.ts +2050 -0
  288. package/src/thread-selection.test.ts +210 -0
  289. package/src/thread-selection.ts +243 -0
  290. package/src/tree-view.test.ts +128 -0
  291. package/src/tree-view.ts +153 -0
  292. package/src/use-annotation-surface.tsx +975 -0
  293. package/src/use-changes-workbench.ts +190 -0
  294. package/src/use-frame-measure.ts +41 -0
  295. package/src/use-remember-layout.ts +31 -0
  296. package/src/use-repo-changes.ts +87 -0
  297. package/src/use-settings-dialog.tsx +364 -0
  298. package/src/use-terminal-virtualizer.ts +259 -0
  299. package/src/use-thread-body-editing.tsx +58 -0
  300. package/src/version.ts +5 -0
  301. package/src/view-diff.test.ts +279 -9
  302. package/src/view-diff.ts +460 -24
  303. package/src/view-plan.regression.test.ts +60 -0
  304. package/src/view-plan.test.ts +545 -0
  305. package/src/view-plan.ts +1019 -0
  306. package/src/visual/contrast.test.ts +35 -0
  307. package/src/visual/contrast.ts +33 -0
  308. package/src/walk.test.ts +121 -0
  309. package/src/walk.ts +74 -0
  310. package/src/workbench-snapshot.test.ts +99 -0
  311. package/src/workbench-snapshot.ts +59 -0
  312. package/src/session-controller.ts +0 -398
  313. package/src/syntax.test.ts +0 -41
  314. package/src/syntax.ts +0 -88
  315. package/src/view.test.ts +0 -157
  316. package/src/view.ts +0 -364
@@ -0,0 +1,1019 @@
1
+ /**
2
+ * The plan projection pipeline: parse → display blocks (base vs working
3
+ * copy reconciled) → styled runs. Pure functions - the TUI renders the runs
4
+ * (the native text primitive wraps them), tests assert on them directly.
5
+ * One planning layer: rendering, navigation, and selection all derive from
6
+ * this module; the rendered/work offset mappings keep the native selection
7
+ * addressable over word-diffed blocks.
8
+ */
9
+
10
+ import {
11
+ inlineRuns,
12
+ isAddressed,
13
+ lcsDiff,
14
+ parseBlocks,
15
+ resolveAnchor,
16
+ applyTextCuts,
17
+ blockTextSourceSegments,
18
+ type Annotation,
19
+ type Block,
20
+ type BlockTextSourceSegment,
21
+ type TextCut,
22
+ } from "@cueloop/schema";
23
+ import { wordLevelChanges } from "./diff-intraline";
24
+ import { spanRangeInBlock, type CharRange, type TextSpan } from "./thread-selection";
25
+ import type { MarkRange, VisualLine } from "./mark-runs";
26
+ import type { SyntaxSpan } from "./diff-syntax";
27
+
28
+ // ── display model ─────────────────────────────
29
+
30
+ export type DisplayType = "same" | "mod" | "del" | "add";
31
+
32
+ export interface DisplayBlock {
33
+ type: DisplayType;
34
+ kind: Block["kind"];
35
+ /** Block in the working copy (absent for del). */
36
+ work?: Block;
37
+ /** Block in the submitted revision (absent for add). */
38
+ base?: Block;
39
+ /** Source projection for mapping a visible base-text selection back to Markdown. */
40
+ baseSourceSegments?: readonly BlockTextSourceSegment[];
41
+ /** Exact base-text ranges removed by character Cut. */
42
+ textCutRanges?: CharRange[];
43
+ orderedItemNumber?: number;
44
+ }
45
+
46
+ export function displayText(block: DisplayBlock): string {
47
+ return (block.work ?? block.base)!.text;
48
+ }
49
+
50
+ /**
51
+ * The text a block actually paints: every rendered run's text in order, with
52
+ * inline markers concealed. This is the coordinate space the read-only thread
53
+ * surface hit-tests and paints in; it differs from displayText wherever a block
54
+ * carries inline markup (a link, code, or emphasis) whose markers drop away.
55
+ */
56
+ export function renderedText(block: DisplayBlock): string {
57
+ return blockRuns(block, true)
58
+ .map((run) => run.text)
59
+ .join("");
60
+ }
61
+
62
+ /** Exact character runs when one contiguous insertion or deletion produced the new text. */
63
+ function singleRangeTextChanges(
64
+ oldText: string,
65
+ newText: string,
66
+ ): ReturnType<typeof wordLevelChanges> | null {
67
+ if (oldText === newText || oldText.length === newText.length) return null;
68
+ let prefixLength = 0;
69
+
70
+ while (
71
+ prefixLength < oldText.length &&
72
+ prefixLength < newText.length &&
73
+ oldText[prefixLength] === newText[prefixLength]
74
+ )
75
+ prefixLength++;
76
+ let suffixLength = 0;
77
+ const shorterLength = Math.min(oldText.length, newText.length);
78
+
79
+ while (
80
+ suffixLength < shorterLength - prefixLength &&
81
+ oldText[oldText.length - 1 - suffixLength] === newText[newText.length - 1 - suffixLength]
82
+ )
83
+ suffixLength++;
84
+ if (prefixLength + suffixLength !== shorterLength) return null;
85
+ const commonPrefix = oldText.slice(0, prefixLength);
86
+ const commonSuffix = oldText.slice(oldText.length - suffixLength);
87
+ const changed =
88
+ oldText.length > newText.length
89
+ ? {
90
+ text: oldText.slice(prefixLength, oldText.length - suffixLength),
91
+ kind: "removed" as const,
92
+ }
93
+ : {
94
+ text: newText.slice(prefixLength, newText.length - suffixLength),
95
+ kind: "added" as const,
96
+ };
97
+
98
+ return [
99
+ { text: commonPrefix, kind: "common" as const },
100
+ changed,
101
+ { text: commonSuffix, kind: "common" as const },
102
+ ].filter((change) => change.text.length > 0);
103
+ }
104
+
105
+ /** Word-overlap similarity gate: an unrelated cut + insert must not merge. */
106
+ function similar(baseBlock: Block, workBlock: Block, expectedText?: string): boolean {
107
+ if (baseBlock.kind !== workBlock.kind) return false;
108
+ if (expectedText === workBlock.text) return true;
109
+ if (singleRangeTextChanges(baseBlock.text, workBlock.text)) return true;
110
+ const words = (text: string) => new Set(text.toLowerCase().split(/\s+/).filter(Boolean));
111
+ const baseWords = words(baseBlock.text);
112
+ const workWords = words(workBlock.text);
113
+ let intersection = 0;
114
+
115
+ for (const word of baseWords) if (workWords.has(word)) intersection++;
116
+ const union = baseWords.size + workWords.size - intersection;
117
+
118
+ return union === 0 ? true : intersection / union >= 0.3;
119
+ }
120
+
121
+ /** Reconcile base blocks vs working blocks into the display list. */
122
+ export function buildDisplay(
123
+ baseContent: string,
124
+ workingContent?: string,
125
+ textCuts: readonly TextCut[] = [],
126
+ ): DisplayBlock[] {
127
+ const baseBlocks = parseBlocks(baseContent);
128
+ const exactCuts =
129
+ textCuts.length > 0 && workingContent === applyTextCuts(baseContent, textCuts) ? textCuts : [];
130
+ const sourceSegments =
131
+ exactCuts.length > 0 ? blockTextSourceSegments(baseContent, baseBlocks) : [];
132
+ const cutData = new Map<Block, Pick<DisplayBlock, "baseSourceSegments" | "textCutRanges">>();
133
+ let cutIndex = 0;
134
+
135
+ baseBlocks.forEach((block, blockIndex) => {
136
+ const segments = sourceSegments[blockIndex];
137
+
138
+ if (!segments?.length) return;
139
+ const sourceStart = segments[0]!.sourceStart;
140
+ const last = segments.at(-1)!;
141
+ const sourceEnd = last.sourceStart + last.textEnd - last.textStart;
142
+
143
+ while (cutIndex < exactCuts.length && exactCuts[cutIndex]!.end <= sourceStart) cutIndex++;
144
+ const ranges: CharRange[] = [];
145
+
146
+ for (let index = cutIndex; index < exactCuts.length; index++) {
147
+ const cut = exactCuts[index]!;
148
+
149
+ if (cut.start >= sourceEnd) break;
150
+ const start = blockTextBoundary(segments, block.text.length, cut.start);
151
+ const end = blockTextBoundary(segments, block.text.length, cut.end);
152
+
153
+ if (end > start) ranges.push({ start, end });
154
+ }
155
+ const data: Pick<DisplayBlock, "baseSourceSegments" | "textCutRanges"> = {
156
+ baseSourceSegments: segments,
157
+ };
158
+
159
+ if (ranges.length > 0) data.textCutRanges = ranges;
160
+ cutData.set(block, data);
161
+ });
162
+ const blockCutData = (block: Block) => cutData.get(block) ?? {};
163
+ const expectedBlockText = (block: Block): string | undefined => {
164
+ const data = blockCutData(block);
165
+
166
+ if (!data.textCutRanges) return undefined;
167
+ let text = block.text;
168
+
169
+ for (let index = data.textCutRanges.length - 1; index >= 0; index--) {
170
+ const { start, end } = data.textCutRanges[index]!;
171
+
172
+ text = text.slice(0, start) + text.slice(end);
173
+ }
174
+
175
+ return text;
176
+ };
177
+
178
+ if (exactCuts.length > 0) {
179
+ const display = baseBlocks.map((base): DisplayBlock => {
180
+ const data = blockCutData(base);
181
+
182
+ if (!data.textCutRanges) {
183
+ return { type: "same", kind: base.kind, base, work: base, ...data };
184
+ }
185
+ const text = expectedBlockText(base)!;
186
+
187
+ return {
188
+ type: "mod",
189
+ kind: base.kind,
190
+ base,
191
+ work: { ...base, text },
192
+ ...data,
193
+ };
194
+ });
195
+
196
+ numberOrderedListItems(display);
197
+
198
+ return display;
199
+ }
200
+
201
+ if (workingContent === undefined) {
202
+ const display: DisplayBlock[] = baseBlocks.map((block) => ({
203
+ type: "same",
204
+ kind: block.kind,
205
+ work: block,
206
+ base: block,
207
+ ...blockCutData(block),
208
+ }));
209
+
210
+ numberOrderedListItems(display);
211
+
212
+ return display;
213
+ }
214
+ const workBlocks = parseBlocks(workingContent);
215
+ const signature = (block: Block) => block.kind + "\0" + block.text;
216
+ const ops = lcsDiff(
217
+ baseBlocks,
218
+ workBlocks,
219
+ (baseBlock, workBlock) => signature(baseBlock) === signature(workBlock),
220
+ );
221
+ const display: DisplayBlock[] = [];
222
+ let opIndex = 0;
223
+
224
+ while (opIndex < ops.length) {
225
+ const op = ops[opIndex]!;
226
+
227
+ if (op.kind === "ctx") {
228
+ display.push({ type: "same", kind: op.newValue!.kind, work: op.newValue, base: op.oldValue });
229
+ opIndex++;
230
+ continue;
231
+ }
232
+ const deletedBlocks: Block[] = [];
233
+ const addedBlocks: Block[] = [];
234
+
235
+ while (opIndex < ops.length && ops[opIndex]!.kind !== "ctx") {
236
+ const changeOp = ops[opIndex]!;
237
+
238
+ if (changeOp.kind === "del") deletedBlocks.push(changeOp.oldValue!);
239
+ else addedBlocks.push(changeOp.newValue!);
240
+ opIndex++;
241
+ }
242
+ let deletedIndex = 0;
243
+ let addedIndex = 0;
244
+
245
+ while (deletedIndex < deletedBlocks.length && addedIndex < addedBlocks.length) {
246
+ if (
247
+ similar(
248
+ deletedBlocks[deletedIndex]!,
249
+ addedBlocks[addedIndex]!,
250
+ expectedBlockText(deletedBlocks[deletedIndex]!),
251
+ )
252
+ ) {
253
+ const base = deletedBlocks[deletedIndex]!;
254
+
255
+ display.push({
256
+ type: "mod",
257
+ kind: addedBlocks[addedIndex]!.kind,
258
+ base,
259
+ work: addedBlocks[addedIndex]!,
260
+ ...blockCutData(base),
261
+ });
262
+ deletedIndex++;
263
+ addedIndex++;
264
+ } else if (deletedBlocks.length - deletedIndex >= addedBlocks.length - addedIndex) {
265
+ const base = deletedBlocks[deletedIndex]!;
266
+
267
+ display.push({
268
+ type: "del",
269
+ kind: base.kind,
270
+ base,
271
+ ...blockCutData(base),
272
+ });
273
+ deletedIndex++;
274
+ } else {
275
+ display.push({
276
+ type: "add",
277
+ kind: addedBlocks[addedIndex]!.kind,
278
+ work: addedBlocks[addedIndex]!,
279
+ });
280
+ addedIndex++;
281
+ }
282
+ }
283
+ while (deletedIndex < deletedBlocks.length) {
284
+ const base = deletedBlocks[deletedIndex]!;
285
+
286
+ display.push({
287
+ type: "del",
288
+ kind: base.kind,
289
+ base,
290
+ ...blockCutData(base),
291
+ });
292
+ deletedIndex++;
293
+ }
294
+ while (addedIndex < addedBlocks.length) {
295
+ display.push({
296
+ type: "add",
297
+ kind: addedBlocks[addedIndex]!.kind,
298
+ work: addedBlocks[addedIndex++]!,
299
+ });
300
+ }
301
+ }
302
+ numberOrderedListItems(display);
303
+
304
+ return display;
305
+ }
306
+
307
+ function blockTextBoundary(
308
+ segments: readonly BlockTextSourceSegment[],
309
+ textLength: number,
310
+ sourceOffset: number,
311
+ ): number {
312
+ for (const segment of segments) {
313
+ const sourceEnd = segment.sourceStart + segment.textEnd - segment.textStart;
314
+
315
+ if (sourceOffset < segment.sourceStart) return segment.textStart;
316
+ if (sourceOffset <= sourceEnd) return segment.textStart + sourceOffset - segment.sourceStart;
317
+ }
318
+
319
+ return textLength;
320
+ }
321
+
322
+ function numberOrderedListItems(display: DisplayBlock[]): void {
323
+ let runLength = 0;
324
+
325
+ for (const block of display) {
326
+ if (block.kind === "oli") block.orderedItemNumber = ++runLength;
327
+ else runLength = 0;
328
+ }
329
+ }
330
+
331
+ // ── marks (annotation highlights + keyboard span) ──
332
+
333
+ export type RunRole =
334
+ | "plain"
335
+ | "ins"
336
+ | "del"
337
+ | "mark-comment"
338
+ | "kspan"
339
+ | "mark-focus"
340
+ | "strong"
341
+ | "em"
342
+ | "code"
343
+ | "strike"
344
+ | "link";
345
+
346
+ export interface StyleRun {
347
+ text: string;
348
+ role: RunRole;
349
+ /** Offset of this run in the block's working text; null for del runs. */
350
+ start: number | null;
351
+ /** Offset in the submitted block when an exact Cut preserves source provenance. */
352
+ baseStart?: number;
353
+ annotationId?: string;
354
+ /** Link target for `link` runs. */
355
+ href?: string;
356
+ }
357
+
358
+ export interface Mark {
359
+ start: number;
360
+ end: number;
361
+ role: RunRole;
362
+ annotationId?: string;
363
+ /** The whole anchored stretch in display coordinates; one thread per span. */
364
+ span?: TextSpan;
365
+ /** The recorded PR line remains visible, but its quote no longer resolves. */
366
+ outdated?: boolean;
367
+ }
368
+
369
+ /**
370
+ * Resolve annotations against the working blocks and group marks per display
371
+ * index. Working blocks are the blocks of (workingCopy ?? artifact) - the
372
+ * same list buildDisplay derived work entries from.
373
+ */
374
+ export function marksByDisplay(
375
+ annotations: Annotation[],
376
+ display: DisplayBlock[],
377
+ focusedId?: string,
378
+ ): Map<number, Mark[]> {
379
+ // block list in "work space": display entries that carry a work block, in order
380
+ const workEntries = display
381
+ .map((block, displayIndex) => ({ block, displayIndex }))
382
+ .filter((entry) => entry.block.work !== undefined);
383
+ const workBlocks = workEntries.map((entry) => entry.block.work!);
384
+ const marksByIndex = new Map<number, Mark[]>();
385
+
386
+ for (const annotation of annotations) {
387
+ // an addressed annotation keeps its record but paints no highlight
388
+ if (isAddressed(annotation)) continue;
389
+ const resolved = resolveAnchor(annotation.anchor, workBlocks);
390
+
391
+ if (!resolved) continue;
392
+ const startEntry = workEntries[resolved.blockIndex];
393
+ const endEntry = workEntries[resolved.endBlockIndex];
394
+
395
+ if (!startEntry || !endEntry) continue;
396
+ // resolveAnchor works in the block's work text; the surface paints and hit-tests in rendered
397
+ // text (markers concealed), so map the span onto the visible characters it covers
398
+ const renderedStart = renderedOffsetAtOrAfter(
399
+ blockRuns(startEntry.block, true),
400
+ resolved.start,
401
+ );
402
+ const renderedEndInclusive = renderedOffsetAtOrBefore(
403
+ blockRuns(endEntry.block, true),
404
+ Math.max(0, resolved.end - 1),
405
+ );
406
+
407
+ if (renderedStart === null || renderedEndInclusive === null) continue;
408
+ const span: TextSpan = {
409
+ start: { blockIndex: startEntry.displayIndex, char: renderedStart },
410
+ end: { blockIndex: endEntry.displayIndex, char: renderedEndInclusive + 1 },
411
+ };
412
+
413
+ // a span paints on every block it covers; the mark carries the whole span
414
+ for (let workIndex = resolved.blockIndex; workIndex <= resolved.endBlockIndex; workIndex++) {
415
+ const entry = workEntries[workIndex]!;
416
+ const range = spanRangeInBlock(span, entry.displayIndex, renderedText(entry.block).length);
417
+
418
+ if (!range) continue;
419
+ const marks = marksByIndex.get(entry.displayIndex) ?? [];
420
+
421
+ marks.push({
422
+ start: range.start,
423
+ end: range.end,
424
+ role: annotation.id === focusedId ? "mark-focus" : "mark-comment",
425
+ annotationId: annotation.id,
426
+ span,
427
+ });
428
+ marksByIndex.set(entry.displayIndex, marks);
429
+ }
430
+ }
431
+
432
+ return marksByIndex;
433
+ }
434
+
435
+ function exactCutRuns(block: DisplayBlock, literal: boolean): StyleRun[] | null {
436
+ if (!block.base || !block.work || !block.textCutRanges) return null;
437
+ const localCuts = block.textCutRanges;
438
+ const expected = localCuts.reduceRight(
439
+ (text, cut) => text.slice(0, cut.start) + text.slice(cut.end),
440
+ block.base.text,
441
+ );
442
+
443
+ if (expected !== block.work.text) return null;
444
+ const sourceRuns = literal
445
+ ? [{ text: block.base.text, role: "plain" as const, start: 0 }]
446
+ : inlineStyleRuns(block.base.text, 0);
447
+ const runs: StyleRun[] = [];
448
+ let cutIndex = 0;
449
+ let deletedBefore = 0;
450
+
451
+ for (const sourceRun of sourceRuns) {
452
+ if (sourceRun.start === null) continue;
453
+ const runStart = sourceRun.start;
454
+ const runEnd = runStart + sourceRun.text.length;
455
+ let position = runStart;
456
+
457
+ while (cutIndex < localCuts.length && localCuts[cutIndex]!.end <= position) {
458
+ deletedBefore += localCuts[cutIndex]!.end - localCuts[cutIndex]!.start;
459
+ cutIndex++;
460
+ }
461
+ while (position < runEnd) {
462
+ const cut = localCuts[cutIndex];
463
+
464
+ if (!cut || cut.start >= runEnd) {
465
+ runs.push({
466
+ ...sourceRun,
467
+ text: block.base.text.slice(position, runEnd),
468
+ start: position - deletedBefore,
469
+ baseStart: position,
470
+ });
471
+ break;
472
+ }
473
+ if (position < cut.start) {
474
+ const commonEnd = Math.min(runEnd, cut.start);
475
+
476
+ runs.push({
477
+ ...sourceRun,
478
+ text: block.base.text.slice(position, commonEnd),
479
+ start: position - deletedBefore,
480
+ baseStart: position,
481
+ });
482
+ position = commonEnd;
483
+ continue;
484
+ }
485
+ const removedEnd = Math.min(runEnd, cut.end);
486
+
487
+ runs.push({
488
+ text: block.base.text.slice(position, removedEnd),
489
+ role: "del",
490
+ start: null,
491
+ baseStart: position,
492
+ });
493
+ position = removedEnd;
494
+ if (position === cut.end) {
495
+ deletedBefore += cut.end - cut.start;
496
+ cutIndex++;
497
+ }
498
+ }
499
+ }
500
+
501
+ return runs;
502
+ }
503
+
504
+ /** Base runs for a display block: plain text, or word-diff for mod blocks. */
505
+ export function blockRuns(block: DisplayBlock, markup: boolean): StyleRun[] {
506
+ const literal =
507
+ block.kind === "code" ||
508
+ block.kind === "hr" ||
509
+ block.kind === "table" ||
510
+ block.kind === "frontmatter";
511
+
512
+ if (markup) {
513
+ const exact = exactCutRuns(block, literal);
514
+
515
+ if (exact) return exact;
516
+ }
517
+
518
+ if (block.type === "mod" && markup) {
519
+ const changes =
520
+ singleRangeTextChanges(block.base!.text, block.work!.text) ??
521
+ wordLevelChanges(block.base!.text, block.work!.text);
522
+ const runs: StyleRun[] = [];
523
+ let workOffset = 0;
524
+
525
+ for (const change of changes) {
526
+ if (change.kind === "removed") {
527
+ runs.push({ text: change.text, role: "del", start: null });
528
+ continue;
529
+ }
530
+ // A code/rule block stays literal; prose segments inline-tokenize so their
531
+ // markers conceal while their work offsets stay intact. Added text keeps
532
+ // the green diff role (it wins over emphasis); context text shows emphasis.
533
+ const segment = literal
534
+ ? [{ text: change.text, role: "plain" as const, start: workOffset }]
535
+ : inlineStyleRuns(change.text, workOffset);
536
+
537
+ for (const run of segment) {
538
+ runs.push(change.kind === "added" ? { ...run, role: "ins" } : run);
539
+ }
540
+ workOffset += change.text.length;
541
+ }
542
+
543
+ return runs;
544
+ }
545
+ const text = displayText(block);
546
+
547
+ // Prose carries inline markup; code fences and rules are literal. Concealed
548
+ // markers are dropped, so every rendered cell still maps to a work offset.
549
+ if (markup && !literal) {
550
+ return inlineStyleRuns(text, 0);
551
+ }
552
+
553
+ return [{ text, role: "plain", start: 0 }];
554
+ }
555
+
556
+ /** A link href safe to hand a terminal as an OSC 8 target, or undefined. Only
557
+ * http(s) and mailto are trusted; a scheme like javascript: is dropped. The
558
+ * target must stay printable ASCII: a control byte (ESC, BEL) in a reviewed
559
+ * document could otherwise splice into the terminal escape stream. */
560
+ export function safeLinkHref(href: string | undefined): string | undefined {
561
+ if (href === undefined) return undefined;
562
+ if (!/^(https?:|mailto:)/i.test(href)) return undefined;
563
+
564
+ return /^[\x21-\x7e]+$/.test(href) ? href : undefined;
565
+ }
566
+
567
+ /**
568
+ * Map the schema inline tokens to positioned StyleRuns, dropping the concealed
569
+ * markers. `base` is the run's offset within the block's working text (0 for a
570
+ * whole prose block; a segment's start for a word-diff piece).
571
+ */
572
+ export function inlineStyleRuns(text: string, base: number): StyleRun[] {
573
+ const runs: StyleRun[] = [];
574
+
575
+ for (const run of inlineRuns(text)) {
576
+ if (run.role === "marker" || run.start === null) continue;
577
+ const styleRun: StyleRun = {
578
+ text: run.text,
579
+ role: run.role === "text" ? "plain" : run.role,
580
+ start: base + run.start,
581
+ };
582
+
583
+ if (run.href !== undefined) styleRun.href = run.href;
584
+ runs.push(styleRun);
585
+ }
586
+
587
+ return runs;
588
+ }
589
+
590
+ /** A rendered run positioned by its offset in the block's rendered (concealed) text. */
591
+ export interface RenderedRun {
592
+ text: string;
593
+ role: RunRole;
594
+ /** Offset of this run in the block's rendered text. */
595
+ start: number;
596
+ href?: string;
597
+ }
598
+
599
+ /** The block's role runs positioned in rendered-text coordinates, for painting and hit-testing. */
600
+ export function renderedStyleRuns(block: DisplayBlock): RenderedRun[] {
601
+ const runs: RenderedRun[] = [];
602
+ let rendered = 0;
603
+
604
+ for (const run of blockRuns(block, true)) {
605
+ const positioned: RenderedRun = { text: run.text, role: run.role, start: rendered };
606
+
607
+ if (run.href !== undefined) positioned.href = run.href;
608
+ runs.push(positioned);
609
+ rendered += run.text.length;
610
+ }
611
+
612
+ return runs;
613
+ }
614
+
615
+ /** A painted run of one visual line: its role, its href, and whether a mark or the caret cell covers it. */
616
+ export interface StyledRun {
617
+ text: string;
618
+ role: RunRole;
619
+ marked: boolean;
620
+ caretOnly: boolean;
621
+ href?: string;
622
+ syntaxGroup?: string;
623
+ }
624
+
625
+ function addSyntaxBoundaries(
626
+ cuts: Set<number>,
627
+ line: VisualLine,
628
+ syntaxSpans: readonly SyntaxSpan[],
629
+ ): void {
630
+ for (const span of syntaxSpans) {
631
+ if (span.start > line.start && span.start < line.end) cuts.add(span.start);
632
+ if (span.end > line.start && span.end < line.end) cuts.add(span.end);
633
+ }
634
+ }
635
+
636
+ function syntaxGroupForRun(
637
+ syntaxSpans: readonly SyntaxSpan[],
638
+ start: number,
639
+ end: number,
640
+ ): string | undefined {
641
+ return syntaxSpans.find((span) => span.start <= start && end <= span.end)?.group;
642
+ }
643
+
644
+ /**
645
+ * Slice a block's rendered role runs to one visual line and split them further
646
+ * at mark boundaries, so each painted run carries both its markdown role and
647
+ * whether a discussion mark or the caret cell covers it. Every coordinate here
648
+ * is a rendered-text offset; `markRanges` are block-level and clipped to the line.
649
+ */
650
+ export function styledRunsFor(
651
+ roleRuns: RenderedRun[],
652
+ line: VisualLine,
653
+ markRanges: MarkRange[],
654
+ syntaxSpans: readonly SyntaxSpan[] = [],
655
+ ): StyledRun[] {
656
+ const cuts = new Set<number>([line.start, line.end]);
657
+
658
+ for (const run of roleRuns) {
659
+ if (run.start > line.start && run.start < line.end) cuts.add(run.start);
660
+ const runEnd = run.start + run.text.length;
661
+
662
+ if (runEnd > line.start && runEnd < line.end) cuts.add(runEnd);
663
+ }
664
+ for (const range of markRanges) {
665
+ const start = Math.max(line.start, range.start);
666
+ const end = Math.min(line.end, range.end);
667
+
668
+ if (start < end) {
669
+ cuts.add(start);
670
+ cuts.add(end);
671
+ }
672
+ }
673
+ addSyntaxBoundaries(cuts, line, syntaxSpans);
674
+ const edges = [...cuts].toSorted((left, right) => left - right);
675
+ const runs: StyledRun[] = [];
676
+
677
+ for (let index = 0; index < edges.length - 1; index++) {
678
+ const start = edges[index]!;
679
+ const end = edges[index + 1]!;
680
+
681
+ if (end <= start) continue;
682
+ const roleRun = roleRuns.find(
683
+ (candidate) => candidate.start <= start && start < candidate.start + candidate.text.length,
684
+ );
685
+ const covering = markRanges.filter((range) => range.start <= start && end <= range.end);
686
+ const styled: StyledRun = {
687
+ text: roleRun ? roleRun.text.slice(start - roleRun.start, end - roleRun.start) : "",
688
+ role: roleRun?.role ?? "plain",
689
+ marked: covering.some((range) => range.caretOnly !== true),
690
+ caretOnly: covering.some((range) => range.caretOnly === true),
691
+ };
692
+
693
+ if (roleRun?.href !== undefined) styled.href = roleRun.href;
694
+ const syntaxGroup = syntaxGroupForRun(syntaxSpans, start, end);
695
+
696
+ if (syntaxGroup) styled.syntaxGroup = syntaxGroup;
697
+ runs.push(styled);
698
+ }
699
+
700
+ return runs.length > 0 ? runs : [{ text: "", role: "plain", marked: false, caretOnly: false }];
701
+ }
702
+
703
+ /** Split runs at mark boundaries; marks only bind to runs with offsets. */
704
+ export function overlayMarks(runs: StyleRun[], marks: Mark[]): StyleRun[] {
705
+ if (!marks.length) return runs;
706
+ const splitRuns: StyleRun[] = [];
707
+
708
+ for (const run of runs) {
709
+ if (run.start === null) {
710
+ splitRuns.push(run);
711
+ continue;
712
+ }
713
+ const bounds = new Set<number>([0, run.text.length]);
714
+
715
+ for (const mark of marks) {
716
+ const boundStart = Math.max(0, mark.start - run.start);
717
+ const boundEnd = Math.min(run.text.length, mark.end - run.start);
718
+
719
+ if (boundStart < boundEnd) {
720
+ bounds.add(boundStart);
721
+ bounds.add(boundEnd);
722
+ }
723
+ }
724
+ const runStart = run.start;
725
+ const boundaries = [...bounds].sort((left, right) => left - right);
726
+
727
+ for (let boundaryIndex = 0; boundaryIndex < boundaries.length - 1; boundaryIndex++) {
728
+ const sliceStart = boundaries[boundaryIndex]!;
729
+ const sliceEnd = boundaries[boundaryIndex + 1]!;
730
+
731
+ if (sliceStart >= sliceEnd) continue;
732
+ const absoluteStart = runStart + sliceStart;
733
+ const mark = marks.find(
734
+ (candidate) => candidate.start <= absoluteStart && runStart + sliceEnd <= candidate.end,
735
+ );
736
+
737
+ splitRuns.push({
738
+ text: run.text.slice(sliceStart, sliceEnd),
739
+ // a mark never overrides ins/del emphasis; it rides on plain text
740
+ role: mark && run.role === "plain" ? mark.role : run.role,
741
+ start: absoluteStart,
742
+ annotationId: mark?.annotationId ?? run.annotationId,
743
+ });
744
+ }
745
+ }
746
+
747
+ return splitRuns;
748
+ }
749
+
750
+ /**
751
+ * Rendered-text offset of a work-text offset within a block's runs. The
752
+ * rendered text is every run's text in order (inline del runs included);
753
+ * only positioned runs carry work offsets. Drives the renderer's native
754
+ * selection from keyboard span offsets - the quote-anchor path depends on
755
+ * this mapping staying exact.
756
+ */
757
+ export function renderedOffsetFor(runs: StyleRun[], workOffset: number): number | null {
758
+ let rendered = 0;
759
+
760
+ for (const run of runs) {
761
+ if (run.start !== null && workOffset >= run.start && workOffset < run.start + run.text.length) {
762
+ return rendered + (workOffset - run.start);
763
+ }
764
+ rendered += run.text.length;
765
+ }
766
+
767
+ return null;
768
+ }
769
+
770
+ /**
771
+ * Rendered offset of the first positioned character at or after `workOffset`.
772
+ * A word span may start on a concealed marker (the `**` of an emphasized
773
+ * word), which has no rendered cell; the span visuals snap to the first
774
+ * character that does. Positioned runs appear in ascending work order.
775
+ */
776
+ export function renderedOffsetAtOrAfter(runs: StyleRun[], workOffset: number): number | null {
777
+ let rendered = 0;
778
+
779
+ for (const run of runs) {
780
+ if (run.start !== null && run.start + run.text.length > workOffset) {
781
+ return rendered + Math.max(0, workOffset - run.start);
782
+ }
783
+ rendered += run.text.length;
784
+ }
785
+
786
+ return null;
787
+ }
788
+
789
+ /**
790
+ * Rendered offset of the last positioned character at or before `workOffset` -
791
+ * the closing-marker counterpart of `renderedOffsetAtOrAfter`.
792
+ */
793
+ export function renderedOffsetAtOrBefore(runs: StyleRun[], workOffset: number): number | null {
794
+ let rendered = 0;
795
+ let best: number | null = null;
796
+
797
+ for (const run of runs) {
798
+ if (run.start !== null && run.start <= workOffset) {
799
+ best = rendered + Math.min(workOffset - run.start, run.text.length - 1);
800
+ }
801
+ rendered += run.text.length;
802
+ }
803
+
804
+ return best;
805
+ }
806
+
807
+ /**
808
+ * Work-text range covered by a rendered-text selection [start, end). Del runs
809
+ * and other unpositioned text inside the selection contribute nothing; the
810
+ * result is the tightest work range whose text the selection touched.
811
+ */
812
+ export function workRangeForRendered(
813
+ runs: StyleRun[],
814
+ renderedStart: number,
815
+ renderedEnd: number,
816
+ ): { start: number; end: number } | null {
817
+ let rendered = 0;
818
+ let range: { start: number; end: number } | null = null;
819
+
820
+ for (const run of runs) {
821
+ const runRenderedStart = rendered;
822
+ const runRenderedEnd = rendered + run.text.length;
823
+
824
+ rendered = runRenderedEnd;
825
+ if (run.start === null) continue;
826
+ const overlapStart = Math.max(renderedStart, runRenderedStart);
827
+ const overlapEnd = Math.min(renderedEnd, runRenderedEnd);
828
+
829
+ if (overlapEnd <= overlapStart) continue;
830
+ const workStart = run.start + (overlapStart - runRenderedStart);
831
+ const workEnd = run.start + (overlapEnd - runRenderedStart);
832
+
833
+ if (!range) range = { start: workStart, end: workEnd };
834
+ else {
835
+ range.start = Math.min(range.start, workStart);
836
+ range.end = Math.max(range.end, workEnd);
837
+ }
838
+ }
839
+
840
+ return range;
841
+ }
842
+
843
+ /** Base-text range covered by a rendered selection in an exact Cut projection. */
844
+ export function baseRangeForRendered(
845
+ runs: StyleRun[],
846
+ renderedStart: number,
847
+ renderedEnd: number,
848
+ ): { start: number; end: number } | null {
849
+ let rendered = 0;
850
+ let range: { start: number; end: number } | null = null;
851
+
852
+ for (const run of runs) {
853
+ const runRenderedStart = rendered;
854
+ const runRenderedEnd = rendered + run.text.length;
855
+
856
+ rendered = runRenderedEnd;
857
+ if (run.baseStart === undefined) continue;
858
+ const overlapStart = Math.max(renderedStart, runRenderedStart);
859
+ const overlapEnd = Math.min(renderedEnd, runRenderedEnd);
860
+
861
+ if (overlapEnd <= overlapStart) continue;
862
+ const baseStart = run.baseStart + overlapStart - runRenderedStart;
863
+ const baseEnd = run.baseStart + overlapEnd - runRenderedStart;
864
+
865
+ if (!range) range = { start: baseStart, end: baseEnd };
866
+ else {
867
+ range.start = Math.min(range.start, baseStart);
868
+ range.end = Math.max(range.end, baseEnd);
869
+ }
870
+ }
871
+
872
+ return range;
873
+ }
874
+
875
+ /**
876
+ * Map a rendered-text selection back to the work-text range an anchor is cut
877
+ * from. The read-only surface yields rendered offsets (markers concealed); an
878
+ * anchor's quote must come from the block's work text, so convert at the seam
879
+ * before makeAnchor. A selection may cross blocks: the start rendered offset
880
+ * lives in `displayIndex`, the end in `endDisplayIndex`.
881
+ */
882
+ export function renderedSpanToWork(
883
+ display: DisplayBlock[],
884
+ displayIndex: number,
885
+ endDisplayIndex: number,
886
+ renderedStart: number,
887
+ renderedEnd: number,
888
+ ): CharRange {
889
+ const startBlock = display[displayIndex]!;
890
+
891
+ if (displayIndex === endDisplayIndex) {
892
+ const range = workRangeForRendered(blockRuns(startBlock, true), renderedStart, renderedEnd);
893
+
894
+ return range ?? { start: renderedStart, end: renderedEnd };
895
+ }
896
+ const startRuns = blockRuns(startBlock, true);
897
+ const startRange = workRangeForRendered(
898
+ startRuns,
899
+ renderedStart,
900
+ renderedText(startBlock).length,
901
+ );
902
+ const endRange = workRangeForRendered(blockRuns(display[endDisplayIndex]!, true), 0, renderedEnd);
903
+
904
+ return { start: startRange?.start ?? renderedStart, end: endRange?.end ?? renderedEnd };
905
+ }
906
+
907
+ /** Line delta between two revision contents, for the sheet-header summary. */
908
+ export function revisionDelta(previousContent: string, nextContent: string) {
909
+ const ops = lcsDiff(previousContent.split("\n"), nextContent.split("\n"));
910
+ let added = 0;
911
+ let removed = 0;
912
+
913
+ for (const op of ops) {
914
+ if (op.kind === "add") added++;
915
+ else if (op.kind === "del") removed++;
916
+ }
917
+
918
+ return { added, removed };
919
+ }
920
+
921
+ // ── keyboard span mode ────────────────────────
922
+
923
+ export interface SpanState {
924
+ displayIndex: number;
925
+ wordIndex: number;
926
+ wordEnd: number;
927
+ start: number;
928
+ end: number;
929
+ }
930
+
931
+ export function wordRanges(text: string): [number, number][] {
932
+ const ranges: [number, number][] = [];
933
+ const wordPattern = /\S+/g;
934
+ let match: RegExpExecArray | null;
935
+
936
+ while ((match = wordPattern.exec(text)))
937
+ ranges.push([match.index, match.index + match[0]!.length]);
938
+
939
+ return ranges;
940
+ }
941
+
942
+ export function startSpan(displayIndex: number, text: string): SpanState | null {
943
+ const words = wordRanges(text);
944
+
945
+ if (!words.length) return null;
946
+
947
+ return { displayIndex, wordIndex: 0, wordEnd: 0, start: words[0]![0], end: words[0]![1] };
948
+ }
949
+
950
+ /**
951
+ * Build a word-granular span from a raw char range (a mouse drag-selection),
952
+ * snapping to every word the range overlaps so w/b/l/h keep working after. Null
953
+ * when the range covers no word.
954
+ */
955
+ export function spanFromRange(
956
+ displayIndex: number,
957
+ text: string,
958
+ start: number,
959
+ end: number,
960
+ ): SpanState | null {
961
+ const words = wordRanges(text);
962
+ const wordIndex = words.findIndex((word) => word[1] > start);
963
+
964
+ if (wordIndex === -1) return null;
965
+ let wordEnd = wordIndex;
966
+
967
+ for (let index = wordIndex; index < words.length && words[index]![0] < end; index++) {
968
+ wordEnd = index;
969
+ }
970
+
971
+ return {
972
+ displayIndex,
973
+ wordIndex,
974
+ wordEnd,
975
+ start: words[wordIndex]![0],
976
+ end: words[wordEnd]![1],
977
+ };
978
+ }
979
+
980
+ export function spanKey(span: SpanState, key: string, text: string): SpanState {
981
+ const words = wordRanges(text);
982
+ const nextSpan = { ...span };
983
+ const width = nextSpan.wordEnd - nextSpan.wordIndex;
984
+
985
+ if (key === "l") nextSpan.wordEnd = Math.min(words.length - 1, nextSpan.wordEnd + 1);
986
+ else if (key === "h") nextSpan.wordEnd = Math.max(nextSpan.wordIndex, nextSpan.wordEnd - 1);
987
+ else if (key === "w") {
988
+ const slidTo = Math.min(words.length - 1 - width, nextSpan.wordIndex + 1);
989
+
990
+ nextSpan.wordIndex = slidTo;
991
+ nextSpan.wordEnd = slidTo + width;
992
+ } else if (key === "b") {
993
+ const slidTo = Math.max(0, nextSpan.wordIndex - 1);
994
+
995
+ nextSpan.wordIndex = slidTo;
996
+ nextSpan.wordEnd = slidTo + width;
997
+ } else if (key === "$") nextSpan.wordEnd = words.length - 1;
998
+ else if (key === "0") {
999
+ nextSpan.wordEnd = width;
1000
+ nextSpan.wordIndex = 0;
1001
+ } else return span;
1002
+ nextSpan.start = words[nextSpan.wordIndex]![0];
1003
+ nextSpan.end = words[nextSpan.wordEnd]![1];
1004
+
1005
+ return nextSpan;
1006
+ }
1007
+
1008
+ // ── working-copy block operations ─────────────
1009
+
1010
+ /** The next work block after a display index (a del block re-enters before it). */
1011
+ export function nextWorkBlock(display: DisplayBlock[], displayIndex: number): Block | undefined {
1012
+ for (let candidateIndex = displayIndex + 1; candidateIndex < display.length; candidateIndex++) {
1013
+ const workBlock = display[candidateIndex]!.work;
1014
+
1015
+ if (workBlock) return workBlock;
1016
+ }
1017
+
1018
+ return undefined;
1019
+ }