@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,476 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ applyTextCuts,
4
+ cutBlock,
5
+ parseBlocks,
6
+ restoreBlock,
7
+ SCHEMA_VERSION,
8
+ type DiffFileContents,
9
+ type HunkRejection,
10
+ type Thread,
11
+ type TextCut,
12
+ } from "@cueloop/schema";
13
+ import { curateDiff } from "@cueloop/daemon/curate";
14
+ import type { ThreadClient } from "@cueloop/daemon/client";
15
+ import { createReviewController } from "./thread-controller";
16
+
17
+ const PATCH = `diff --git a/src/store.ts b/src/store.ts
18
+ index 111..222 100644
19
+ --- a/src/store.ts
20
+ +++ b/src/store.ts
21
+ @@ -1,4 +1,4 @@
22
+ export class Store {
23
+ - private items = [];
24
+ + private items = new Map();
25
+ }
26
+ `;
27
+
28
+ const FILES: DiffFileContents[] = [
29
+ {
30
+ path: "src/store.ts",
31
+ oldContents: "export class Store {\n private items = [];\n}\n",
32
+ newContents: "export class Store {\n private items = new Map();\n}\n",
33
+ status: "modified",
34
+ },
35
+ ];
36
+
37
+ function diffSession(files?: DiffFileContents[]): Thread {
38
+ return {
39
+ schemaVersion: SCHEMA_VERSION,
40
+ id: "ses_diff",
41
+ workspace: { repoRoot: "/repo", branch: "main" },
42
+ artifact: { type: "diff", content: PATCH, meta: {}, files },
43
+ revisions: [{ revision: 1, content: PATCH, submittedAt: "2026-01-01T00:00:00.000Z" }],
44
+ annotations: [],
45
+ message: null,
46
+ status: "pending",
47
+ createdAt: "2026-01-01T00:00:00.000Z",
48
+ };
49
+ }
50
+
51
+ interface WorkingCopySink {
52
+ workingCopy?: string;
53
+ textCuts?: TextCut[];
54
+ }
55
+
56
+ const unimplemented = (member: string) => () =>
57
+ Promise.reject(new Error(`fakeClient does not implement ${member}`));
58
+
59
+ /** A fake client that records the working copy the controller writes. */
60
+ function fakeClient(initial: Thread, sink: WorkingCopySink): ThreadClient {
61
+ let session = initial;
62
+
63
+ return {
64
+ onEvent: () => () => {},
65
+ subscribe: async () => {},
66
+ sessionGet: async () => session,
67
+ sessionList: async () => [session],
68
+ sessionComment: unimplemented("sessionComment"),
69
+ sessionAnnotate: unimplemented("sessionAnnotate"),
70
+ sessionRemoveAnnotation: unimplemented("sessionRemoveAnnotation"),
71
+ sessionSetWorkingCopy: mock(
72
+ async (_id: string, content: string | undefined, textCuts?: TextCut[]) => {
73
+ sink.workingCopy = content;
74
+ sink.textCuts = textCuts;
75
+ session = { ...session, workingCopy: content, textCuts };
76
+
77
+ return session;
78
+ },
79
+ ),
80
+ // the daemon's block primitives, stood in for with the same pure helpers it uses
81
+ sessionCutBlock: mock(async (_id: string, blockIndex: number) => {
82
+ const working = session.workingCopy ?? session.artifact.content;
83
+ const content = cutBlock(working, parseBlocks(working)[blockIndex]!);
84
+
85
+ sink.workingCopy = content;
86
+ session = { ...session, workingCopy: content };
87
+
88
+ return session;
89
+ }),
90
+ sessionRestoreBlock: mock(async (_id: string, baseBlockIndex: number, line?: number) => {
91
+ const base = session.artifact.content;
92
+ const working = session.workingCopy ?? base;
93
+ const content = restoreBlock(
94
+ base,
95
+ working,
96
+ parseBlocks(base)[baseBlockIndex]!,
97
+ line ?? working.split("\n").length,
98
+ );
99
+
100
+ sink.workingCopy = content;
101
+ session = { ...session, workingCopy: content };
102
+ if (content === undefined) delete session.workingCopy;
103
+
104
+ return session;
105
+ }),
106
+ // the daemon's curation, stood in for: the record carries the decisions and the patch they leave
107
+ sessionCurate: mock(async (_id: string, rejections: HunkRejection[]) => {
108
+ const content = rejections.length
109
+ ? curateDiff(session.artifact.files!, rejections)
110
+ : undefined;
111
+
112
+ sink.workingCopy = content;
113
+ session = { ...session, workingCopy: content };
114
+ if (rejections.length) session.curation = rejections;
115
+ else delete session.curation;
116
+
117
+ return session;
118
+ }),
119
+ sessionSetAccess: unimplemented("sessionSetAccess"),
120
+ sessionNavigate: unimplemented("sessionNavigate"),
121
+ sessionBranch: unimplemented("sessionBranch"),
122
+ sessionSwitch: unimplemented("sessionSwitch"),
123
+ sessionLabel: unimplemented("sessionLabel"),
124
+ sessionFork: unimplemented("sessionFork"),
125
+ sessionSetViewed: unimplemented("sessionSetViewed"),
126
+ sessionSetTitle: unimplemented("sessionSetTitle"),
127
+ sessionSetShareId: unimplemented("sessionSetShareId"),
128
+ sessionSetShares: unimplemented("sessionSetShares"),
129
+ sessionMergeShared: unimplemented("sessionMergeShared"),
130
+ sessionDelete: unimplemented("sessionDelete"),
131
+ sessionSetSelfName: unimplemented("sessionSetSelfName"),
132
+ sessionSendMessage: unimplemented("sessionSendMessage"),
133
+ close: () => {},
134
+ };
135
+ }
136
+
137
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
138
+
139
+ async function connected(session: Thread) {
140
+ const sink: WorkingCopySink = {};
141
+ const client = fakeClient(session, sink);
142
+ const controller = createReviewController({
143
+ sessionId: session.id,
144
+ openClient: async () => client,
145
+ });
146
+
147
+ controller.connect();
148
+ await tick();
149
+
150
+ return { controller, client, sink };
151
+ }
152
+
153
+ // rows for PATCH: file(0), hunk(1), ctx(2), del(3), add(4), ctx(5)
154
+ describe("diff hunk curation", () => {
155
+ test("rejecting the only change writes an empty curated working copy", async () => {
156
+ // Arrange
157
+ const { controller, sink } = await connected(diffSession(FILES));
158
+
159
+ // Act - reject the change on the added line
160
+ controller.toggleRejectChange(4);
161
+ await tick();
162
+
163
+ // Assert
164
+ expect(controller.getSnapshot().toast).toBeNull();
165
+ expect(sink.workingCopy).toBe("");
166
+ });
167
+
168
+ test("both the deletion and addition rows of the change dim", async () => {
169
+ // Arrange
170
+ const { controller } = await connected(diffSession(FILES));
171
+
172
+ // Act
173
+ controller.toggleRejectChange(4);
174
+ await tick();
175
+
176
+ // Assert - the change spans the del row and the add row
177
+ expect([...controller.rejectedRows()].sort((a, b) => a - b)).toEqual([3, 4]);
178
+ });
179
+
180
+ test("toggling the same change twice restores the full diff", async () => {
181
+ // Arrange
182
+ const { controller, sink } = await connected(diffSession(FILES));
183
+
184
+ // Act - reject then restore
185
+ controller.toggleRejectChange(4);
186
+ await tick();
187
+ controller.toggleRejectChange(4);
188
+ await tick();
189
+
190
+ // Assert - no decisions left, so the working copy reverts to undefined
191
+ expect(controller.getSnapshot().toast).toBeNull();
192
+ expect(sink.workingCopy).toBeUndefined();
193
+ expect(controller.rejectedRows().size).toBe(0);
194
+ });
195
+
196
+ test("whole-hunk reject drops the hunk from the working copy", async () => {
197
+ // Arrange
198
+ const { controller, sink } = await connected(diffSession(FILES));
199
+
200
+ // Act - reject the whole hunk from any of its rows
201
+ controller.toggleRejectHunk(3);
202
+ await tick();
203
+
204
+ // Assert - the single hunk is the whole diff, so nothing remains
205
+ expect(controller.getSnapshot().toast).toBeNull();
206
+ expect(sink.workingCopy).toBe("");
207
+ });
208
+
209
+ test("a rejected change becomes a removal card with a source, id, and preview", async () => {
210
+ // Arrange
211
+ const { controller } = await connected(diffSession(FILES));
212
+
213
+ // Act
214
+ controller.toggleRejectChange(4);
215
+ await tick();
216
+
217
+ // Assert - one item: diff source, revealing the change's row, both lines previewed
218
+ const items = controller.curationItems();
219
+
220
+ expect(items.length).toBe(1);
221
+ expect(items[0]!.source).toBe("diff");
222
+ expect(items[0]!.id).toBe("diff:src/store.ts#0#1");
223
+ expect(items[0]!.revealIndex).toBe(3);
224
+ expect(items[0]!.preview).toEqual([
225
+ "- private items = [];",
226
+ "+ private items = new Map();",
227
+ ]);
228
+ });
229
+
230
+ test("a rejected hunk becomes a removal card with the whole-hunk id", async () => {
231
+ // Arrange
232
+ const { controller } = await connected(diffSession(FILES));
233
+
234
+ // Act
235
+ controller.toggleRejectHunk(3);
236
+ await tick();
237
+
238
+ // Assert - the whole hunk, id diff:path#hunk#hunk
239
+ const items = controller.curationItems();
240
+
241
+ expect(items.length).toBe(1);
242
+ expect(items[0]!.source).toBe("diff");
243
+ expect(items[0]!.id).toBe("diff:src/store.ts#0#hunk");
244
+ });
245
+
246
+ test("restoreCuration drops the diff removal and reverts the working copy", async () => {
247
+ // Arrange - one change rejected
248
+ const { controller, sink } = await connected(diffSession(FILES));
249
+
250
+ controller.toggleRejectChange(4);
251
+ await tick();
252
+ const [item] = controller.curationItems();
253
+
254
+ // Act
255
+ controller.restoreCuration(item!.id);
256
+ await tick();
257
+
258
+ // Assert - the list empties and the working copy reverts to the full diff
259
+ expect(controller.getSnapshot().toast).toBeNull();
260
+ expect(controller.curationItems().length).toBe(0);
261
+ expect(sink.workingCopy).toBeUndefined();
262
+ expect(controller.rejectedRows().size).toBe(0);
263
+ });
264
+
265
+ test("restoreCuration ignores an unknown id", async () => {
266
+ // Arrange - one change rejected
267
+ const { controller } = await connected(diffSession(FILES));
268
+
269
+ controller.toggleRejectChange(4);
270
+ await tick();
271
+
272
+ // Act
273
+ controller.restoreCuration("diff:nope#0#0");
274
+ await tick();
275
+
276
+ // Assert - the item stays
277
+ expect(controller.curationItems().length).toBe(1);
278
+ });
279
+
280
+ test("a second decision before the daemon answers builds on the first", async () => {
281
+ // Arrange
282
+ const { controller, client } = await connected(diffSession(FILES));
283
+
284
+ // Act - reject, then restore, with no answer in between
285
+ controller.toggleRejectChange(4);
286
+ controller.toggleRejectChange(4);
287
+ await tick();
288
+
289
+ // Assert - the second request restores; a stale snapshot would reject again
290
+ expect(client.sessionCurate).toHaveBeenCalledTimes(2);
291
+ expect(client.sessionCurate).toHaveBeenNthCalledWith(1, "ses_diff", [
292
+ { path: "src/store.ts", hunkIndex: 0, changeIndex: 1 },
293
+ ]);
294
+ expect(client.sessionCurate).toHaveBeenNthCalledWith(2, "ses_diff", []);
295
+ });
296
+
297
+ test("curation is disabled without full file contents", async () => {
298
+ // Arrange - a legacy diff with no artifact.files
299
+ const { controller, sink } = await connected(diffSession(undefined));
300
+
301
+ // Act
302
+ controller.toggleRejectChange(4);
303
+ await tick();
304
+
305
+ // Assert
306
+ expect(controller.getSnapshot().status).toContain("hunk curation needs full file contents");
307
+ expect(sink.workingCopy).toBeUndefined();
308
+ });
309
+
310
+ test("curation is disabled when a revision cleared its file snapshots", async () => {
311
+ const { controller, client } = await connected(diffSession([]));
312
+
313
+ controller.toggleRejectChange(4);
314
+ await tick();
315
+
316
+ expect(controller.getSnapshot().status).toContain("hunk curation needs full file contents");
317
+ expect(client.sessionCurate).not.toHaveBeenCalled();
318
+ });
319
+ });
320
+
321
+ const PLAN_CONTENT = "# Title\n\nFirst paragraph.\n\nSecond paragraph.\n";
322
+ // the working copy with the "Second paragraph." block cut out
323
+ const PLAN_CUT = "# Title\n\nFirst paragraph.\n";
324
+
325
+ function planSession(workingCopy?: string): Thread {
326
+ return {
327
+ schemaVersion: SCHEMA_VERSION,
328
+ id: "ses_plan",
329
+ workspace: { repoRoot: "/repo", branch: "main" },
330
+ artifact: { type: "plan", content: PLAN_CONTENT, meta: {} },
331
+ revisions: [{ revision: 1, content: PLAN_CONTENT, submittedAt: "2026-01-01T00:00:00.000Z" }],
332
+ annotations: [],
333
+ message: null,
334
+ status: "pending",
335
+ createdAt: "2026-01-01T00:00:00.000Z",
336
+ workingCopy,
337
+ };
338
+ }
339
+
340
+ describe("plan cut removals", () => {
341
+ test("a cut block becomes a plan removal card previewing its content", async () => {
342
+ // Arrange - a plan whose working copy dropped the second paragraph
343
+ const { controller } = await connected(planSession(PLAN_CUT));
344
+
345
+ // Act
346
+ const items = controller.curationItems();
347
+
348
+ // Assert - one plan-source removal, keyed on the base line range, content previewed
349
+ expect(items.length).toBe(1);
350
+ expect(items[0]!.source).toBe("plan");
351
+ expect(items[0]!.id).toBe("plan:4-4");
352
+ expect(items[0]!.preview).toEqual(["Second paragraph."]);
353
+ });
354
+
355
+ test("restoreCuration re-inserts the cut block and returns to pristine", async () => {
356
+ // Arrange
357
+ const { controller, sink } = await connected(planSession(PLAN_CUT));
358
+ const [item] = controller.curationItems();
359
+
360
+ // Act
361
+ controller.restoreCuration(item!.id);
362
+ await tick();
363
+
364
+ // Assert - restoring the only cut round-trips to the submitted revision
365
+ expect(controller.getSnapshot().toast).toBeNull();
366
+ expect(sink.workingCopy).toBeUndefined();
367
+ });
368
+
369
+ test("a second cut before the daemon answers indexes the already-cut copy", async () => {
370
+ // Arrange - pristine plan: title(0), first(1), second(2)
371
+ const { controller, client, sink } = await connected(planSession());
372
+
373
+ // Act - cut the first paragraph, then the second, with no answer in between
374
+ controller.cut(1);
375
+ controller.cut(2);
376
+ await tick();
377
+
378
+ // Assert - the second paragraph is block 1 once the first is gone
379
+ expect(client.sessionCutBlock).toHaveBeenNthCalledWith(1, "ses_plan", 1);
380
+ expect(client.sessionCutBlock).toHaveBeenNthCalledWith(2, "ses_plan", 1);
381
+ expect(sink.workingCopy).toBe("# Title\n");
382
+ });
383
+
384
+ test("a whole-block Cut composes with an earlier exact Cut", async () => {
385
+ const start = PLAN_CONTENT.indexOf("paragraph.");
386
+ const end = PLAN_CONTENT.indexOf("Second") + "Sec".length;
387
+ const textCuts = [{ start, end, quote: PLAN_CONTENT.slice(start, end) }];
388
+ const session = {
389
+ ...planSession(applyTextCuts(PLAN_CONTENT, textCuts)),
390
+ textCuts,
391
+ };
392
+ const { controller, client, sink } = await connected(session);
393
+
394
+ controller.cut(1);
395
+ await tick();
396
+
397
+ expect(client.sessionSetWorkingCopy).toHaveBeenCalledTimes(1);
398
+ expect(client.sessionCutBlock).not.toHaveBeenCalled();
399
+ expect(sink.textCuts).toHaveLength(1);
400
+ expect(sink.workingCopy).toBe("# Title\nond paragraph.\n");
401
+ });
402
+ });
403
+
404
+ // a file whose hunk touches line 2 only, so lines 1/3/4 are unchanged tail/context
405
+ const TAIL_PATCH = `diff --git a/src/store.ts b/src/store.ts
406
+ --- a/src/store.ts
407
+ +++ b/src/store.ts
408
+ @@ -2,1 +2,1 @@
409
+ - private items = [];
410
+ + private items = new Map();
411
+ `;
412
+
413
+ function tailSession(): Thread {
414
+ return {
415
+ ...diffSession(),
416
+ artifact: {
417
+ type: "diff",
418
+ content: TAIL_PATCH,
419
+ meta: {},
420
+ files: [
421
+ {
422
+ path: "src/store.ts",
423
+ oldContents: "export class Store {\n private items = [];\n}\nexport const x = 1;\n",
424
+ newContents:
425
+ "export class Store {\n private items = new Map();\n}\nexport const x = 1;\n",
426
+ status: "modified",
427
+ },
428
+ ],
429
+ },
430
+ };
431
+ }
432
+
433
+ describe("diff file fold", () => {
434
+ test("collapse leaves only the file band; expand restores the body", async () => {
435
+ // Arrange
436
+ const { controller } = await connected(tailSession());
437
+ const bodyRows = () =>
438
+ controller.rows().filter((row) => row.file === "src/store.ts" && row.kind !== "file").length;
439
+
440
+ // Assert - starts unfolded with a body
441
+ expect(bodyRows()).toBeGreaterThan(0);
442
+
443
+ // Act / Assert - collapse hides the body
444
+ controller.setFileCollapsed("src/store.ts", true);
445
+ expect(bodyRows()).toBe(0);
446
+ expect(controller.isFileCollapsed("src/store.ts")).toBe(true);
447
+
448
+ // Act / Assert - unfold brings it back
449
+ controller.setFileCollapsed("src/store.ts", false);
450
+ expect(bodyRows()).toBeGreaterThan(0);
451
+ });
452
+
453
+ test("expand weaves the file's unchanged tail line the hunk never showed", async () => {
454
+ // Arrange
455
+ const { controller } = await connected(tailSession());
456
+ const hasTail = () => controller.rows().some((row) => row.text.includes("export const x = 1"));
457
+
458
+ // Assert - the hunk view does not show the tail line
459
+ expect(controller.canExpandFile("src/store.ts")).toBe(true);
460
+ expect(hasTail()).toBe(false);
461
+
462
+ // Act - weave the full file
463
+ controller.setFileExpanded("src/store.ts", true);
464
+
465
+ // Assert
466
+ expect(hasTail()).toBe(true);
467
+ });
468
+
469
+ test("a diff without full contents cannot be expanded", async () => {
470
+ // Arrange - no artifact.files
471
+ const { controller } = await connected(diffSession(undefined));
472
+
473
+ // Assert
474
+ expect(controller.canExpandFile("src/store.ts")).toBe(false);
475
+ });
476
+ });
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SCHEMA_VERSION, type Annotation, type Thread } from "@cueloop/schema";
3
+ import { sameDerivationInputs } from "./thread-controller";
4
+
5
+ function threadFixture(overrides: Partial<Thread> = {}): Thread {
6
+ return {
7
+ schemaVersion: SCHEMA_VERSION,
8
+ id: "ses_1",
9
+ workspace: { repoRoot: "/repo", branch: "main" },
10
+ artifact: { type: "plan", content: "# Plan\n\nShip it.\n", meta: {} },
11
+ revisions: [
12
+ { revision: 1, content: "# Plan\n\nShip it.\n", submittedAt: "2026-01-01T00:00:00.000Z" },
13
+ ],
14
+ annotations: [],
15
+ message: null,
16
+ status: "pending",
17
+ createdAt: "2026-01-01T00:00:00.000Z",
18
+ ...overrides,
19
+ };
20
+ }
21
+
22
+ const annotation: Annotation = {
23
+ id: "ann_1",
24
+ kind: "comment",
25
+ anchor: { quote: "Ship it.", prefix: "", suffix: "" },
26
+ body: "nit",
27
+ createdAt: "2026-01-01T00:00:00.000Z",
28
+ };
29
+
30
+ describe("sameDerivationInputs", () => {
31
+ test("an annotation-only update reuses the projection (a fresh record, identical inputs)", () => {
32
+ // Arrange - the daemon re-serializes the whole thread on any change, so the record is all-new
33
+ const before = threadFixture();
34
+ const after = threadFixture({ annotations: [annotation] });
35
+
36
+ // Assert - the artifact and working copy are unchanged, so the projection can be reused
37
+ expect(sameDerivationInputs(before, after)).toBe(true);
38
+ });
39
+
40
+ test("a content edit forces a re-derivation", () => {
41
+ const before = threadFixture();
42
+ const after = threadFixture({
43
+ artifact: { type: "plan", content: "# Plan\n\nShip it soon.\n", meta: {} },
44
+ });
45
+
46
+ expect(sameDerivationInputs(before, after)).toBe(false);
47
+ });
48
+
49
+ test("a working-copy change forces a re-derivation", () => {
50
+ const before = threadFixture();
51
+ const after = threadFixture({ workingCopy: "# Plan\n\nShip it.\n\nedited\n" });
52
+
53
+ expect(sameDerivationInputs(before, after)).toBe(false);
54
+ });
55
+
56
+ test("a different thread is never a reuse", () => {
57
+ const before = threadFixture();
58
+ const after = threadFixture({ id: "ses_2" });
59
+
60
+ expect(sameDerivationInputs(before, after)).toBe(false);
61
+ });
62
+ });