@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,128 @@
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 { DARK } from "./theme";
12
+ import { isolateUserConfig, renderReadyApp, settle, waitForText } from "./test-support";
13
+
14
+ const DOC = `# PromoCard
15
+
16
+ A promotional card for a growth surface.
17
+
18
+ ## API
19
+
20
+ \`\`\`ts
21
+ interface PromoCardProps {
22
+ title: string
23
+ onDismiss?: () => void
24
+ }
25
+ \`\`\`
26
+
27
+ ## Composition
28
+
29
+ \`\`\`
30
+ PromoCard
31
+ └─ TextGroup reuse: Text
32
+ \`\`\`
33
+ `;
34
+
35
+ let home: string;
36
+ let server: DaemonServer;
37
+ let session: Thread;
38
+ let restoreUserConfig: () => void;
39
+
40
+ beforeEach(() => {
41
+ home = mkdtempSync(join(tmpdir(), "cueloop-prototype-md-"));
42
+ restoreUserConfig = isolateUserConfig(home);
43
+ server = new DaemonServer({ home, idleExitMs: 0 });
44
+ server.start();
45
+ session = server.core.sessionCreate({
46
+ workspace: { repoRoot: "/repo", branch: "promo-card" },
47
+ artifact: { type: "prototype", content: DOC, meta: { title: "PromoCard", agent: "pi" } },
48
+ });
49
+ });
50
+ afterEach(() => {
51
+ restoreUserConfig();
52
+ server.stop();
53
+ rmSync(home, { recursive: true, force: true });
54
+ });
55
+
56
+ describe("markdown prototype review", () => {
57
+ test("syntax-highlights TypeScript fences without changing their text", async () => {
58
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
59
+ width: 120,
60
+ height: 40,
61
+ });
62
+
63
+ await waitForText(setup, "PromoCardProps");
64
+ let keywordHighlighted = false;
65
+
66
+ for (let attempt = 0; attempt < 60 && !keywordHighlighted; attempt++) {
67
+ await setup.renderOnce();
68
+ await new Promise((resolve) => setTimeout(resolve, 20));
69
+ const accentText = setup
70
+ .captureSpans()
71
+ .lines.flatMap((line) =>
72
+ line.spans
73
+ .filter((span) => {
74
+ const [red, green, blue] = span.fg.toInts();
75
+
76
+ return (
77
+ `#${[red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("")}` ===
78
+ DARK.accent
79
+ );
80
+ })
81
+ .map((span) => span.text),
82
+ )
83
+ .join("");
84
+
85
+ keywordHighlighted = accentText.includes("interface");
86
+ }
87
+
88
+ expect(keywordHighlighted).toBe(true);
89
+ expect(setup.captureCharFrame()).toContain("interface PromoCardProps {");
90
+ }, 25000);
91
+
92
+ test("renders as a markdown design doc, not the pixel view", async () => {
93
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
94
+ width: 120,
95
+ height: 40,
96
+ });
97
+
98
+ await waitForText(setup, "PromoCard");
99
+ await settle(setup);
100
+ const frame = setup.captureCharFrame();
101
+
102
+ // the markdown path renders the doc's headings and interface as text
103
+ expect(frame).toContain("API");
104
+ expect(frame).toContain("PromoCardProps");
105
+ expect(frame).toContain("Composition");
106
+ // and never falls into the pixel view's status line
107
+ expect(frame).not.toContain("rendering prototype");
108
+ expect(frame).not.toContain("graphics terminal");
109
+ });
110
+
111
+ test("a quote-anchored comment stores against the doc, like a plan", async () => {
112
+ server.core.sessionAnnotate(session.id, {
113
+ id: "own_1",
114
+ kind: "comment",
115
+ anchor: { quote: "onDismiss?: () => void", prefix: "", suffix: "" },
116
+ body: "make dismiss required for a modal surface?",
117
+ });
118
+
119
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
120
+ width: 120,
121
+ height: 40,
122
+ });
123
+
124
+ await waitForText(setup, "make dismiss required");
125
+ expect(server.core.sessionGet(session.id).annotations).toHaveLength(1);
126
+ expect(server.core.sessionGet(session.id).annotations[0]!.anchor.selector).toBeUndefined();
127
+ });
128
+ });
@@ -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
+ });
@@ -1,18 +1,24 @@
1
- /**
2
- * Observer mode (tier 2): the App with readOnly renders and navigates like the
3
- * controller's, but every mutating verb answers "observer - read-only" and
4
- * leaves daemon state untouched.
5
- */
1
+ /** Observer mode (tier 2): the App with readOnly renders and navigates like the controller's, but every mutating primitive answers "observer - read-only" and leaves daemon state untouched. */
6
2
 
7
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
4
  import { mkdtempSync, rmSync } from "node:fs";
