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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +39 -32
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +76 -50
  10. package/src/App.diff.test.tsx +156 -42
  11. package/src/App.export.test.tsx +58 -40
  12. package/src/App.plan-review.test.tsx +363 -0
  13. package/src/App.prototype-markdown.test.tsx +92 -0
  14. package/src/App.prototype.test.tsx +176 -0
  15. package/src/App.readonly.test.tsx +128 -67
  16. package/src/App.rendering.test.tsx +61 -35
  17. package/src/App.submit-confirm.test.tsx +191 -0
  18. package/src/App.test.tsx +310 -114
  19. package/src/App.toast.test.tsx +89 -0
  20. package/src/App.tsx +1043 -798
  21. package/src/App.walk.test.tsx +298 -0
  22. package/src/App.workbench.test.tsx +495 -0
  23. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  24. package/src/annotation-palette.ts +61 -0
  25. package/src/app-key-state.ts +61 -0
  26. package/src/app-screens.tsx +432 -0
  27. package/src/app-view-model.ts +159 -0
  28. package/src/attribution.test.ts +31 -0
  29. package/src/attribution.ts +42 -0
  30. package/src/clipboard.test.ts +23 -0
  31. package/src/clipboard.ts +33 -0
  32. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  33. package/src/components/AnnotatableFileView.test.tsx +102 -0
  34. package/src/components/AnnotatableFileView.tsx +126 -0
  35. package/src/components/AnnotationCards.stories.tsx +87 -0
  36. package/src/components/AnnotationCards.tsx +303 -0
  37. package/src/components/AppHeader.stories.tsx +50 -0
  38. package/src/components/AppHeader.tsx +83 -0
  39. package/src/components/AppShell.stories.tsx +89 -0
  40. package/src/components/AppShell.tsx +290 -0
  41. package/src/components/AppShell.zoom.test.tsx +135 -0
  42. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  43. package/src/components/BareWorkbenchFileView.tsx +64 -0
  44. package/src/components/Breadcrumb.stories.tsx +19 -0
  45. package/src/components/Breadcrumb.tsx +45 -0
  46. package/src/components/ChangesColumn.stories.tsx +39 -0
  47. package/src/components/ChangesColumn.tsx +171 -0
  48. package/src/components/CodeBlock.stories.tsx +42 -0
  49. package/src/components/CodeBlock.tsx +84 -0
  50. package/src/components/CompletionOverlay.stories.tsx +23 -0
  51. package/src/components/CompletionOverlay.tsx +60 -0
  52. package/src/components/ConfirmCard.stories.tsx +35 -0
  53. package/src/components/ConfirmCard.tsx +128 -0
  54. package/src/components/ConfirmDialog.stories.tsx +21 -0
  55. package/src/components/ConfirmDialog.tsx +66 -0
  56. package/src/components/DiffContentView.scroll.test.tsx +121 -0
  57. package/src/components/DiffContentView.split.test.tsx +140 -0
  58. package/src/components/DiffContentView.stories.tsx +80 -0
  59. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  60. package/src/components/DiffContentView.tsx +859 -0
  61. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  62. package/src/components/DiscussionMarkerRail.tsx +181 -0
  63. package/src/components/EditorGrid.stories.tsx +59 -0
  64. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  65. package/src/components/EditorGrid.tsx +368 -0
  66. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  67. package/src/components/FileContentsView.stories.tsx +16 -0
  68. package/src/components/FileContentsView.test.tsx +29 -0
  69. package/src/components/FileContentsView.tsx +98 -0
  70. package/src/components/KeybindsDialog.stories.tsx +31 -0
  71. package/src/components/KeybindsDialog.tsx +103 -0
  72. package/src/components/MenuBar.stories.tsx +28 -0
  73. package/src/components/MenuBar.tsx +44 -0
  74. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  75. package/src/components/OverlayScrollbar.test.tsx +139 -0
  76. package/src/components/OverlayScrollbar.tsx +129 -0
  77. package/src/components/PaneGrid.stories.tsx +84 -0
  78. package/src/components/PaneGrid.tsx +128 -0
  79. package/src/components/PanelColumn.stories.tsx +25 -0
  80. package/src/components/PanelColumn.tsx +95 -0
  81. package/src/components/ProjectTreeView.stories.tsx +25 -0
  82. package/src/components/ProjectTreeView.test.tsx +32 -0
  83. package/src/components/ProjectTreeView.tsx +83 -0
  84. package/src/components/PromptDialog.stories.tsx +36 -0
  85. package/src/components/PromptDialog.tsx +87 -0
  86. package/src/components/PrototypeContentView.stories.tsx +19 -0
  87. package/src/components/PrototypeContentView.tsx +446 -0
  88. package/src/components/RootOverlay.stories.tsx +44 -0
  89. package/src/components/RootOverlay.tsx +35 -0
  90. package/src/components/ScrollArea.stories.tsx +20 -0
  91. package/src/components/ScrollArea.tsx +44 -0
  92. package/src/components/SettingsDialog.stories.tsx +117 -0
  93. package/src/components/SettingsDialog.tsx +195 -0
  94. package/src/components/SettingsRows.stories.tsx +18 -0
  95. package/src/components/SettingsRows.tsx +112 -0
  96. package/src/components/ShellHeader.stories.tsx +50 -0
  97. package/src/components/ShellHeader.tsx +137 -0
  98. package/src/components/ThreadFooter.stories.tsx +30 -0
  99. package/src/components/ThreadFooter.test.tsx +31 -0
  100. package/src/components/ThreadFooter.tsx +54 -0
  101. package/src/components/ThreadTree.stories.tsx +46 -0
  102. package/src/components/ThreadTree.tsx +219 -0
  103. package/src/components/ThreadView.selection.test.tsx +812 -0
  104. package/src/components/ThreadView.slash.test.tsx +158 -0
  105. package/src/components/ThreadView.stories.tsx +26 -0
  106. package/src/components/ThreadView.tsx +330 -0
  107. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  108. package/src/components/ThreadsSidebar.tsx +71 -0
  109. package/src/components/Toast.stories.tsx +12 -0
  110. package/src/components/Toast.tsx +45 -0
  111. package/src/components/Tooltip.stories.tsx +26 -0
  112. package/src/components/Tooltip.tsx +72 -0
  113. package/src/components/TreePane.stories.tsx +106 -0
  114. package/src/components/TreePane.tsx +157 -0
  115. package/src/components/WalkWizard.stories.tsx +100 -0
  116. package/src/components/WalkWizard.tsx +152 -0
  117. package/src/components/WelcomePlayground.stories.tsx +12 -0
  118. package/src/components/WelcomePlayground.test.tsx +47 -0
  119. package/src/components/WelcomePlayground.tsx +140 -0
  120. package/src/components/__snapshots__/stories.test.tsx.snap +1845 -0
  121. package/src/components/agent-launcher.stories.tsx +26 -0
  122. package/src/components/agent-launcher.test.tsx +50 -0
  123. package/src/components/agent-launcher.tsx +149 -0
  124. package/src/components/diff-content-view-layout.test.ts +198 -0
  125. package/src/components/diff-content-view-layout.ts +84 -0
  126. package/src/components/editor-grid.test.ts +86 -0
  127. package/src/components/editor-grid.ts +138 -0
  128. package/src/components/fade-title.test.ts +49 -0
  129. package/src/components/fade-title.ts +61 -0
  130. package/src/components/file-tree.test.ts +60 -0
  131. package/src/components/file-tree.ts +112 -0
  132. package/src/components/pages/Workspace.stories.tsx +181 -0
  133. package/src/components/primitives/Button.stories.tsx +32 -0
  134. package/src/components/primitives/Button.tsx +52 -0
  135. package/src/components/primitives/Card.stories.tsx +25 -0
  136. package/src/components/primitives/Card.tsx +60 -0
  137. package/src/components/primitives/Dialog.stories.tsx +24 -0
  138. package/src/components/primitives/Dialog.tsx +66 -0
  139. package/src/components/primitives/IconButton.stories.tsx +36 -0
  140. package/src/components/primitives/IconButton.tsx +62 -0
  141. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  142. package/src/components/primitives/StatusBar.tsx +23 -0
  143. package/src/components/primitives/Tabs.stories.tsx +29 -0
  144. package/src/components/primitives/Tabs.tsx +102 -0
  145. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  146. package/src/components/primitives/Toolbar.tsx +14 -0
  147. package/src/components/primitives/Tree.stories.tsx +124 -0
  148. package/src/components/primitives/Tree.tsx +96 -0
  149. package/src/components/primitives/frame.test.ts +12 -0
  150. package/src/components/primitives/frame.ts +14 -0
  151. package/src/components/primitives/icons.ts +61 -0
  152. package/src/components/primitives/tree-model.test.ts +99 -0
  153. package/src/components/primitives/tree-model.ts +137 -0
  154. package/src/components/quick-actions-editor.stories.tsx +54 -0
  155. package/src/components/quick-actions-editor.test.tsx +49 -0
  156. package/src/components/quick-actions-editor.tsx +150 -0
  157. package/src/components/session-tree.test.ts +134 -0
  158. package/src/components/session-tree.ts +108 -0
  159. package/src/components/stories-app.tsx +184 -0
  160. package/src/components/stories.test.tsx +66 -0
  161. package/src/components/story-fixtures.ts +102 -0
  162. package/src/components/story-tree.test.ts +100 -0
  163. package/src/components/story.ts +142 -0
  164. package/src/components/syntax-highlight.ts +110 -0
  165. package/src/components/terminal-pane.test.tsx +40 -0
  166. package/src/components/terminal-pane.ts +199 -0
  167. package/src/components/theme-context.stories.tsx +46 -0
  168. package/src/components/theme-context.tsx +37 -0
  169. package/src/components/truncate-text.ts +7 -0
  170. package/src/config.test.ts +461 -31
  171. package/src/config.ts +389 -57
  172. package/src/diff-fold.test.ts +78 -0
  173. package/src/diff-fold.ts +126 -0
  174. package/src/diff-hunk-curate.test.ts +315 -0
  175. package/src/diff-hunk-curate.ts +153 -0
  176. package/src/diff-intraline.test.ts +165 -0
  177. package/src/diff-intraline.ts +146 -0
  178. package/src/diff-syntax.test.ts +112 -0
  179. package/src/diff-syntax.ts +179 -0
  180. package/src/discussions.ts +79 -0
  181. package/src/editor.test.ts +149 -0
  182. package/src/editor.ts +139 -9
  183. package/src/ghostty-terminal.test.ts +85 -0
  184. package/src/ghostty-terminal.ts +199 -0
  185. package/src/herdr.test.ts +9 -34
  186. package/src/herdr.ts +25 -21
  187. package/src/index.ts +11 -0
  188. package/src/integrations.test.ts +41 -0
  189. package/src/integrations.ts +32 -0
  190. package/src/intent-dispatch.test.ts +698 -0
  191. package/src/intent-dispatch.ts +700 -0
  192. package/src/key-bindings.test.ts +156 -0
  193. package/src/key-bindings.ts +465 -0
  194. package/src/keymap.test.ts +611 -0
  195. package/src/keymap.ts +410 -0
  196. package/src/kitty-image.test.ts +66 -0
  197. package/src/kitty-image.ts +138 -0
  198. package/src/mark-runs.ts +120 -0
  199. package/src/prototype-browser.test.ts +56 -0
  200. package/src/prototype-browser.ts +307 -0
  201. package/src/prototype-pixels.tsx +35 -0
  202. package/src/pty.test.ts +81 -0
  203. package/src/pty.ts +245 -0
  204. package/src/ready-signal.test.ts +85 -0
  205. package/src/ready-signal.ts +52 -0
  206. package/src/run.ts +30 -9
  207. package/src/serve.test.ts +24 -12
  208. package/src/serve.ts +20 -9
  209. package/src/share.ts +215 -0
  210. package/src/skills.test.ts +74 -0
  211. package/src/skills.ts +68 -0
  212. package/src/slash-palette.test.ts +54 -0
  213. package/src/slash-palette.ts +104 -0
  214. package/src/split-diff.test.ts +110 -0
  215. package/src/split-diff.ts +149 -0
  216. package/src/test-support.test.ts +264 -0
  217. package/src/test-support.ts +355 -0
  218. package/src/theme-presets.test.ts +51 -0
  219. package/src/theme-presets.ts +214 -0
  220. package/src/theme.ts +84 -19
  221. package/src/thread-chords.test.ts +170 -0
  222. package/src/thread-chords.ts +169 -0
  223. package/src/thread-controller.curate.test.ts +438 -0
  224. package/src/thread-controller.live-diff.test.ts +199 -0
  225. package/src/thread-controller.share.test.ts +472 -0
  226. package/src/thread-controller.tree.test.ts +255 -0
  227. package/src/thread-controller.ts +1509 -0
  228. package/src/thread-selection.test.ts +210 -0
  229. package/src/thread-selection.ts +243 -0
  230. package/src/tree-view.test.ts +128 -0
  231. package/src/tree-view.ts +148 -0
  232. package/src/use-annotation-surface.tsx +851 -0
  233. package/src/use-changes-workbench.ts +153 -0
  234. package/src/use-frame-measure.ts +38 -0
  235. package/src/use-settings-dialog.tsx +274 -0
  236. package/src/use-terminal-virtualizer.ts +233 -0
  237. package/src/version.ts +5 -0
  238. package/src/view-diff.test.ts +66 -9
  239. package/src/view-diff.ts +231 -23
  240. package/src/view-plan.regression.test.ts +60 -0
  241. package/src/view-plan.test.ts +406 -0
  242. package/src/view-plan.ts +570 -0
  243. package/src/walk.test.ts +121 -0
  244. package/src/walk.ts +74 -0
  245. package/src/syntax.test.ts +0 -41
  246. package/src/syntax.ts +0 -88
  247. package/src/view.test.ts +0 -157
  248. package/src/view.ts +0 -312
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { diffRowAnchor, diffRows } from "./view-diff";
2
+ import { makeAnchor, type Annotation } from "@cueloop/schema";
3
+ import { diffRowAnchor, diffRowBlocks, diffRows, fileRowRange, fileTargetMarks } from "./view-diff";
3
4
 
