@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
@@ -0,0 +1,363 @@
1
+ /** The plan review surface v2: native selection feeds the annotation quote, the inline compose box keeps its anchor painted, the rail edits what the document selects, and edit-exit reconciliation orphans annotations whose passage was removed. Char-frame + styled-span assertions over the real App and a real in-process daemon. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import React from "react";
8
+ import { DaemonServer } from "@cueloop/daemon";
9
+ import type { Thread } from "@cueloop/schema";
10
+ import { App } from "./App";
11
+ import { DEFAULT_QUICK_ACTIONS } from "./config";
12
+ import { slashItemsFrom } from "./slash-palette";
13
+ import {
14
+ clickText,
15
+ dragText,
16
+ isolateUserConfig,
17
+ press,
18
+ pressKey,
19
+ renderReadyApp,
20
+ typeText as type,
21
+ waitForState,
22
+ waitForText,
23
+ waitForTextGone,
24
+ } from "./test-support";
25
+
26
+ const PLAN = `# Migration Plan
27
+
28
+ ## Context
29
+
30
+ The daemon persists sessions to disk atomically.
31
+
32
+ ## Steps
33
+
34
+ - move the store
35
+ - add recovery
36
+ `;
37
+
38
+ let home: string;
39
+ let server: DaemonServer;
40
+ let session: Thread;
41
+ let restoreUserConfig: () => void;
42
+
43
+ beforeEach(() => {
44
+ home = mkdtempSync(join(tmpdir(), "cueloop-plan-review-"));
45
+ restoreUserConfig = isolateUserConfig(home);
46
+ server = new DaemonServer({ home, idleExitMs: 0 });
47
+ server.start();
48
+ session = server.core.sessionCreate({
49
+ workspace: { repoRoot: "/repo", branch: "main" },
50
+ artifact: {
51
+ type: "plan",
52
+ content: PLAN,
53
+ meta: { title: "Migration Plan", planPath: "plan.md", agent: "agent/worker-3" },
54
+ },
55
+ });
56
+ });
57
+ afterEach(() => {
58
+ restoreUserConfig();
59
+ server.stop();
60
+ rmSync(home, { recursive: true, force: true });
61
+ });
62
+
63
+ async function renderApp() {
64
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
65
+ width: 120,
66
+ height: 32,
67
+ });
68
+
69
+ await waitForText(setup, "cueloop");
70
+
71
+ return setup;
72
+ }
73
+
74
+ type Setup = Awaited<ReturnType<typeof renderApp>>;
75
+
76
+ /**
77
+ * Background colors (hex) of every styled span containing the needle - the
78
+ * document highlight and the rail's quote excerpt can both match.
79
+ */
80
+ function backgroundsOf(setup: Setup, needle: string): string[] {
81
+ const backgrounds: string[] = [];
82
+
83
+ for (const line of setup.captureSpans().lines) {
84
+ for (const span of line.spans) {
85
+ if (!span.text.includes(needle)) continue;
86
+ const [red, green, blue] = span.bg.toInts();
87
+
88
+ backgrounds.push(
89
+ "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""),
90
+ );
91
+ }
92
+ }
93
+
94
+ return backgrounds;
95
+ }
96
+
97
+ describe("share button", () => {
98
+ test("the owner's plan header shows the Share button next to Edit", async () => {
99
+ // Arrange / Act
100
+ const setup = await renderApp();
101
+
102
+ // Assert
103
+ // both word-buttons ride the header's thread segment, so share sits next to edit
104
+ const headerLine = setup
105
+ .captureCharFrame()
106
+ .split("\n")
107
+ .find((line) => line.includes("edit"));
108
+
109
+ expect(headerLine).toContain("share");
110
+ });
111
+
112
+ test("a read-only viewer (a plan shared over ssh) never sees the Share button", async () => {
113
+ // Arrange / Act
114
+ const viewer = await renderReadyApp(<App home={home} sessionId={session.id} readOnly />, {
115
+ width: 120,
116
+ height: 32,
117
+ });
118
+
119
+ await waitForText(viewer, "cueloop");
120
+
121
+ // Assert
122
+ expect(viewer.captureCharFrame()).not.toContain(" share ");
123
+ });
124
+
125
+ test("a resolved plan hides Edit and Share (no re-sharing a finished review)", async () => {
126
+ // Arrange - resolve the session before opening it
127
+ server.core.sessionResolve(session.id, "approve", "");
128
+
129
+ // Act
130
+ const setup = await renderApp();
131
+
132
+ await waitForText(setup, "Migration Plan");
133
+
134
+ // Assert - the owner toolbar is gone once the review is resolved
135
+ const frame = setup.captureCharFrame();
136
+
137
+ expect(frame).not.toContain(" edit ");
138
+ expect(frame).not.toContain(" share ");
139
+ });
140
+ });
141
+
142
+ describe("edit affordance", () => {
143
+ test("the owner sees the Edit button", async () => {
144
+ // Arrange / Act
145
+ const owner = await renderApp();
146
+
147
+ // Assert
148
+ expect(owner.captureCharFrame()).toContain(" edit ");
149
+ });
150
+
151
+ test("a read-only viewer (a plan shared over ssh) never sees the Edit button", async () => {
152
+ // Arrange / Act
153
+ const viewer = await renderReadyApp(<App home={home} sessionId={session.id} readOnly />, {
154
+ width: 120,
155
+ height: 32,
156
+ });
157
+
158
+ await waitForText(viewer, "cueloop");
159
+
160
+ // Assert
161
+ expect(viewer.captureCharFrame()).not.toContain(" edit ");
162
+ });
163
+ });
164
+
165
+ describe("marks feed the comment anchor", () => {
166
+ test("a drag marks a character-precise span and typing anchors the comment to it", async () => {
167
+ // Arrange
168
+ const setup = await renderApp();
169
+
170
+ // Act
171
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
172
+ await type(setup, "Which daemon?");
173
+ await pressKey(setup, "RETURN", { meta: true });
174
+
175
+ // Assert
176
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
177
+ expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe("The daemon");
178
+ });
179
+
180
+ test("a drag that ends mid-sentence keeps exactly the dragged words", async () => {
181
+ // Arrange
182
+ const setup = await renderApp();
183
+
184
+ // Act: release one past the last character of "sessions"
185
+ await dragText(setup, "persists", "sessions", "sessions".length);
186
+ await type(setup, "sessions plural?");
187
+ await pressKey(setup, "RETURN", { meta: true });
188
+
189
+ // Assert
190
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
191
+ expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe(
192
+ "persists sessions",
193
+ );
194
+ });
195
+ });
196
+
197
+ /** The thread view's mark backdrop in the dark palette. */
198
+ const THREAD_MARK = "#463852";
199
+
200
+ describe("the mark stays painted while composing", () => {
201
+ test("the mark paints while composing, escape un-paints, save keeps it as the comment's mark", async () => {
202
+ // Arrange
203
+ const setup = await renderApp();
204
+
205
+ // Act
206
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
207
+ await type(setup, "x");
208
+
209
+ // Assert: composing, the marked words carry the mark backdrop
210
+ await waitForText(setup, "● x");
211
+ expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
212
+
213
+ // Act: escape discards the draft; the mark stays for re-typing, a second escape drops it
214
+ await press(setup, "escape");
215
+ await waitForTextGone(setup, "● x");
216
+ expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
217
+ await press(setup, "escape");
218
+
219
+ // Assert
220
+ await waitForState(setup, () => !backgroundsOf(setup, "The daemon").includes(THREAD_MARK));
221
+
222
+ // Act: mark again, save
223
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
224
+ await type(setup, "Which daemon?");
225
+ await pressKey(setup, "RETURN", { meta: true });
226
+
227
+ // Assert: the saved comment keeps the passage marked
228
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
229
+ await waitForState(setup, () => backgroundsOf(setup, "The daemon").includes(THREAD_MARK));
230
+ }, 60_000);
231
+ });
232
+
233
+ describe("compose newline convention", () => {
234
+ test("enter breaks the line; cmd+enter saves the multiline body verbatim", async () => {
235
+ // Arrange
236
+ const setup = await renderApp();
237
+
238
+ await clickText(setup, "daemon");
239
+
240
+ // Act
241
+ await type(setup, "first line");
242
+ await press(setup, "enter");
243
+ await type(setup, "second line");
244
+
245
+ // Assert: still composing, the newline did not save
246
+ expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
247
+
248
+ // Act
249
+ await pressKey(setup, "RETURN", { meta: true });
250
+
251
+ // Assert
252
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
253
+ expect(server.core.sessionGet(session.id).annotations[0]!.body).toBe("first line\nsecond line");
254
+ });
255
+
256
+ test("escape cancels the composer without saving", async () => {
257
+ // Arrange
258
+ const setup = await renderApp();
259
+
260
+ await clickText(setup, "daemon");
261
+ await type(setup, "never saved");
262
+ await waitForText(setup, "● never saved");
263
+
264
+ // Act
265
+ await press(setup, "escape");
266
+
267
+ // Assert
268
+ await waitForTextGone(setup, "● never saved");
269
+ expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
270
+ });
271
+ });
272
+
273
+ describe("sheet header", () => {
274
+ test("mirrors the thread name and carries the Edit and Share word-buttons", async () => {
275
+ // Arrange
276
+ const setup = await renderApp();
277
+
278
+ // Assert - the minimal header shows the thread title (mirror) plus the owner
279
+ // actions; the revision and submitter metadata are no longer in the header
280
+ const frame = setup.captureCharFrame();
281
+
282
+ expect(frame).toContain("Migration Plan");
283
+ expect(frame).not.toContain("submitted by");
284
+ expect(frame).not.toContain("rev 1");
285
+ expect(frame).toContain(" edit ");
286
+ expect(frame).toContain(" share ");
287
+ });
288
+ });
289
+
290
+ describe("quick-actions settings editor", () => {
291
+ test("expanding an action edits its system prompt inline and persists it", async () => {
292
+ // Arrange
293
+ const setup = await renderApp();
294
+
295
+ // Act - open Settings from the top-left gear, enter Actions, expand the first action, type
296
+ await setup.mockMouse.click(1, 0);
297
+ await waitForText(setup, "Keybinds");
298
+ await clickText(setup, "Settings");
299
+ await clickText(setup, "Actions");
300
+ await clickText(setup, "Zoom out, research in depth");
301
+ await type(setup, "CUSTOM");
302
+ await setup.renderOnce();
303
+
304
+ // Assert - the keystrokes reached the focused input (not swallowed by nav) and persisted
305
+ expect(setup.captureCharFrame()).toContain("CUSTOM");
306
+ await waitForState(setup, () =>
307
+ readFileSync(join(home, "no-config.toml"), "utf8").includes("CUSTOM"),
308
+ );
309
+ });
310
+ });
311
+
312
+ describe("edit-exit reconciliation", () => {
313
+ test("an edit that removes an anchored passage orphans the annotation and shows the banner", async () => {
314
+ const script = join(home, "fake-editor.sh");
315
+
316
+ await Bun.write(script, `#!/bin/sh\nsed -i '' '/^The daemon persists/d' "$1"\n`);
317
+ Bun.spawnSync(["chmod", "+x", script]);
318
+ process.env.CUELOOP_EDITOR = script;
319
+ try {
320
+ // Arrange
321
+ const setup = await renderApp();
322
+
323
+ await clickText(setup, "daemon");
324
+ await type(setup, "Anchor me to the doomed passage.");
325
+ await pressKey(setup, "RETURN", { meta: true });
326
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
327
+
328
+ // Act
329
+ await pressKey(setup, "e", { ctrl: true });
330
+
331
+ // Assert - the thread view banner reports the orphaned annotation
332
+ await waitForText(setup, "1 annotation no longer match - the passage was removed.");
333
+ // the annotation is NOT deleted: the feedback serializer handles orphans
334
+ expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
335
+ } finally {
336
+ delete process.env.CUELOOP_EDITOR;
337
+ }
338
+ });
339
+ });
340
+
341
+ describe("the quick-action palette", () => {
342
+ test("/ lists the quick actions and a pick inserts the reference, not the body", async () => {
343
+ // Arrange
344
+ const setup = await renderApp();
345
+
346
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
347
+
348
+ // Act - a leading slash opens the palette; step to the second default and pick it
349
+ await type(setup, "/");
350
+ await waitForText(setup, "zoom-out-research-in-depth");
351
+ await pressKey(setup, "ARROW_DOWN");
352
+ await press(setup, "enter");
353
+ await pressKey(setup, "RETURN", { meta: true });
354
+
355
+ // Assert - the comment holds the /name reference, not the expanded body
356
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
357
+ const stored = server.core.sessionGet(session.id);
358
+ const secondName = slashItemsFrom(DEFAULT_QUICK_ACTIONS)[1]!.name;
359
+
360
+ expect(stored.annotations[0]!.kind).toBe("comment");
361
+ expect(stored.annotations[0]!.body.trim()).toBe(`/${secondName}`);
362
+ });
363
+ });
@@ -0,0 +1,92 @@
1
+ /** The default prototype is a Markdown design doc: with no experimental flag it renders through the thread/markdown path (not the kitty pixel view), its headings and fenced sections show as text, and a quote-anchored comment lands on a line - the same as a plan. A real in-process daemon; char-frame assertions over the real App. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import React from "react";
8
+ import { DaemonServer } from "@cueloop/daemon";
9
+ import type { Thread } from "@cueloop/schema";
10
+ import { App } from "./App";
11
+ import { isolateUserConfig, renderReadyApp, settle, waitForText } from "./test-support";
12
+
13
+ const DOC = `# PromoCard
14
+
15
+ A promotional card for a growth surface.
16
+
17
+ ## API
18
+
19
+ \`\`\`ts
20
+ interface PromoCardProps {
21
+ title: string
22
+ onDismiss?: () => void
23
+ }
24
+ \`\`\`
25
+
26
+ ## Composition
27
+
28
+ \`\`\`
29
+ PromoCard
30
+ └─ TextGroup reuse: Text
31
+ \`\`\`
32
+ `;
33
+
34
+ let home: string;
35
+ let server: DaemonServer;
36
+ let session: Thread;
37
+ let restoreUserConfig: () => void;
38
+
39
+ beforeEach(() => {
40
+ home = mkdtempSync(join(tmpdir(), "cueloop-prototype-md-"));
41
+ restoreUserConfig = isolateUserConfig(home);
42
+ server = new DaemonServer({ home, idleExitMs: 0 });
43
+ server.start();
44
+ session = server.core.sessionCreate({
45
+ workspace: { repoRoot: "/repo", branch: "promo-card" },
46
+ artifact: { type: "prototype", content: DOC, meta: { title: "PromoCard", agent: "pi" } },
47
+ });
48
+ });
49
+ afterEach(() => {
50
+ restoreUserConfig();
51
+ server.stop();
52
+ rmSync(home, { recursive: true, force: true });
53
+ });
54
+
55
+ describe("markdown prototype review", () => {
56
+ test("renders as a markdown design doc, not the pixel view", async () => {
57
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
58
+ width: 120,
59
+ height: 40,
60
+ });
61
+
62
+ await waitForText(setup, "PromoCard");
63
+ await settle(setup);
64
+ const frame = setup.captureCharFrame();
65
+
66
+ // the markdown path renders the doc's headings and interface as text
67
+ expect(frame).toContain("API");
68
+ expect(frame).toContain("PromoCardProps");
69
+ expect(frame).toContain("Composition");
70
+ // and never falls into the pixel view's status line
71
+ expect(frame).not.toContain("rendering prototype");
72
+ expect(frame).not.toContain("graphics terminal");
73
+ });
74
+
75
+ test("a quote-anchored comment stores against the doc, like a plan", async () => {
76
+ server.core.sessionAnnotate(session.id, {
77
+ id: "own_1",
78
+ kind: "comment",
79
+ anchor: { quote: "onDismiss?: () => void", prefix: "", suffix: "" },
80
+ body: "make dismiss required for a modal surface?",
81
+ });
82
+
83
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
84
+ width: 120,
85
+ height: 40,
86
+ });
87
+
88
+ await waitForText(setup, "make dismiss required");
89
+ expect(server.core.sessionGet(session.id).annotations).toHaveLength(1);
90
+ expect(server.core.sessionGet(session.id).annotations[0]!.anchor.selector).toBeUndefined();
91
+ });
92
+ });
@@ -0,0 +1,176 @@
1
+ /** Prototype review end to end, in the opt-in EXPERIMENTAL pixel mode ([experimental] prototype_pixels): a click on the rendered page selects a DOM element and opens the compose card directly - the same inline composer plan and diff use - a typed comment saves through the controller on cmd+enter, and the annotation anchors to the element's selector. The headless browser is replaced with a fake so no Chrome or kitty output is needed. Char-frame assertions over the real App and a real in-process daemon. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import React from "react";
8
+ import { DaemonServer } from "@cueloop/daemon";
9
+ import type { Thread } from "@cueloop/schema";
10
+ import { App } from "./App";
11
+ import {
12
+ setPrototypeRendererFactory,
13
+ type PrototypeElement,
14
+ type PrototypeRenderer,
15
+ } from "./prototype-browser";
16
+ import {
17
+ isolateUserConfig,
18
+ press,
19
+ renderReadyApp,
20
+ settle,
21
+ typeText,
22
+ waitForState,
23
+ waitForText,
24
+ waitForTextGone,
25
+ } from "./test-support";
26
+
27
+ const HTML = "<main><div class='card'><h2>Pro</h2><p>$24/mo</p></div></main>";
28
+
29
+ const FAKE_ELEMENT: PrototypeElement = {
30
+ selector: "main > div.card",
31
+ quote: "Pro $24/mo",
32
+ // mid-page, so the popover anchors well away from the click point below
33
+ box: { x: 440, y: 320, width: 320, height: 220 },
34
+ };
35
+
36
+ let rendered = false;
37
+ let screenshotCount = 0;
38
+ let scrollDeltas: number[] = [];
39
+ const fakeRenderer: PrototypeRenderer = {
40
+ viewport: { width: 1280, height: 800 },
41
+ screenshot: async () => {
42
+ rendered = true;
43
+ screenshotCount += 1;
44
+
45
+ return new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
46
+ },
47
+ elementAt: async () => FAKE_ELEMENT,
48
+ scrollBy: async (deltaY) => {
49
+ scrollDeltas.push(deltaY);
50
+
51
+ return true;
52
+ },
53
+ close: async () => undefined,
54
+ };
55
+
56
+ let home: string;
57
+ let server: DaemonServer;
58
+ let session: Thread;
59
+ let restoreUserConfig: () => void;
60
+
61
+ beforeEach(() => {
62
+ home = mkdtempSync(join(tmpdir(), "cueloop-prototype-"));
63
+ // the pixel mockup is opt-in; these tests exercise that experimental path
64
+ writeFileSync(join(home, "config.toml"), "[experimental]\nprototype_pixels = true\n");
65
+ restoreUserConfig = isolateUserConfig(home, "config.toml");
66
+ server = new DaemonServer({ home, idleExitMs: 0 });
67
+ server.start();
68
+ session = server.core.sessionCreate({
69
+ workspace: { repoRoot: "/repo", branch: "main" },
70
+ artifact: {
71
+ type: "prototype",
72
+ content: HTML,
73
+ meta: { title: "Pricing cards", prototypePath: "/repo/cards.html" },
74
+ },
75
+ });
76
+ rendered = false;
77
+ screenshotCount = 0;
78
+ scrollDeltas = [];
79
+ setPrototypeRendererFactory(async () => fakeRenderer);
80
+ });
81
+ afterEach(() => {
82
+ setPrototypeRendererFactory(null);
83
+ restoreUserConfig();
84
+ server.stop();
85
+ rmSync(home, { recursive: true, force: true });
86
+ });
87
+
88
+ async function renderApp() {
89
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
90
+ width: 120,
91
+ height: 32,
92
+ });
93
+
94
+ await waitForText(setup, "cueloop");
95
+
96
+ return setup;
97
+ }
98
+
99
+ describe("prototype review", () => {
100
+ test("click an element, comment on it, and it saves to the daemon", async () => {
101
+ // Arrange - wait until the fake browser produced its first screenshot, so
102
+ // the sheet is interactive before the click
103
+ const setup = await renderApp();
104
+
105
+ await waitForState(setup, () => rendered);
106
+ await settle(setup);
107
+ // selecting an element must not re-screenshot the page (the compose-open
108
+ // lag regression): the click opens the composer, not a fresh Chromium capture
109
+ const screenshotsAfterLoad = screenshotCount;
110
+
111
+ // Act - a click opens the compose card directly on the element
112
+ await setup.mockMouse.click(6, 6);
113
+ await waitForText(setup, "●");
114
+ expect(screenshotCount).toBe(screenshotsAfterLoad);
115
+ // the keymap-suppression flag must propagate before typing so keys reach the
116
+ // textarea, not the global keymap
117
+ await settle(setup);
118
+ await typeText(setup, "tighten the padding");
119
+ // cmd+enter sends, matching the plan and diff composer; plain enter is a newline
120
+ setup.mockInput.pressKey("RETURN", { meta: true });
121
+
122
+ // Assert - the annotation is stored against the element's selector
123
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
124
+ const stored = server.core.sessionGet(session.id).annotations[0]!;
125
+
126
+ expect(stored.kind).toBe("comment");
127
+ expect(stored.body).toBe("tighten the padding");
128
+ expect(stored.anchor.selector).toBe("main > div.card");
129
+ });
130
+
131
+ test("escape cancels the compose, matching the plan composer", async () => {
132
+ // Arrange
133
+ const setup = await renderApp();
134
+
135
+ await waitForState(setup, () => rendered);
136
+ await settle(setup);
137
+ await setup.mockMouse.click(6, 6);
138
+ await waitForText(setup, "●");
139
+ await settle(setup);
140
+
141
+ // Act
142
+ await press(setup, "escape");
143
+
144
+ // Assert - the composer closes and nothing was saved
145
+ await waitForTextGone(setup, "●");
146
+ expect(server.core.sessionGet(session.id).annotations).toHaveLength(0);
147
+ });
148
+
149
+ test("wheel over the preview scrolls the page down, then up, and clears a selection", async () => {
150
+ // Arrange
151
+ const setup = await renderApp();
152
+
153
+ await waitForState(setup, () => rendered);
154
+ await settle(setup);
155
+
156
+ // open a selection, then scroll: the scroll drops it (it would drift once
157
+ // the page moves under it)
158
+ await setup.mockMouse.click(6, 6);
159
+ await waitForText(setup, "●");
160
+ // scrolling moves the page, so it DOES re-screenshot (unlike a click)
161
+ const screenshotsBeforeScroll = screenshotCount;
162
+
163
+ await setup.mockMouse.scroll(6, 6, "down");
164
+ await waitForState(setup, () => scrollDeltas.length === 1);
165
+ await waitForTextGone(setup, "●");
166
+ await waitForState(setup, () => screenshotCount > screenshotsBeforeScroll);
167
+
168
+ // Act - scroll back up
169
+ await setup.mockMouse.scroll(6, 6, "up");
170
+ await waitForState(setup, () => scrollDeltas.length === 2);
171
+
172
+ // Assert - down was a positive delta, up was negative
173
+ expect(scrollDeltas[0]!).toBeGreaterThan(0);
174
+ expect(scrollDeltas[1]!).toBeLessThan(0);
175
+ });
176
+ });