9
5
  import { tmpdir } from "node:os";
10
6
  import { join } from "node:path";
11
7
  import React from "react";
12
- import { testRender } from "@opentui/react/test-utils";
13
8
  import { DaemonServer } from "@cueloop/daemon";
14
- import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
9
+ import { makeAnchor, parseBlocks, type Thread } from "@cueloop/schema";
15
10
  import { App } from "./App";
11
+ import {
12
+ clickText,
13
+ dragText,
14
+ frameRow,
15
+ isolateUserConfig,
16
+ navCommand,
17
+ pressKey,
18
+ renderReadyApp,
19
+ typeText,
20
+ waitForText,
21
+ } from "./test-support";
16
22
 
17
23
  const PLAN = `# Migration Plan
18
24
 
@@ -27,113 +33,164 @@ The daemon persists sessions to disk atomically.
27
33
  `;
28
34
 
29
35
  let home: string;
36
+ let restoreUserConfig: () => void;
30
37
  let server: DaemonServer;
31
- let session: ReviewSession;
38
+ let session: Thread;
32
39
 
33
40
  beforeEach(() => {
34
41
  home = mkdtempSync(join(tmpdir(), "cueloop-observer-"));
42
+ restoreUserConfig = isolateUserConfig(home);
35
43
  server = new DaemonServer({ home, idleExitMs: 0 });
36
44
  server.start();
37
45
  session = server.core.sessionCreate({
38
46
  workspace: { repoRoot: "/repo", branch: "main" },
39
- artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
47
+ artifact: {
48
+ type: "plan",
49
+ content: PLAN,
50
+ meta: { title: "Migration Plan", planPath: "plan.md" },
51
+ },
40
52
  });
41
53
  });
42
54
  afterEach(() => {
55
+ restoreUserConfig();
43
56
  server.stop();
44
57
  rmSync(home, { recursive: true, force: true });
45
58
  });
46
59
 
47
60
  async function renderObserver() {
48
- const setup = await testRender(<App home={home} sessionId={session.id} readOnly />, {
61
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} readOnly />, {
49
62
  width: 120,
50
63
  height: 32,
51
64
  });
52
- for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
53
- await Bun.sleep(25);
54
- await setup.renderOnce();
55
- }
56
- await setup.renderOnce();
65
+
66
+ await waitForText(setup, "cueloop");
67
+
57
68
  return setup;
58
69
  }
59
70
 
60
71
  type Setup = Awaited<ReturnType<typeof renderObserver>>;
61
72
 
62
- async function press(setup: Setup, k: string): Promise<void> {
63
- if (k === "enter") setup.mockInput.pressKey("RETURN");
64
- else await setup.mockInput.typeText(k);
65
- await Bun.sleep(15);
66
- await setup.renderOnce();
67
- }
68
-
69
- /** Session state that any mutating verb would change. */
73
+ /** Session state that any mutating primitive would change. */
70
74
  function snapshot() {
71
- const s = server.core.sessionGet(session.id);
72
- return { annotations: s.annotations.length, workingCopy: s.workingCopy, status: s.status };
75
+ const stored = server.core.sessionGet(session.id);
76
+
77
+ return {
78
+ annotations: stored.annotations.length,
79
+ workingCopy: stored.workingCopy,
80
+ status: stored.status,
81
+ };
73
82
  }
74
83
 