4
5
  const PATCH = `diff --git a/src/store.ts b/src/store.ts
5
6
  index 111..222 100644
@@ -21,23 +22,32 @@ index 111..222 100644
21
22
 
22
23
  describe("diffRows", () => {
23
24
  test("flattens files, hunks, and signed lines with line numbers", () => {
25
+ // Act
24
26
  const rows = diffRows(PATCH);
25
- expect(rows[0]).toMatchObject({ t: "file", file: "src/store.ts" });
26
- expect(rows[1]!.t).toBe("hunk");
27
- const del = rows.find((r) => r.t === "del")!;
27
+
28
+ // Assert
29
+ expect(rows[0]).toMatchObject({ kind: "file", file: "src/store.ts" });
30
+ expect(rows[1]!.kind).toBe("hunk");
31
+ const del = rows.find((row) => row.kind === "del")!;
32
+
28
33
  expect(del.text).toContain("private items = [];");
29
34
  expect(del.oldLine).toBe(3);
30
- const adds = rows.filter((r) => r.t === "add");
35
+ const adds = rows.filter((row) => row.kind === "add");
36
+
31
37
  expect(adds[0]!.text).toContain("new Map()");
32
38
  expect(adds[0]!.newLine).toBe(3);
33
39
  expect(adds[1]!.newLine).toBe(4);
34
40
  // two hunks
35
- expect(rows.filter((r) => r.t === "hunk").length).toBe(2);
41
+ expect(rows.filter((row) => row.kind === "hunk").length).toBe(2);
36
42
  });
37
43
 
38
44
  test("context lines carry both line numbers", () => {
45
+ // Act
39
46
  const rows = diffRows(PATCH);
40
- const ctx = rows.find((r) => r.t === "ctx")!;
47
+
48
+ // Assert
49
+ const ctx = rows.find((row) => row.kind === "ctx")!;
50
+
41
51
  expect(ctx.oldLine).toBe(1);
42
52
  expect(ctx.newLine).toBe(1);
43
53
  });
@@ -45,11 +55,58 @@ describe("diffRows", () => {
45
55
 
46
56
  describe("diffRowAnchor", () => {
47
57
  test("quote is the line, neighbors are the context selectors", () => {
58
+ // Arrange
48
59
  const rows = diffRows(PATCH);
49
- const idx = rows.findIndex((r) => r.text.includes("new Map()"));
50
- const anchor = diffRowAnchor(rows, idx);
60
+ const rowIndex = rows.findIndex((row) => row.text.includes("new Map()"));
61
+
62
+ // Act
63
+ const anchor = diffRowAnchor(rows, rowIndex);
64
+
65
+ // Assert
51
66
  expect(anchor.quote).toContain("new Map()");
52
67
  expect(anchor.prefix.length).toBeGreaterThan(0);
53
68
  expect(anchor.suffix.length).toBeGreaterThan(0);
54
69
  });
55
70
  });
71
+
72
+ describe("fileTargetMarks", () => {
73
+ // two files whose added line is byte-identical; a note on one must never mark the other
74
+ const TWO_FILE = `diff --git a/a.ts b/a.ts
75
+ --- a/a.ts
76
+ +++ b/a.ts
77
+ @@ -1,1 +1,2 @@
78
+ const keep = 0;
79
+ +const dup = 1;
80
+ diff --git a/b.ts b/b.ts
81
+ --- a/b.ts
82
+ +++ b/b.ts
83
+ @@ -1,1 +1,2 @@
84
+ const keep = 0;
85
+ +const dup = 1;
86
+ `;
87
+
88
+ test("a file-target note marks only its own file, never identical text in another", () => {
89
+ // Arrange - anchor the added line inside a.ts, file-relative like the controller does
90
+ const rows = diffRows(TWO_FILE);
91
+ const range = fileRowRange(rows, "a.ts")!;
92
+ const fileRows = rows.slice(range.start, range.end);
93
+ const addRelative = fileRows.findIndex((row) => row.kind === "add");
94
+ const quote = fileRows[addRelative]!.text.replace(/\n$/, "");
95
+ const anchor = makeAnchor(diffRowBlocks(fileRows), addRelative, 0, quote.length, addRelative);
96
+ const annotation: Annotation = {
97
+ id: "a1",
98
+ kind: "comment",
99
+ anchor,
100
+ target: { kind: "file", path: "a.ts", rev: "worktree" },
101
+ body: "note",
102
+ createdAt: "2026-01-01T00:00:00Z",
103
+ };
104
+
105
+ // Act
106
+ const marked = [...fileTargetMarks([annotation], rows).keys()];
107
+
108
+ // Assert - every marked row sits inside a.ts's range, and b.ts's identical line is untouched
109
+ expect(marked.length).toBeGreaterThan(0);
110
+ expect(marked.every((index) => index >= range.start && index < range.end)).toBe(true);
111
+ });
112
+ });
package/src/view-diff.ts CHANGED
@@ -1,16 +1,30 @@
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.
6
6
  */
7
7
 
8
8
  import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs";
9
+ import {
10
+ annotationTarget,
11
+ isAddressed,
12
+ resolveAnchor,
13
+ type Annotation,
14
+ type Block,
15
+ type Thread,
16
+ } from "@cueloop/schema";
17
+ import { spanRangeInBlock, type TextSpan } from "./thread-selection";
18
+ import type { Mark } from "./view-plan";
19
+ import { discussionsFrom } from "./discussions";
9
20
 
10
- export type DiffRowType = "file" | "hunk" | "ctx" | "add" | "del";
21
+ // the diff sheet reads marks over rows as a diff concept; re-export so it need not reach into view-plan
22
+ export type { Mark } from "./view-plan";
23
+
24
+ export type DiffRowKind = "file" | "hunk" | "ctx" | "add" | "del";
11
25
 
12
26
  export interface DiffRow {
13
- t: DiffRowType;
27
+ kind: DiffRowKind;
14
28
  text: string;
15
29
  file: string;
16
30
  oldLine?: number;
@@ -20,37 +34,39 @@ export interface DiffRow {
20
34
  export function diffRows(patchText: string): DiffRow[] {
21
35
  const rows: DiffRow[] = [];
22
36
  const patches = parsePatchFiles(patchText);
37
+
23
38
  for (const patch of patches) {
24
39
  for (const file of patch.files) {
25
- rows.push({ t: "file", text: fileLabel(file), file: file.name });
40
+ rows.push({ kind: "file", text: fileLabel(file), file: file.name });
26
41
  for (const hunk of file.hunks) {
27
- rows.push({ t: "hunk", text: hunk.hunkSpecs ?? "@@", file: file.name });
42
+ rows.push({ kind: "hunk", text: hunk.hunkSpecs ?? "@@", file: file.name });
28
43
  let oldLine = hunk.deletionStart;
29
44
  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++) {
45
+
46
+ for (const segment of hunk.hunkContent) {
47
+ if (segment.type === "context") {
48
+ for (let lineOffset = 0; lineOffset < segment.lines; lineOffset++) {
33
49
  rows.push({
34
- t: "ctx",
35
- text: file.additionLines[seg.additionLineIndex + i] ?? "",
50
+ kind: "ctx",
51
+ text: file.additionLines[segment.additionLineIndex + lineOffset] ?? "",
36
52
  file: file.name,
37
53
  oldLine: oldLine++,
38
54
  newLine: newLine++,
39
55
  });
40
56
  }
41
57
  } else {
42
- for (let i = 0; i < seg.deletions; i++) {
58
+ for (let lineOffset = 0; lineOffset < segment.deletions; lineOffset++) {
43
59
  rows.push({
44
- t: "del",
45
- text: file.deletionLines[seg.deletionLineIndex + i] ?? "",
60
+ kind: "del",
61
+ text: file.deletionLines[segment.deletionLineIndex + lineOffset] ?? "",
46
62
  file: file.name,
47
63
  oldLine: oldLine++,
48
64
  });
49
65
  }
50
- for (let i = 0; i < seg.additions; i++) {
66
+ for (let lineOffset = 0; lineOffset < segment.additions; lineOffset++) {
51
67
  rows.push({
52
- t: "add",
53
- text: file.additionLines[seg.additionLineIndex + i] ?? "",
68
+ kind: "add",
69
+ text: file.additionLines[segment.additionLineIndex + lineOffset] ?? "",
54
70
  file: file.name,
55
71
  newLine: newLine++,
56
72
  });
@@ -60,27 +76,219 @@ export function diffRows(patchText: string): DiffRow[] {
60
76
  }
61
77
  }
62
78
  }
79
+
63
80
  return rows;
64
81
  }
65
82
 
83
+ /** Added and removed line counts per file path, for the file band's badge; base rows only. */
84
+ export function fileChangeCounts(
85
+ rows: DiffRow[],
86
+ ): Map<string, { additions: number; deletions: number }> {
87
+ const counts = new Map<string, { additions: number; deletions: number }>();
88
+
89
+ for (const row of rows) {
90
+ if (row.kind !== "add" && row.kind !== "del") continue;
91
+ const entry = counts.get(row.file) ?? { additions: 0, deletions: 0 };
92
+
93
+ if (row.kind === "add") entry.additions += 1;
94
+ else entry.deletions += 1;
95
+ counts.set(row.file, entry);
96
+ }
97
+
98
+ return counts;
99
+ }
100
+
66
101
  function fileLabel(file: FileDiffMetadata): string {
67
- return file.prevName && file.prevName !== file.name ? `${file.prevName} → ${file.name}` : file.name;
102
+ return file.prevName && file.prevName !== file.name
103
+ ? `${file.prevName} → ${file.name}`
104
+ : file.name;
105
+ }
106
+
107
+ /** Row text carries the patch's trailing newline; anchors and rendering strip it. */
108
+ export function diffRowText(row: DiffRow): string {
109
+ return row.text.replace(/\n$/, "");
110
+ }
111
+
112
+ /**
113
+ * The diff rows as anchor blocks, one per row in row order, so a comment span anchors
114
+ * with the same quote/context/position selectors a plan block does. Header rows are
115
+ * blocks too (indices stay 1:1 with the rows) but hold no comment.
116
+ */
117
+ export function diffRowBlocks(rows: DiffRow[]): Block[] {
118
+ return rows.map((row, index) => ({
119
+ kind: "code",
120
+ text: row.kind === "file" || row.kind === "hunk" ? "" : diffRowText(row),
121
+ lineStart: index,
122
+ lineEnd: index,
123
+ }));
124
+ }
125
+
126
+ /**
127
+ * Resolve annotations against the diff rows and group marks per row index, with
128
+ * character ranges and the whole span, so the diff sheet paints and threads them
129
+ * exactly as the plan view does its blocks.
130
+ */
131
+ export function marksByRows(
132
+ annotations: Annotation[],
133
+ rows: DiffRow[],
134
+ focusedId?: string,
135
+ ): Map<number, Mark[]> {
136
+ const blocks = diffRowBlocks(rows);
137
+ const marksByIndex = new Map<number, Mark[]>();
138
+
139
+ for (const annotation of annotations) {
140
+ // an addressed annotation keeps its record but paints no highlight
141
+ if (isAddressed(annotation)) continue;
142
+ const resolved = resolveAnchor(annotation.anchor, blocks);
143
+
144
+ if (!resolved) continue;
145
+ const span: TextSpan = {
146
+ start: { blockIndex: resolved.blockIndex, char: resolved.start },
147
+ end: { blockIndex: resolved.endBlockIndex, char: resolved.end },
148
+ };
149
+
150
+ // a span paints on every row it covers; the mark carries the whole span
151
+ for (let rowIndex = resolved.blockIndex; rowIndex <= resolved.endBlockIndex; rowIndex++) {
152
+ const range = spanRangeInBlock(span, rowIndex, blocks[rowIndex]!.text.length);
153
+
154
+ if (!range) continue;
155
+ const marks = marksByIndex.get(rowIndex) ?? [];
156
+
157
+ marks.push({
158
+ start: range.start,
159
+ end: range.end,
160
+ role: annotation.id === focusedId ? "mark-focus" : "mark-comment",
161
+ annotationId: annotation.id,
162
+ span,
163
+ });
164
+ marksByIndex.set(rowIndex, marks);
165
+ }
166
+ }
167
+
168
+ return marksByIndex;
169
+ }
170
+
171
+ /**
172
+ * A whole file rendered as context rows, so the diff sheet can show and annotate a plain file the
173
+ * same way it does a diff: every line is unchanged, numbered on both sides (the file view collapses
174
+ * that to one number). A trailing newline does not add an empty final row.
175
+ */
176
+ export function fileContentsRows(path: string, contents: string): DiffRow[] {
177
+ const lines = contents.replace(/\n$/, "").split("\n");
178
+
179
+ return lines.map((text, index) => ({
180
+ kind: "ctx",
181
+ text,
182
+ file: path,
183
+ oldLine: index + 1,
184
+ newLine: index + 1,
185
+ }));
186
+ }
187
+
188
+ /** The contiguous row range a file occupies in the aggregate diff, or null when it is not shown. */
189
+ export function fileRowRange(rows: DiffRow[], path: string): { start: number; end: number } | null {
190
+ const start = rows.findIndex((row) => row.file === path);
191
+
192
+ if (start === -1) return null;
193
+ let end = start;
194
+
195
+ while (end < rows.length && rows[end]!.file === path) end++;
196
+
197
+ return { start, end };
198
+ }
199
+
200
+ /**
201
+ * Marks for file-target notes on the aggregate working-tree diff. Each note resolves against only
202
+ * its own file's rows, then the row index and span shift back to aggregate coordinates - so a quote
203
+ * never attaches to the same text in another file or on the opposite diff side.
204
+ */
205
+ export function fileTargetMarks(
206
+ annotations: Annotation[],
207
+ rows: DiffRow[],
208
+ focusedId?: string,
209
+ ): Map<number, Mark[]> {
210
+ const byFile = new Map<string, Annotation[]>();
211
+
212
+ for (const annotation of annotations) {
213
+ const target = annotationTarget(annotation);
214
+
215
+ if (target.kind !== "file") continue;
216
+ const forPath = byFile.get(target.path) ?? [];
217
+
218
+ forPath.push(annotation);
219
+ byFile.set(target.path, forPath);
220
+ }
221
+ const result = new Map<number, Mark[]>();
222
+
223
+ for (const [path, fileAnnotations] of byFile) {
224
+ const range = fileRowRange(rows, path);
225
+
226
+ if (!range) continue;
227
+ const base = range.start;
228
+ const fileMarks = marksByRows(fileAnnotations, rows.slice(range.start, range.end), focusedId);
229
+
230
+ for (const [relativeRow, marks] of fileMarks) {
231
+ result.set(
232
+ relativeRow + base,
233
+ marks.map((mark) =>
234
+ mark.span
235
+ ? {
236
+ ...mark,
237
+ span: {
238
+ start: { ...mark.span.start, blockIndex: mark.span.start.blockIndex + base },
239
+ end: { ...mark.span.end, blockIndex: mark.span.end.blockIndex + base },
240
+ },
241
+ }
242
+ : mark,
243
+ ),
244
+ );
245
+ }
246
+ }
247
+
248
+ return result;
249
+ }
250
+
251
+ /**
252
+ * Comments per file path across the diff: each discussion counts its comments (root plus
253
+ * replies) toward the file its span ends in. Feeds the changed-files tree and tab badges, so a
254
+ * file whose tab is closed still shows it carries feedback.
255
+ */
256
+ export function commentCountsByFile(session: Thread, rows: DiffRow[]): Map<string, number> {
257
+ const discussions = discussionsFrom(session, marksByRows(session.annotations, rows));
258
+ const counts = new Map<string, number>();
259
+
260
+ for (const discussion of discussions) {
261
+ const file = rows[discussion.blockIndex]?.file;
262
+
263
+ if (file === undefined) continue;
264
+ counts.set(file, (counts.get(file) ?? 0) + discussion.annotations.length);
265
+ }
266
+
267
+ return counts;
68
268
  }
69
269
 
70
270
  /** 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];
271
+ export function diffRowAnchor(rows: DiffRow[], rowIndex: number) {
272
+ const row = rows[rowIndex]!;
273
+ const prev = rows[rowIndex - 1];
274
+ const next = rows[rowIndex + 1];
275
+
75
276
  return {
76
277
  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) : "",
278
+ prefix:
279
+ prev && (prev.kind === "ctx" || prev.kind === "add" || prev.kind === "del")
280
+ ? prev.text.slice(-24)
281
+ : "",
282
+ suffix:
283
+ next && (next.kind === "ctx" || next.kind === "add" || next.kind === "del")
284
+ ? next.text.slice(0, 24)
285
+ : "",
79
286
  };
80
287
  }
81
288
 
82
289
  /** Location label for the rail and feedback: file:newLine (or old for del). */
83
290
  export function diffRowLocation(row: DiffRow): string {
84
291
  const line = row.newLine ?? row.oldLine;
292
+
85
293
  return line !== undefined ? `${row.file}:${line}` : row.file;
86
294
  }
@@ -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
+ });