@cueloop/client 0.1.0-alpha.5 → 0.1.0-alpha.51

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 (152) 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 +36 -31
  8. package/src/App.autoclose.test.tsx +137 -0
  9. package/src/App.diff.test.tsx +158 -22
  10. package/src/App.export.test.tsx +44 -37
  11. package/src/App.plan-review.test.tsx +672 -0
  12. package/src/App.prototype.test.tsx +155 -0
  13. package/src/App.readonly.test.tsx +60 -36
  14. package/src/App.rendering.test.tsx +132 -0
  15. package/src/App.review-panel.test.tsx +136 -0
  16. package/src/App.submit-confirm.test.tsx +218 -0
  17. package/src/App.test.tsx +128 -69
  18. package/src/App.toast.test.tsx +75 -0
  19. package/src/App.tsx +0 -0
  20. package/src/App.walk.test.tsx +289 -0
  21. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  22. package/src/attribution.test.ts +31 -0
  23. package/src/attribution.ts +38 -0
  24. package/src/clipboard.test.ts +23 -0
  25. package/src/clipboard.ts +30 -0
  26. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  27. package/src/components/AnnotationCard.stories.tsx +94 -0
  28. package/src/components/AnnotationCard.tsx +241 -0
  29. package/src/components/Breadcrumb.stories.tsx +19 -0
  30. package/src/components/Breadcrumb.tsx +44 -0
  31. package/src/components/CodeBlock.stories.tsx +42 -0
  32. package/src/components/CodeBlock.tsx +83 -0
  33. package/src/components/CompletionOverlay.stories.tsx +23 -0
  34. package/src/components/CompletionOverlay.tsx +59 -0
  35. package/src/components/ConfirmCard.stories.tsx +35 -0
  36. package/src/components/ConfirmCard.tsx +127 -0
  37. package/src/components/ConfirmDialog.stories.tsx +21 -0
  38. package/src/components/ConfirmDialog.tsx +64 -0
  39. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  40. package/src/components/DiffSheet.stories.tsx +78 -0
  41. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  42. package/src/components/DiffSheet.tsx +355 -0
  43. package/src/components/InboxList.stories.tsx +31 -0
  44. package/src/components/InboxList.tsx +62 -0
  45. package/src/components/KeybindsDialog.stories.tsx +31 -0
  46. package/src/components/KeybindsDialog.tsx +58 -0
  47. package/src/components/MarkerPopover.stories.tsx +58 -0
  48. package/src/components/MarkerPopover.tsx +108 -0
  49. package/src/components/MenuBar.stories.tsx +37 -0
  50. package/src/components/MenuBar.tsx +74 -0
  51. package/src/components/PlanSheet.stories.tsx +113 -0
  52. package/src/components/PlanSheet.tsx +456 -0
  53. package/src/components/PromptDialog.stories.tsx +36 -0
  54. package/src/components/PromptDialog.tsx +78 -0
  55. package/src/components/PrototypeSheet.stories.tsx +20 -0
  56. package/src/components/PrototypeSheet.tsx +358 -0
  57. package/src/components/ReviewPanel.stories.tsx +86 -0
  58. package/src/components/ReviewPanel.tsx +101 -0
  59. package/src/components/ReviewRail.stories.tsx +218 -0
  60. package/src/components/ReviewRail.tsx +317 -0
  61. package/src/components/SettingsDialog.stories.tsx +111 -0
  62. package/src/components/SettingsDialog.tsx +141 -0
  63. package/src/components/SettingsRows.stories.tsx +18 -0
  64. package/src/components/SettingsRows.tsx +108 -0
  65. package/src/components/Toast.stories.tsx +12 -0
  66. package/src/components/Toast.tsx +44 -0
  67. package/src/components/WalkWizard.stories.tsx +100 -0
  68. package/src/components/WalkWizard.tsx +152 -0
  69. package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
  70. package/src/components/agent-launcher.stories.tsx +26 -0
  71. package/src/components/agent-launcher.test.tsx +49 -0
  72. package/src/components/agent-launcher.tsx +147 -0
  73. package/src/components/diff-sheet-layout.test.ts +127 -0
  74. package/src/components/diff-sheet-layout.ts +167 -0
  75. package/src/components/primitives/Button.stories.tsx +32 -0
  76. package/src/components/primitives/Button.tsx +51 -0
  77. package/src/components/primitives/Card.stories.tsx +25 -0
  78. package/src/components/primitives/Card.tsx +59 -0
  79. package/src/components/primitives/Dialog.stories.tsx +24 -0
  80. package/src/components/primitives/Dialog.tsx +64 -0
  81. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  82. package/src/components/primitives/StatusBar.tsx +22 -0
  83. package/src/components/primitives/Tabs.stories.tsx +29 -0
  84. package/src/components/primitives/Tabs.tsx +98 -0
  85. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  86. package/src/components/primitives/Toolbar.tsx +14 -0
  87. package/src/components/primitives/frame.test.ts +12 -0
  88. package/src/components/primitives/frame.ts +14 -0
  89. package/src/components/quick-actions-editor.stories.tsx +54 -0
  90. package/src/components/quick-actions-editor.tsx +136 -0
  91. package/src/components/stories-app.tsx +123 -0
  92. package/src/components/stories.test.tsx +62 -0
  93. package/src/components/story-fixtures.ts +98 -0
  94. package/src/components/story.ts +69 -0
  95. package/src/components/syntax-highlight.ts +92 -0
  96. package/src/components/terminal-pane.test.tsx +40 -0
  97. package/src/components/terminal-pane.ts +181 -0
  98. package/src/components/theme-context.stories.tsx +46 -0
  99. package/src/components/theme-context.tsx +36 -0
  100. package/src/components/truncate-text.ts +6 -0
  101. package/src/config.test.ts +430 -31
  102. package/src/config.ts +304 -26
  103. package/src/diff-hunk-curate.test.ts +315 -0
  104. package/src/diff-hunk-curate.ts +233 -0
  105. package/src/diff-intraline.test.ts +164 -0
  106. package/src/diff-intraline.ts +132 -0
  107. package/src/diff-syntax.test.ts +96 -0
  108. package/src/diff-syntax.ts +154 -0
  109. package/src/editor.test.ts +143 -0
  110. package/src/editor.ts +123 -9
  111. package/src/ghostty-terminal.test.ts +82 -0
  112. package/src/ghostty-terminal.ts +168 -0
  113. package/src/herdr.test.ts +41 -0
  114. package/src/herdr.ts +27 -0
  115. package/src/index.ts +10 -0
  116. package/src/integrations.test.ts +41 -0
  117. package/src/integrations.ts +30 -0
  118. package/src/intent-dispatch.test.ts +493 -0
  119. package/src/intent-dispatch.ts +428 -0
  120. package/src/key-bindings.test.ts +152 -0
  121. package/src/key-bindings.ts +430 -0
  122. package/src/keymap.test.ts +622 -0
  123. package/src/keymap.ts +339 -0
  124. package/src/kitty-image.test.ts +20 -0
  125. package/src/kitty-image.ts +75 -0
  126. package/src/prototype-browser.test.ts +55 -0
  127. package/src/prototype-browser.ts +243 -0
  128. package/src/pty.test.ts +78 -0
  129. package/src/pty.ts +222 -0
  130. package/src/review-panel.test.ts +113 -0
  131. package/src/review-panel.ts +83 -0
  132. package/src/run.ts +9 -5
  133. package/src/serve.test.ts +24 -9
  134. package/src/serve.ts +7 -7
  135. package/src/session-controller.curate.test.ts +253 -0
  136. package/src/session-controller.share.test.ts +309 -0
  137. package/src/session-controller.ts +914 -0
  138. package/src/share.ts +113 -0
  139. package/src/test-support.test.ts +55 -0
  140. package/src/test-support.ts +187 -0
  141. package/src/theme-presets.ts +168 -0
  142. package/src/theme.ts +38 -17
  143. package/src/version.ts +5 -0
  144. package/src/view-diff.test.ts +19 -8
  145. package/src/view-diff.ts +34 -23
  146. package/src/view-plan.regression.test.ts +60 -0
  147. package/src/view-plan.test.ts +399 -0
  148. package/src/view-plan.ts +495 -0
  149. package/src/walk.test.ts +121 -0
  150. package/src/walk.ts +70 -0
  151. package/src/view.test.ts +0 -157
  152. package/src/view.ts +0 -0