75
84
  describe("observer rendering", () => {
76
- test("shows the observer badge and read-only hint bar", async () => {
85
+ test("the minimal header mirrors the thread name, with no observer badge", async () => {
86
+ // Arrange
77
87
  const setup = await renderObserver();
88
+
89
+ // Assert - the header shows the thread title only; read-only is enforced by
90
+ // the blocked primitives below, not a header badge
78
91
  const frame = setup.captureCharFrame();
79
- expect(frame).toContain("· observer");
80
- expect(frame).toContain("observer - read-only · j/k move");
81
- expect(frame).not.toContain("⏎ submit");
92
+
93
+ expect(frame).toContain("Migration Plan");
94
+ expect(frame).not.toContain("· observer");
82
95
  });
83
96
  });
84
97
 
85
- describe("observer verbs are blocked", () => {
86
- for (const [key, verb] of [
87
- ["c", "comment"],
88
- ["s", "suggest"],
89
- ["x", "cut"],
90
- ["e", "edit"],
91
- ["enter", "submit"],
92
- ] as const) {
93
- test(`${verb} (${key}) answers observer - read-only and mutates nothing`, async () => {
98
+ describe("observer primitives are blocked", () => {
99
+ const attempts: Array<[string, (setup: Setup) => Promise<void>]> = [
100
+ ["comment (typing)", (setup) => typeText(setup, "c")],
101
+ ["cut (option+x)", (setup) => navCommand(setup, "x")],
102
+ ["edit (ctrl+e)", (setup) => pressKey(setup, "e", { ctrl: true })],
103
+ ["submit (cmd+enter)", (setup) => pressKey(setup, "RETURN", { meta: true })],
104
+ ];
105
+
106
+ for (const [primitive, attempt] of attempts) {
107
+ test(`${primitive} answers observer - read-only and mutates nothing`, async () => {
94
108
  const setup = await renderObserver();
95
109
  const before = snapshot();
96
- await press(setup, "j");
97
- await press(setup, "j");
98
- await press(setup, key);
99
- await Bun.sleep(60);
100
- await setup.renderOnce();
101
- const frame = setup.captureCharFrame();
102
- expect(frame).toContain("observer - read-only");
103
- // no overlay opened: compose/submit bars never appear
104
- expect(frame).not.toContain("COMMENT ON");
105
- expect(frame).not.toContain("SUGGEST REPLACEMENT");
106
- expect(frame).not.toContain("verdict ←/→");
110
+
111
+ await clickText(setup, "daemon");
112
+ await attempt(setup);
113
+ const frame = await waitForText(setup, "observer - read-only");
114
+
115
+ // no draft card and no submit card ever appear
116
+ expect(frame).not.toContain("● c");
117
+ expect(frame).not.toContain("[approve]");
107
118
  expect(snapshot()).toEqual(before);
108
119
  });
109
120
  }
110
121
 
111
- test("span mode c/s is blocked too", async () => {
122
+ test("typing over a marked span is blocked too", async () => {
123
+ // Arrange
112
124
  const setup = await renderObserver();
113
- await press(setup, "j");
114
- await press(setup, "j");
115
- await press(setup, "v");
116
- await press(setup, "c");
117
- await setup.renderOnce();
118
- const frame = setup.captureCharFrame();
119
- expect(frame).toContain("observer - read-only");
120
- expect(frame).not.toContain("COMMENT ON");
125
+
126
+ // Act
127
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
128
+ await typeText(setup, "c");
129
+
130
+ // Assert
131
+ const frame = await waitForText(setup, "observer - read-only");
132
+
133
+ expect(frame).not.toContain("● c");
121
134
  expect(snapshot().annotations).toBe(0);
122
135
  });
123
136
  });
124
137
 
138
+ describe("a resolved review is read-only for its owner too", () => {
139
+ test("typing, deleting a card, and editing answer review submitted - read-only", async () => {
140
+ // Arrange: a comment exists and the message is in
141
+ server.core.sessionAnnotate(session.id, {
142
+ id: "a_done",
143
+ kind: "comment",
144
+ anchor: makeAnchor(parseBlocks(PLAN), 2, 0, 10),
145
+ body: "settled",
146
+ });
147
+ server.core.sessionSendMessage(session.id, "approved", "");
148
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
149
+ width: 120,
150
+ height: 32,
151
+ });
152
+
153
+ await waitForText(setup, "settled");
154
+ const before = snapshot();
155
+
156
+ // Act + Assert: a draft never opens
157
+ await clickText(setup, "daemon");
158
+ await typeText(setup, "c");
159
+ await waitForText(setup, "review submitted - read-only");
160
+ expect(setup.captureCharFrame()).not.toContain("● c");
161
+
162
+ // Act + Assert: the focused card cannot be deleted or edited
163
+ await navCommand(setup, "n");
164
+ await pressKey(setup, "BACKSPACE", { meta: true });
165
+ await pressKey(setup, "e", { ctrl: true });
166
+ await navCommand(setup, "x");
167
+ expect(snapshot()).toEqual(before);
168
+ expect(server.core.sessionGet(session.id).annotations).toHaveLength(1);
169
+ });
170
+ });
171
+
125
172
  describe("observer navigation still works", () => {
126
- test("j/k moves the cursor between blocks", async () => {
173
+ test("↓ moves the caret between blocks", async () => {
174
+ // Arrange
127
175
  const setup = await renderObserver();
128
- await press(setup, "j");
129
- await press(setup, "j");
176
+
177
+ // Act
178
+ await pressKey(setup, "ARROW_DOWN");
179
+ await pressKey(setup, "ARROW_DOWN");
130
180
  await setup.renderOnce();
131
- const lines = setup.captureCharFrame().split("\n");
132
- const cursorLine = lines.find((l) => l.includes("▎"))!;
133
- expect(cursorLine).toContain("persists sessions");
181
+
182
+ // Assert: the caret cell is painted on the paragraph's row
183
+ const caretRow = setup
184
+ .captureSpans()
185
+ .lines.findIndex((line) =>
186
+ line.spans.some((span) => span.bg.toInts().slice(0, 3).join() === "86,91,104"),
187
+ );
188
+
189
+ expect(caretRow).toBe(frameRow(setup, "persists sessions"));
134
190
  });
135
191
 
136
- test("n focuses annotations made by the controller", async () => {
192
+ test("option+n focuses annotations made by the controller", async () => {
193
+ // Arrange
137
194
  server.core.sessionAnnotate(session.id, {
138
195
  id: "a_obs1",
139
196
  kind: "comment",
@@ -141,8 +198,13 @@ describe("observer navigation still works", () => {
141
198
  body: "From the controller.",
142
199
  });
143
200
  const setup = await renderObserver();
144
- await press(setup, "n");
201
+
202
+ // Act - the observer navigates to the annotation without mutating anything
203
+ await navCommand(setup, "n");
145
204
  await setup.renderOnce();
146
- expect(setup.captureCharFrame()).toContain("▸ COMMENT");
205
+
206
+ // Assert - the observer reads the controller's comment inline in the thread
207
+ await waitForText(setup, "From the controller.");
208
+ expect(snapshot().annotations).toBe(1);
147
209
  });
148
210
  });
@@ -0,0 +1,94 @@
1
+ /** Regressions from the focus/perf round: ctrl+q must quit from any keyboard state, and clicking a
2
+ * sidebar thread must give it the selected accent at once (the cursor follows the click). */
3
+
4
+ import { afterEach, beforeEach, expect, test } from "bun:test";
5
+ import { mkdtempSync, rmSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import React from "react";
9
+ import { DaemonServer } from "@cueloop/daemon";
10
+ import { App } from "./App";
11
+ import {
12
+ clickText,
13
+ isolateUserConfig,
14
+ pressKey,
15
+ renderReadyApp,
16
+ waitForText,
17
+ } from "./test-support";
18
+
19
+ const ACCENT_DARK: [number, number, number] = [203, 166, 247];
20
+
21
+ let home: string;
22
+ let restoreUserConfig: () => void;
23
+ let server: DaemonServer;
24
+
25
+ function plan(title: string): void {
26
+ server.core.sessionCreate({
27
+ workspace: { repoRoot: "/repo", branch: "main" },
28
+ artifact: { type: "plan", content: `# ${title}\n`, meta: { title, planPath: "plan.md" } },
29
+ });
30
+ }
31
+
32
+ beforeEach(() => {
33
+ home = mkdtempSync(join(tmpdir(), "cueloop-regression-"));
34
+ restoreUserConfig = isolateUserConfig(home);
35
+ server = new DaemonServer({ home, idleExitMs: 0 });
36
+ server.start();
37
+ plan("First review");
38
+ plan("Second review");
39
+ });
40
+ afterEach(() => {
41
+ restoreUserConfig();
42
+ server.stop();
43
+ rmSync(home, { recursive: true, force: true });
44
+ });
45
+
46
+ /** The text of the sidebar title span currently painted in the accent colour, or undefined. */
47
+ function accentTitle(setup: Awaited<ReturnType<typeof renderReadyApp>>): string | undefined {
48
+ for (const line of setup.captureSpans().lines) {
49
+ for (const span of line.spans) {
50
+ const rgb = span.fg?.toInts();
51
+ if (
52
+ rgb &&
53
+ rgb[0] === ACCENT_DARK[0] &&
54
+ rgb[1] === ACCENT_DARK[1] &&
55
+ rgb[2] === ACCENT_DARK[2]
56
+ ) {
57
+ const text = span.text.trim();
58
+ if (text.includes("review")) return text;
59
+ }
60
+ }
61
+ }
62
+
63
+ return undefined;
64
+ }
65
+
66
+ test("ctrl+q quits even when a menu holds the keyboard", async () => {
67
+ let exitCode: number | undefined;
68
+ const setup = await renderReadyApp(<App home={home} onExit={(code) => (exitCode = code)} />, {
69
+ width: 120,
70
+ height: 32,
71
+ });
72
+ await waitForText(setup, "review");
73
+
74
+ // open the settings dialog from the top-left gear - it takes the keyboard
75
+ await setup.mockMouse.click(1, 0);
76
+ await waitForText(setup, "Keybinds");
77
+
78
+ await pressKey(setup, "q", { ctrl: true });
79
+
80
+ expect(exitCode).toBe(0);
81
+ setup.renderer.destroy();
82
+ });
83
+
84
+ test("clicking a sidebar thread gives it the selected accent at once", async () => {
85
+ const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
86
+ await waitForText(setup, "Second review");
87
+
88
+ // click whichever thread is not already the selected (accent) one
89
+ const target = accentTitle(setup)?.includes("Second") ? "First review" : "Second review";
90
+ await clickText(setup, target);
91
+
92
+ expect(accentTitle(setup)).toContain(target.split(" ")[0]!);
93
+ setup.renderer.destroy();
94
+ });