package/src/view-diff.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Diff artifact projection (#18 slice 3): flatten @pierre/diffs' parsed
2
+ * Diff artifact projection: flatten @pierre/diffs' parsed
3
3
  * patch model into render rows. Line-anchored annotations use the same
4
4
  * quote-primary anchors as plans: quote = the line content, prefix/suffix =
5
5
  * the neighbor lines - so the whole anchor/feedback pipeline is shared.
@@ -7,10 +7,10 @@
7
7
 
8
8
  import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs";
9
9
 
10
- export type DiffRowType = "file" | "hunk" | "ctx" | "add" | "del";
10
+ export type DiffRowKind = "file" | "hunk" | "ctx" | "add" | "del";
11
11
 
12
12
  export interface DiffRow {
13
- t: DiffRowType;
13
+ kind: DiffRowKind;
14
14
  text: string;
15
15
  file: string;
16
16
  oldLine?: number;
@@ -22,35 +22,35 @@ export function diffRows(patchText: string): DiffRow[] {
22
22
  const patches = parsePatchFiles(patchText);
23
23
  for (const patch of patches) {
24
24
  for (const file of patch.files) {
25
- rows.push({ t: "file", text: fileLabel(file), file: file.name });
25
+ rows.push({ kind: "file", text: fileLabel(file), file: file.name });
26
26
  for (const hunk of file.hunks) {
27
- rows.push({ t: "hunk", text: hunk.hunkSpecs ?? "@@", file: file.name });
27
+ rows.push({ kind: "hunk", text: hunk.hunkSpecs ?? "@@", file: file.name });
28
28
  let oldLine = hunk.deletionStart;
29
29
  let newLine = hunk.additionStart;
30
- for (const seg of hunk.hunkContent) {
31
- if (seg.type === "context") {
32
- for (let i = 0; i < seg.lines; i++) {
30
+ for (const segment of hunk.hunkContent) {
31
+ if (segment.type === "context") {
32
+ for (let lineOffset = 0; lineOffset < segment.lines; lineOffset++) {
33
33
  rows.push({
34
- t: "ctx",
35
- text: file.additionLines[seg.additionLineIndex + i] ?? "",
34
+ kind: "ctx",
35
+ text: file.additionLines[segment.additionLineIndex + lineOffset] ?? "",
36
36
  file: file.name,
37
37
  oldLine: oldLine++,
38
38
  newLine: newLine++,
39
39
  });
40
40
  }
41
41
  } else {
42
- for (let i = 0; i < seg.deletions; i++) {
42
+ for (let lineOffset = 0; lineOffset < segment.deletions; lineOffset++) {
43
43
  rows.push({
44
- t: "del",
45
- text: file.deletionLines[seg.deletionLineIndex + i] ?? "",
44
+ kind: "del",
45
+ text: file.deletionLines[segment.deletionLineIndex + lineOffset] ?? "",
46
46
  file: file.name,
47
47
  oldLine: oldLine++,
48
48
  });
49
49
  }
50
- for (let i = 0; i < seg.additions; i++) {
50
+ for (let lineOffset = 0; lineOffset < segment.additions; lineOffset++) {
51
51
  rows.push({
52
- t: "add",
53
- text: file.additionLines[seg.additionLineIndex + i] ?? "",
52
+ kind: "add",
53
+ text: file.additionLines[segment.additionLineIndex + lineOffset] ?? "",
54
54
  file: file.name,
55
55
  newLine: newLine++,
56
56
  });
@@ -64,18 +64,29 @@ export function diffRows(patchText: string): DiffRow[] {
64
64
  }
65
65
 
66
66
  function fileLabel(file: FileDiffMetadata): string {
67
- return file.prevName && file.prevName !== file.name ? `${file.prevName} → ${file.name}` : file.name;
67
+ return file.prevName && file.prevName !== file.name
68
+ ? `${file.prevName} → ${file.name}`
69
+ : file.name;
68
70
  }
69
71
 
70
72
  /** Quote-primary anchor for a diff row: neighbors as context selectors. */
71
- export function diffRowAnchor(rows: DiffRow[], idx: number): { quote: string; prefix: string; suffix: string } {
72
- const row = rows[idx]!;
73
- const prev = rows[idx - 1];
74
- const next = rows[idx + 1];
73
+ export function diffRowAnchor(
74
+ rows: DiffRow[],
75
+ rowIndex: number,
76
+ ): { quote: string; prefix: string; suffix: string } {
77
+ const row = rows[rowIndex]!;
78
+ const prev = rows[rowIndex - 1];
79
+ const next = rows[rowIndex + 1];
75
80
  return {
76
81
  quote: row.text,
77
- prefix: prev && (prev.t === "ctx" || prev.t === "add" || prev.t === "del") ? prev.text.slice(-24) : "",
78
- suffix: next && (next.t === "ctx" || next.t === "add" || next.t === "del") ? next.text.slice(0, 24) : "",
82
+ prefix:
83
+ prev && (prev.kind === "ctx" || prev.kind === "add" || prev.kind === "del")
84
+ ? prev.text.slice(-24)
85
+ : "",
86
+ suffix:
87
+ next && (next.kind === "ctx" || next.kind === "add" || next.kind === "del")
88
+ ? next.text.slice(0, 24)
89
+ : "",
79
90
  };
80
91
  }
81
92
 
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ blockRuns,
4
+ buildDisplay,
5
+ displayText,
6
+ renderedOffsetFor,
7
+ workRangeForRendered,
8
+ } from "./view-plan";
9
+
10
+ // M0 (#232): lock the renderer's current StyleRun output and offset round-trips
11
+ // before the inline-tokenizer milestones (M1-M5) change blockRuns. The snapshots
12
+ // capture today's runs, so a later milestone's diff is explicit and reviewable.
13
+ // The round-trip assertions are the durable invariant: a visible word's rendered
14
+ // selection must always recover that word's *source* (block.text) offsets, even
15
+ // once inline markers are concealed - this is what keeps quote anchors exact.
16
+
17
+ const DOC = `# Heading with **bold**
18
+
19
+ A paragraph with **strong**, *em*, \`code\`, ~~strike~~ and a [label](https://example.com).
20
+
21
+ - a list item with *emphasis*
22
+ `;
23
+
24
+ const blocks = buildDisplay(DOC);
25
+
26
+ describe("view-plan regression harness (M0)", () => {
27
+ test("blockRuns output is stable across the representative blocks", () => {
28
+ // Arrange / Act
29
+ const runsByBlock = blocks.map((block) => ({
30
+ type: block.type,
31
+ text: displayText(block),
32
+ runs: blockRuns(block, true),
33
+ }));
34
+
35
+ // Assert
36
+ expect(runsByBlock).toMatchSnapshot();
37
+ });
38
+
39
+ // A visible word's source offsets survive the rendered round-trip. Holds at M0
40
+ // (1:1 today) and must keep holding once M2 conceals markers and the rendered
41
+ // offset shifts - the recovered work range stays the word's block.text span.
42
+ const paragraph = blocks.find((block) => displayText(block).includes("strong"))!;
43
+ const paragraphRuns = blockRuns(paragraph, true);
44
+ const paragraphText = displayText(paragraph);
45
+
46
+ for (const word of ["paragraph", "strong", "strike", "label"]) {
47
+ test(`rendered selection of "${word}" recovers its block.text offsets`, () => {
48
+ // Arrange
49
+ const start = paragraphText.indexOf(word);
50
+ const rendered = renderedOffsetFor(paragraphRuns, start);
51
+
52
+ // Assert
53
+ expect(rendered).not.toBeNull();
54
+ expect(workRangeForRendered(paragraphRuns, rendered!, rendered! + word.length)).toEqual({
55
+ start,
56
+ end: start + word.length,
57
+ });
58
+ });
59
+ }
60
+ });
@@ -0,0 +1,399 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ blockRuns,
4
+ buildDisplay,
5
+ displayText,
6
+ marksByDisplay,
7
+ nextWorkBlock,
8
+ overlayMarks,
9
+ renderedOffsetAtOrAfter,
10
+ renderedOffsetAtOrBefore,
11
+ renderedOffsetFor,
12
+ safeLinkHref,
13
+ spanFromRange,
14
+ spanKey,
15
+ startSpan,
16
+ workRangeForRendered,
17
+ } from "./view-plan";
18
+ import { cutBlock, parseBlocks, restoreBlock, restoreLine, type Annotation } from "@cueloop/schema";
19
+
20
+ const BASE = `# Plan
21
+
22
+ ## Context
23
+
24
+ The daemon persists sessions to disk atomically.
25
+
26
+ - first item
27
+ - second item
28
+ `;
29
+
30
+ describe("buildDisplay reconciliation", () => {
31
+ test("no working copy: everything is same", () => {
32
+ // Act
33
+ const display = buildDisplay(BASE);
34
+
35
+ // Assert
36
+ expect(display.every((block) => block.type === "same")).toBe(true);
37
+ });
38
+
39
+ test("reworded block pairs as mod (similar)", () => {
40
+ // Arrange
41
+ const working = BASE.replace("persists sessions to disk", "writes sessions to disk");
42
+
43
+ // Act
44
+ const display = buildDisplay(BASE, working);
45
+
46
+ // Assert
47
+ const mod = display.find((block) => block.type === "mod")!;
48
+ expect(mod.base!.text).toContain("persists");
49
+ expect(mod.work!.text).toContain("writes");
50
+ });
51
+
52
+ test("unrelated cut + insert do NOT merge into a mod", () => {
53
+ // Arrange
54
+ const working = BASE.replace("- second item\n", "") + "\nA totally new closing note.\n";
55
+
56
+ // Act
57
+ const display = buildDisplay(BASE, working);
58
+
59
+ // Assert
60
+ expect(
61
+ display.some((block) => block.type === "del" && block.base!.text === "second item"),
62
+ ).toBe(true);
63
+ expect(
64
+ display.some((block) => block.type === "add" && block.work!.text.includes("closing note")),
65
+ ).toBe(true);
66
+ expect(display.some((block) => block.type === "mod")).toBe(false);
67
+ });
68
+
69
+ test("ordered list items get running numbers", () => {
70
+ // Act
71
+ const display = buildDisplay("1. one\n2. two\n3. three\n");
72
+
73
+ // Assert
74
+ expect(display.map((block) => block.orderedItemNumber)).toEqual([1, 2, 3]);
75
+ });
76
+ });
77
+
78
+ describe("blockRuns + overlayMarks", () => {
79
+ test("mod blocks emit word-diff runs that reconstruct both sides", () => {
80
+ // Arrange
81
+ const block = buildDisplay("plain old text here\n", "plain new text here\n")[0]!;
82
+
83
+ // Act
84
+ const runs = blockRuns(block, true);
85
+
86
+ // Assert
87
+ const oldSide = runs
88
+ .filter((run) => run.role !== "ins")
89
+ .map((run) => run.text)
90
+ .join("");
91
+ const newSide = runs
92
+ .filter((run) => run.role !== "del")
93
+ .map((run) => run.text)
94
+ .join("");
95
+ expect(oldSide).toBe("plain old text here");
96
+ expect(newSide).toBe("plain new text here");
97
+ });
98
+
99
+ test("marks bind to working-text offsets and skip del runs", () => {
100
+ // Arrange
101
+ const block = buildDisplay("old words linger\n", "new words linger\n")[0]!;
102
+ const runs = blockRuns(block, true);
103
+
104
+ // Act
105
+ const marked = overlayMarks(runs, [{ start: 4, end: 9, role: "mark-comment" }]); // "words"
106
+
107
+ // Assert
108
+ const markedText = marked
109
+ .filter((run) => run.role === "mark-comment")
110
+ .map((run) => run.text)
111
+ .join("");
112
+ expect(markedText).toBe("words");
113
+ // del run text untouched
114
+ expect(marked.some((run) => run.role === "del" && run.text === "old")).toBe(true);
115
+ });
116
+
117
+ test("marks never override ins emphasis", () => {
118
+ // Arrange
119
+ const block = buildDisplay("keep\n", "keep added\n")[0]!;
120
+
121
+ // Act
122
+ const runs = overlayMarks(blockRuns(block, true), [
123
+ { start: 0, end: 10, role: "mark-comment" },
124
+ ]);
125
+
126
+ // Assert
127
+ expect(runs.some((run) => run.role === "ins")).toBe(true);
128
+ });
129
+ });
130
+
131
+ describe("M4: link hrefs are scheme-validated for OSC 8 (#236)", () => {
132
+ test("trusts http(s) and mailto, drops everything else", () => {
133
+ // Assert
134
+ expect(safeLinkHref("https://example.com")).toBe("https://example.com");
135
+ expect(safeLinkHref("http://x")).toBe("http://x");
136
+ expect(safeLinkHref("mailto:a@b.c")).toBe("mailto:a@b.c");
137
+ expect(safeLinkHref("javascript:alert(1)")).toBeUndefined();
138
+ expect(safeLinkHref("./relative/path")).toBeUndefined();
139
+ expect(safeLinkHref(undefined)).toBeUndefined();
140
+ });
141
+
142
+ test("a control byte or space in the target drops the link", () => {
143
+ // Assert - ESC and BEL could splice into the OSC 8 escape stream
144
+ expect(safeLinkHref("https://x\x1b]8;;evil\x07")).toBeUndefined();
145
+ expect(safeLinkHref("https://x\x07")).toBeUndefined();
146
+ expect(safeLinkHref("https://x y")).toBeUndefined();
147
+ });
148
+
149
+ test("blockRuns carries the link href through to the link run", () => {
150
+ // Act
151
+ const block = buildDisplay("see [docs](https://example.com) now\n")[0]!;
152
+ const link = blockRuns(block, true).find((run) => run.role === "link")!;
153
+
154
+ // Assert
155
+ expect(link.text).toBe("docs");
156
+ expect(link.href).toBe("https://example.com");
157
+ });
158
+ });
159
+
160
+ describe("M3: inline emphasis composes with word-diff (#235)", () => {
161
+ test("context emphasis survives; added text keeps the green diff role; markers concealed", () => {
162
+ // Arrange - "**this**" is unchanged (context, stays emphasized); "safe"->"sound" is the edit
163
+ const block = buildDisplay("keep **this** safe\n", "keep **this** sound\n")[0]!;
164
+
165
+ // Act
166
+ const runs = blockRuns(block, true);
167
+
168
+ // Assert - no marker text renders anywhere
169
+ expect(runs.every((run) => !run.text.includes("*"))).toBe(true);
170
+ // the unchanged emphasized word keeps its strong role (context, not diffed)
171
+ expect(runs.find((run) => run.text === "this")!.role).toBe("strong");
172
+ // the added word is green (ins wins over any emphasis), the removed one opaque
173
+ expect(runs.find((run) => run.text.includes("sound"))!.role).toBe("ins");
174
+ expect(runs.some((run) => run.role === "del" && run.start === null)).toBe(true);
175
+ });
176
+
177
+ test("markers inside added text conceal while the run stays a positioned ins", () => {
178
+ // Arrange - an emphasized word is added into an otherwise unchanged line
179
+ const block = buildDisplay("keep it safe\n", "keep it **very** safe\n")[0]!;
180
+
181
+ // Act
182
+ const runs = blockRuns(block, true);
183
+ const added = runs.find((run) => run.text === "very")!;
184
+
185
+ // Assert - concealed markers, green role, and an exact work offset back into work text
186
+ expect(added.role).toBe("ins");
187
+ expect(added.start).not.toBeNull();
188
+ expect(block.work!.text.slice(added.start!, added.start! + "very".length)).toBe("very");
189
+ expect(runs.every((run) => !run.text.includes("*"))).toBe(true);
190
+ });
191
+
192
+ test("an anchor over an emphasized context word round-trips to its work offsets", () => {
193
+ // Arrange
194
+ const block = buildDisplay("hold **steady** now\n", "hold **steady** later\n")[0]!;
195
+ const runs = blockRuns(block, true);
196
+ const work = block.work!.text;
197
+ const start = work.indexOf("steady");
198
+
199
+ // Act
200
+ const rendered = renderedOffsetFor(runs, start);
201
+
202
+ // Assert
203
+ expect(rendered).not.toBeNull();
204
+ expect(workRangeForRendered(runs, rendered!, rendered! + "steady".length)).toEqual({
205
+ start,
206
+ end: start + "steady".length,
207
+ });
208
+ });
209
+ });
210
+
211
+ describe("marksByDisplay", () => {
212
+ test("annotations resolve into display coordinates", () => {
213
+ // Arrange
214
+ const working = BASE.replace("first item", "first item edited beyond recognition of it");
215
+ const display = buildDisplay(BASE, working);
216
+ const ann: Annotation = {
217
+ id: "a1",
218
+ kind: "comment",
219
+ anchor: { quote: "atomically", prefix: "to disk ", suffix: "." },
220
+ body: "define atomically",
221
+ createdAt: "",
222
+ };
223
+
224
+ // Act
225
+ const map = marksByDisplay([ann], display);
226
+
227
+ // Assert
228
+ const [displayIndex, marks] = [...map.entries()][0]!;
229
+ expect(displayText(display[displayIndex]!)).toContain("atomically");
230
+ expect(marks[0]!.annotationId).toBe("a1");
231
+ });
232
+
233
+ test("orphaned annotations produce no marks", () => {
234
+ // Arrange
235
+ const display = buildDisplay(BASE);
236
+ const ann: Annotation = {
237
+ id: "a1",
238
+ kind: "comment",
239
+ anchor: { quote: "text that is gone", prefix: "", suffix: "" },
240
+ body: "",
241
+ createdAt: "",
242
+ };
243
+
244
+ // Assert
245
+ expect(marksByDisplay([ann], display).size).toBe(0);
246
+ });
247
+ });
248
+
249
+ describe("rendered/work offset mapping", () => {
250
+ test("plain blocks map 1:1 in both directions", () => {
251
+ // Arrange
252
+ const block = buildDisplay("alpha beta gamma\n")[0]!;
253
+ const runs = blockRuns(block, true);
254
+
255
+ // Assert
256
+ expect(renderedOffsetFor(runs, 6)).toBe(6);
257
+ expect(workRangeForRendered(runs, 6, 10)).toEqual({ start: 6, end: 10 });
258
+ });
259
+
260
+ test("inline del runs shift rendered offsets in word-diffed blocks", () => {
261
+ // Arrange
262
+ // rendered: "old new words linger" - "old " is a del run without offsets
263
+ const block = buildDisplay("old words linger\n", "new words linger\n")[0]!;
264
+ const runs = blockRuns(block, true);
265
+ const workText = displayText(block);
266
+ const wordsAt = workText.indexOf("words");
267
+
268
+ // Act
269
+ const rendered = renderedOffsetFor(runs, wordsAt)!;
270
+
271
+ // Assert
272
+ const renderedText = runs.map((run) => run.text).join("");
273
+ expect(renderedText.slice(rendered, rendered + "words".length)).toBe("words");
274
+ // reading a rendered selection over "words" recovers the work range
275
+ expect(workRangeForRendered(runs, rendered, rendered + "words".length)).toEqual({
276
+ start: wordsAt,
277
+ end: wordsAt + "words".length,
278
+ });
279
+ });
280
+
281
+ test("a rendered selection spanning a del run keeps only positioned text", () => {
282
+ // Arrange
283
+ const block = buildDisplay("old words\n", "new words\n")[0]!;
284
+ const runs = blockRuns(block, true);
285
+ const renderedText = runs.map((run) => run.text).join("");
286
+
287
+ // Act
288
+ // select everything rendered: the work range is the whole work text
289
+ const range = workRangeForRendered(runs, 0, renderedText.length)!;
290
+
291
+ // Assert
292
+ expect(displayText(block).slice(range.start, range.end)).toBe(displayText(block));
293
+ });
294
+
295
+ test("offsets outside any positioned run map to null", () => {
296
+ // Arrange
297
+ const runs = blockRuns(buildDisplay("short\n")[0]!, true);
298
+
299
+ // Assert
300
+ expect(renderedOffsetFor(runs, 99)).toBeNull();
301
+ expect(workRangeForRendered(runs, 90, 99)).toBeNull();
302
+ });
303
+
304
+ test("a span starting/ending on concealed markers snaps to the visible word", () => {
305
+ // Arrange - the word span over "**very**" covers the markers, which have
306
+ // no rendered cells; the clamped lookups land on "very" itself
307
+ const text = "keep it **very** safe\n";
308
+ const runs = blockRuns(buildDisplay(text)[0]!, true);
309
+ const spanStart = text.indexOf("**very**");
310
+ const spanEnd = spanStart + "**very**".length;
311
+
312
+ // Act
313
+ const renderedStart = renderedOffsetAtOrAfter(runs, spanStart)!;
314
+ const renderedEnd = renderedOffsetAtOrBefore(runs, spanEnd - 1)!;
315
+
316
+ // Assert
317
+ const renderedText = runs.map((run) => run.text).join("");
318
+ expect(renderedText.slice(renderedStart, renderedEnd + 1)).toBe("very");
319
+ // exact hits behave like renderedOffsetFor
320
+ expect(renderedOffsetAtOrAfter(runs, 0)).toBe(0);
321
+ expect(renderedOffsetAtOrBefore(runs, 0)).toBe(0);
322
+ // nothing positioned beyond the text end
323
+ expect(renderedOffsetAtOrAfter(runs, 99)).toBeNull();
324
+ });
325
+ });
326
+
327
+ describe("span mode", () => {
328
+ const text = "one two three four";
329
+ test("v selects the first word; l grows; w slides; $ to end", () => {
330
+ // Act
331
+ let span = startSpan(0, text)!;
332
+
333
+ // Assert
334
+ expect(text.slice(span.start, span.end)).toBe("one");
335
+
336
+ // Act
337
+ span = spanKey(span, "l", text);
338
+
339
+ // Assert
340
+ expect(text.slice(span.start, span.end)).toBe("one two");
341
+
342
+ // Act
343
+ span = spanKey(span, "w", text);
344
+
345
+ // Assert
346
+ expect(text.slice(span.start, span.end)).toBe("two three");
347
+
348
+ // Act
349
+ span = spanKey(span, "$", text);
350
+
351
+ // Assert
352
+ expect(text.slice(span.start, span.end)).toBe("two three four");
353
+ });
354
+
355
+ test("spanFromRange snaps a mid-word mouse drag to whole words", () => {
356
+ // Arrange - a drag from inside "two" to inside "three"
357
+ const dragStart = text.indexOf("two") + 1;
358
+ const dragEnd = text.indexOf("three") + 2;
359
+
360
+ // Act
361
+ const span = spanFromRange(0, text, dragStart, dragEnd)!;
362
+
363
+ // Assert - snaps outward to cover both touched words, ready for w/b/l/h
364
+ expect(text.slice(span.start, span.end)).toBe("two three");
365
+ expect(spanKey(span, "l", text)).toMatchObject({ wordEnd: 3 });
366
+ });
367
+
368
+ test("spanFromRange returns null when the range covers no word", () => {
369
+ // Assert - a range past the last word has nothing to select
370
+ expect(spanFromRange(0, text, text.length, text.length)).toBeNull();
371
+ });
372
+ });
373
+
374
+ describe("cut / restore round-trip", () => {
375
+ test("cut removes the block lines; restore puts them back", () => {
376
+ // Arrange
377
+ const blocks = parseBlocks(BASE);
378
+ const listItem = blocks.find((candidate) => candidate.text === "second item")!;
379
+
380
+ // Act
381
+ const cut = cutBlock(BASE, listItem);
382
+
383
+ // Assert
384
+ expect(cut).not.toContain("second item");
385
+
386
+ // Arrange
387
+ const display = buildDisplay(BASE, cut);
388
+ const deletedIndex = display.findIndex((block) => block.type === "del");
389
+ const line = restoreLine(nextWorkBlock(display, deletedIndex), cut.split("\n").length);
390
+
391
+ // Act
392
+ // undefined = the working copy round-tripped back to the submitted revision
393
+ const restored = restoreBlock(BASE, cut, listItem, line);
394
+
395
+ // Assert
396
+ expect(restored).toBeUndefined();
397
+ expect(buildDisplay(BASE, restored).every((block) => block.type === "same")).toBe(true);
398
+ });
399
+ });