@cueloop/client 0.1.0-alpha.8 → 0.1.0-alpha.81

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 (307) 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 +40 -32
  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 +176 -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 +428 -0
  16. package/src/App.prototype-markdown.test.tsx +92 -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 +222 -0
  23. package/src/App.test.tsx +333 -131
  24. package/src/App.toast.test.tsx +98 -0
  25. package/src/App.tsx +1338 -608
  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 +701 -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 +574 -0
  35. package/src/app-view-model.ts +162 -0
  36. package/src/attribution.test.ts +31 -0
  37. package/src/attribution.ts +42 -0
  38. package/src/clipboard.test.ts +23 -0
  39. package/src/clipboard.ts +33 -0
  40. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  41. package/src/components/AnnotatableFileView.test.tsx +102 -0
  42. package/src/components/AnnotatableFileView.tsx +134 -0
  43. package/src/components/AnnotationCards.stories.tsx +86 -0
  44. package/src/components/AnnotationCards.tsx +341 -0
  45. package/src/components/AnsiScreen.stories.tsx +12 -0
  46. package/src/components/AnsiScreen.tsx +28 -0
  47. package/src/components/AppHeader.stories.tsx +50 -0
  48. package/src/components/AppHeader.tsx +83 -0
  49. package/src/components/AppShell.brand.test.tsx +58 -0
  50. package/src/components/AppShell.stories.tsx +89 -0
  51. package/src/components/AppShell.tsx +329 -0
  52. package/src/components/AppShell.zoom.test.tsx +135 -0
  53. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  54. package/src/components/BareWorkbenchFileView.tsx +64 -0
  55. package/src/components/Breadcrumb.stories.tsx +19 -0
  56. package/src/components/Breadcrumb.tsx +45 -0
  57. package/src/components/ChangesColumn.stories.tsx +44 -0
  58. package/src/components/ChangesColumn.tsx +199 -0
  59. package/src/components/ChangesFileTree.test.tsx +61 -0
  60. package/src/components/CodeBlock.stories.tsx +42 -0
  61. package/src/components/CodeBlock.tsx +84 -0
  62. package/src/components/CompletionOverlay.stories.tsx +37 -0
  63. package/src/components/CompletionOverlay.tsx +77 -0
  64. package/src/components/ConfirmCard.stories.tsx +37 -0
  65. package/src/components/ConfirmCard.tsx +153 -0
  66. package/src/components/ConfirmDialog.stories.tsx +21 -0
  67. package/src/components/ConfirmDialog.tsx +72 -0
  68. package/src/components/DiffContentView.scroll.test.tsx +121 -0
  69. package/src/components/DiffContentView.split.test.tsx +140 -0
  70. package/src/components/DiffContentView.stories.tsx +80 -0
  71. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  72. package/src/components/DiffContentView.tsx +876 -0
  73. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  74. package/src/components/DiscussionMarkerRail.tsx +186 -0
  75. package/src/components/EditorGrid.stories.tsx +59 -0
  76. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  77. package/src/components/EditorGrid.tsx +426 -0
  78. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  79. package/src/components/FileContentsView.stories.tsx +16 -0
  80. package/src/components/FileContentsView.test.tsx +29 -0
  81. package/src/components/FileContentsView.tsx +98 -0
  82. package/src/components/GridTabContent.stories.tsx +59 -0
  83. package/src/components/GridTabContent.tsx +182 -0
  84. package/src/components/HandleChipsInput.stories.tsx +24 -0
  85. package/src/components/HandleChipsInput.test.tsx +35 -0
  86. package/src/components/HandleChipsInput.tsx +145 -0
  87. package/src/components/KeybindsDialog.stories.tsx +31 -0
  88. package/src/components/KeybindsDialog.tsx +103 -0
  89. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  90. package/src/components/MarkdownGridBlock.tsx +72 -0
  91. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  92. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  93. package/src/components/MarkdownThreadEditor.tsx +162 -0
  94. package/src/components/MenuBar.stories.tsx +28 -0
  95. package/src/components/MenuBar.tsx +44 -0
  96. package/src/components/NavModeHint.stories.tsx +40 -0
  97. package/src/components/NavModeHint.tsx +31 -0
  98. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  99. package/src/components/OverlayScrollbar.test.tsx +139 -0
  100. package/src/components/OverlayScrollbar.tsx +129 -0
  101. package/src/components/PaneGrid.stories.tsx +84 -0
  102. package/src/components/PaneGrid.tsx +128 -0
  103. package/src/components/PanelColumn.stories.tsx +25 -0
  104. package/src/components/PanelColumn.tsx +98 -0
  105. package/src/components/ProjectTreeView.stories.tsx +25 -0
  106. package/src/components/ProjectTreeView.test.tsx +59 -0
  107. package/src/components/ProjectTreeView.tsx +111 -0
  108. package/src/components/PromptDialog.stories.tsx +36 -0
  109. package/src/components/PromptDialog.test.tsx +61 -0
  110. package/src/components/PromptDialog.tsx +99 -0
  111. package/src/components/PrototypeContentView.stories.tsx +19 -0
  112. package/src/components/PrototypeContentView.tsx +446 -0
  113. package/src/components/RootOverlay.stories.tsx +45 -0
  114. package/src/components/RootOverlay.tsx +48 -0
  115. package/src/components/ScrollArea.stories.tsx +20 -0
  116. package/src/components/ScrollArea.tsx +44 -0
  117. package/src/components/SettingsDialog.stories.tsx +117 -0
  118. package/src/components/SettingsDialog.tsx +186 -0
  119. package/src/components/SettingsRows.stories.tsx +18 -0
  120. package/src/components/SettingsRows.tsx +112 -0
  121. package/src/components/ShareDialog.stories.tsx +121 -0
  122. package/src/components/ShareDialog.tsx +552 -0
  123. package/src/components/ShellHeader.stories.tsx +50 -0
  124. package/src/components/ShellHeader.tsx +137 -0
  125. package/src/components/SlashComposer.stories.tsx +37 -0
  126. package/src/components/SlashComposer.tsx +87 -0
  127. package/src/components/ThreadFooter.stories.tsx +30 -0
  128. package/src/components/ThreadFooter.test.tsx +47 -0
  129. package/src/components/ThreadFooter.tsx +76 -0
  130. package/src/components/ThreadTree.stories.tsx +46 -0
  131. package/src/components/ThreadTree.tsx +255 -0
  132. package/src/components/ThreadView.selection.test.tsx +899 -0
  133. package/src/components/ThreadView.slash.test.tsx +146 -0
  134. package/src/components/ThreadView.stories.tsx +26 -0
  135. package/src/components/ThreadView.tsx +472 -0
  136. package/src/components/Toast.stories.tsx +19 -0
  137. package/src/components/Toast.tsx +52 -0
  138. package/src/components/Tooltip.stories.tsx +26 -0
  139. package/src/components/Tooltip.tsx +72 -0
  140. package/src/components/TreePane.stories.tsx +106 -0
  141. package/src/components/TreePane.tsx +157 -0
  142. package/src/components/WalkWizard.stories.tsx +100 -0
  143. package/src/components/WalkWizard.tsx +150 -0
  144. package/src/components/WelcomePlayground.stories.tsx +12 -0
  145. package/src/components/WelcomePlayground.test.tsx +47 -0
  146. package/src/components/WelcomePlayground.tsx +142 -0
  147. package/src/components/__snapshots__/stories.test.tsx.snap +2238 -0
  148. package/src/components/diff-content-view-layout.test.ts +198 -0
  149. package/src/components/diff-content-view-layout.ts +84 -0
  150. package/src/components/editor-grid.test.ts +86 -0
  151. package/src/components/editor-grid.ts +138 -0
  152. package/src/components/file-tree.test.ts +60 -0
  153. package/src/components/file-tree.ts +112 -0
  154. package/src/components/menu-control.stories.tsx +35 -0
  155. package/src/components/menu-control.tsx +43 -0
  156. package/src/components/pages/Workspace.stories.tsx +181 -0
  157. package/src/components/primitives/Button.stories.tsx +32 -0
  158. package/src/components/primitives/Button.tsx +56 -0
  159. package/src/components/primitives/Card.stories.tsx +25 -0
  160. package/src/components/primitives/Card.tsx +63 -0
  161. package/src/components/primitives/Dialog.stories.tsx +24 -0
  162. package/src/components/primitives/Dialog.test.tsx +37 -0
  163. package/src/components/primitives/Dialog.tsx +71 -0
  164. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  165. package/src/components/primitives/DialogActions.tsx +39 -0
  166. package/src/components/primitives/IconButton.stories.tsx +36 -0
  167. package/src/components/primitives/IconButton.tsx +62 -0
  168. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  169. package/src/components/primitives/StatusBar.tsx +23 -0
  170. package/src/components/primitives/Switch.stories.tsx +17 -0
  171. package/src/components/primitives/Switch.tsx +32 -0
  172. package/src/components/primitives/Tabs.stories.tsx +29 -0
  173. package/src/components/primitives/Tabs.tsx +102 -0
  174. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  175. package/src/components/primitives/Toolbar.tsx +14 -0
  176. package/src/components/primitives/Tree.stories.tsx +124 -0
  177. package/src/components/primitives/Tree.tsx +100 -0
  178. package/src/components/primitives/frame.test.ts +12 -0
  179. package/src/components/primitives/frame.ts +17 -0
  180. package/src/components/primitives/icons.ts +61 -0
  181. package/src/components/primitives/tree-model.test.ts +99 -0
  182. package/src/components/primitives/tree-model.ts +137 -0
  183. package/src/components/quick-actions-editor.stories.tsx +58 -0
  184. package/src/components/quick-actions-editor.test.tsx +117 -0
  185. package/src/components/quick-actions-editor.tsx +226 -0
  186. package/src/components/share-dialog-store.ts +58 -0
  187. package/src/components/stories-app.tsx +193 -0
  188. package/src/components/stories.test.tsx +74 -0
  189. package/src/components/story-fixtures.ts +102 -0
  190. package/src/components/story-tree.test.ts +108 -0
  191. package/src/components/story.ts +159 -0
  192. package/src/components/syntax-highlight.ts +133 -0
  193. package/src/components/terminal-pane.test.tsx +40 -0
  194. package/src/components/terminal-pane.ts +199 -0
  195. package/src/components/theme-context.stories.tsx +46 -0
  196. package/src/components/theme-context.tsx +37 -0
  197. package/src/components/thread-tree.test.ts +134 -0
  198. package/src/components/thread-tree.ts +109 -0
  199. package/src/components/truncate-text.ts +7 -0
  200. package/src/components/truncate-title.test.ts +31 -0
  201. package/src/components/truncate-title.ts +15 -0
  202. package/src/config-identity.test.ts +78 -0
  203. package/src/config.test.ts +517 -33
  204. package/src/config.ts +467 -57
  205. package/src/diff-fold.test.ts +78 -0
  206. package/src/diff-fold.ts +126 -0
  207. package/src/diff-hunk-curate.test.ts +315 -0
  208. package/src/diff-hunk-curate.ts +153 -0
  209. package/src/diff-intraline.test.ts +238 -0
  210. package/src/diff-intraline.ts +236 -0
  211. package/src/diff-syntax.test.ts +112 -0
  212. package/src/diff-syntax.ts +179 -0
  213. package/src/discussions.ts +79 -0
  214. package/src/editor.test.ts +149 -0
  215. package/src/editor.ts +139 -9
  216. package/src/ghostty-terminal.test.ts +85 -0
  217. package/src/ghostty-terminal.ts +199 -0
  218. package/src/github-identity.test.ts +58 -0
  219. package/src/github-identity.ts +41 -0
  220. package/src/herdr.test.ts +9 -34
  221. package/src/herdr.ts +25 -21
  222. package/src/index.ts +13 -0
  223. package/src/integrations.test.ts +41 -0
  224. package/src/integrations.ts +32 -0
  225. package/src/intent-dispatch.test.ts +723 -0
  226. package/src/intent-dispatch.ts +692 -0
  227. package/src/key-bindings.test.ts +156 -0
  228. package/src/key-bindings.ts +459 -0
  229. package/src/keymap.test.ts +461 -113
  230. package/src/keymap.ts +321 -79
  231. package/src/kitty-image.test.ts +66 -0
  232. package/src/kitty-image.ts +138 -0
  233. package/src/launch-layout.test.ts +52 -0
  234. package/src/launch-layout.ts +42 -0
  235. package/src/mark-runs.ts +121 -0
  236. package/src/markdown-editor-syntax-style.ts +50 -0
  237. package/src/markdown-grid.test.ts +101 -0
  238. package/src/markdown-grid.ts +203 -0
  239. package/src/markdown-highlight.test.ts +61 -0
  240. package/src/markdown-highlight.ts +122 -0
  241. package/src/pasted-image.test.ts +67 -0
  242. package/src/pasted-image.ts +60 -0
  243. package/src/perf/otlp-trace.test.ts +116 -0
  244. package/src/perf/otlp-trace.ts +130 -0
  245. package/src/perf/perf-report.ts +17 -0
  246. package/src/perf/perf-timings.test.ts +18 -0
  247. package/src/perf/perf-timings.ts +38 -0
  248. package/src/prototype-browser.test.ts +64 -0
  249. package/src/prototype-browser.ts +329 -0
  250. package/src/prototype-pixels.tsx +35 -0
  251. package/src/pty.test.ts +81 -0
  252. package/src/pty.ts +245 -0
  253. package/src/ready-signal.test.ts +85 -0
  254. package/src/ready-signal.ts +52 -0
  255. package/src/run.ts +78 -13
  256. package/src/serve.test.ts +24 -12
  257. package/src/serve.ts +52 -10
  258. package/src/share.ts +237 -0
  259. package/src/skills.test.ts +74 -0
  260. package/src/skills.ts +75 -0
  261. package/src/slash-palette.test.ts +103 -0
  262. package/src/slash-palette.ts +129 -0
  263. package/src/split-diff.test.ts +110 -0
  264. package/src/split-diff.ts +149 -0
  265. package/src/test-support.test.ts +264 -0
  266. package/src/test-support.ts +369 -0
  267. package/src/theme-presets.test.ts +51 -0
  268. package/src/theme-presets.ts +214 -0
  269. package/src/theme.ts +84 -19
  270. package/src/thread-chords.test.ts +79 -0
  271. package/src/thread-chords.ts +163 -0
  272. package/src/thread-controller.curate.test.ts +450 -0
  273. package/src/thread-controller.derivation.test.ts +62 -0
  274. package/src/thread-controller.live-diff.test.ts +322 -0
  275. package/src/thread-controller.optimistic.test.ts +69 -0
  276. package/src/thread-controller.share.test.ts +561 -0
  277. package/src/thread-controller.switch-cache.test.ts +176 -0
  278. package/src/thread-controller.tree.test.ts +261 -0
  279. package/src/thread-controller.ts +1893 -0
  280. package/src/thread-selection.test.ts +210 -0
  281. package/src/thread-selection.ts +243 -0
  282. package/src/tree-view.test.ts +128 -0
  283. package/src/tree-view.ts +148 -0
  284. package/src/use-annotation-surface.tsx +915 -0
  285. package/src/use-changes-workbench.ts +190 -0
  286. package/src/use-frame-measure.ts +41 -0
  287. package/src/use-remember-layout.ts +31 -0
  288. package/src/use-settings-dialog.tsx +320 -0
  289. package/src/use-terminal-virtualizer.ts +233 -0
  290. package/src/use-thread-body-editing.tsx +58 -0
  291. package/src/version.ts +5 -0
  292. package/src/view-diff.test.ts +137 -9
  293. package/src/view-diff.ts +269 -23
  294. package/src/view-plan.regression.test.ts +60 -0
  295. package/src/view-plan.test.ts +456 -0
  296. package/src/view-plan.ts +719 -0
  297. package/src/visual/contrast.test.ts +35 -0
  298. package/src/visual/contrast.ts +33 -0
  299. package/src/walk.test.ts +121 -0
  300. package/src/walk.ts +74 -0
  301. package/src/workbench-snapshot.test.ts +81 -0
  302. package/src/workbench-snapshot.ts +29 -0
  303. package/src/session-controller.ts +0 -345
  304. package/src/syntax.test.ts +0 -41
  305. package/src/syntax.ts +0 -88
  306. package/src/view.test.ts +0 -157
  307. package/src/view.ts +0 -312
@@ -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
+ });
@@ -1,14 +1,14 @@
1
- /** Plan rendering (#67): code blocks verbatim + contained, spacing never collapses. */
1
+ /** Plan rendering: code blocks verbatim + contained, spacing never collapses. */
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
4
  import { mkdtempSync, rmSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import React from "react";
8
- import { testRender } from "@opentui/react/test-utils";
9
8
  import { DaemonServer } from "@cueloop/daemon";
10
- import type { ReviewSession } from "@cueloop/schema";
9
+ import type { Thread } from "@cueloop/schema";
11
10
  import { App } from "./App";
11
+ import { isolateUserConfig, waitForText, renderReadyApp } from "./test-support";
12
12
 
13
13
  const PLAN = `# Render Plan
14
14
 
@@ -29,11 +29,13 @@ Deploys block below the threshold.
29
29
  `;
30
30
 
31
31
  let home: string;
32
+ let restoreUserConfig: () => void;
32
33
  let server: DaemonServer;
33
- let session: ReviewSession;
34
+ let session: Thread;
34
35
 
35
36
  beforeEach(() => {
36
37
  home = mkdtempSync(join(tmpdir(), "cueloop-render-"));
38
+ restoreUserConfig = isolateUserConfig(home);
37
39
  server = new DaemonServer({ home, idleExitMs: 0 });
38
40
  server.start();
39
41
  session = server.core.sessionCreate({
@@ -42,67 +44,91 @@ beforeEach(() => {
42
44
  });
43
45
  });
44
46
  afterEach(() => {
47
+ restoreUserConfig();
45
48
  server.stop();
46
49
  rmSync(home, { recursive: true, force: true });
47
50
  });
48
51
 
49
52
  async function frame(): Promise<string> {
50
- const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 36 });
51
- const deadline = Date.now() + 10_000;
52
- while (Date.now() < deadline && !setup.captureCharFrame().includes("Render Plan")) {
53
- await Bun.sleep(25);
54
- await setup.renderOnce();
55
- }
56
- // give the async highlighter a beat, then take the final frame
57
- await Bun.sleep(150);
58
- await setup.renderOnce();
53
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
54
+ width: 120,
55
+ height: 36,
56
+ });
57
+
58
+ await waitForText(setup, "Render Plan");
59
+ // async highlights settle within the visual-idle wait
60
+ await setup.waitForVisualIdle();
61
+
59
62
  return setup.captureCharFrame();
60
63
  }
61
64
 
62
65
  describe("code blocks", () => {
63
66
  test("lines render verbatim: indentation preserved, no word-wrap", async () => {
64
- const f = await frame();
65
- expect(f).toContain(" const blocked = full < 0.6;");
66
- expect(f).toContain(' return <text>{blocked ? "blocked" : "clear"}</text>;');
67
+ // Arrange
68
+ const rendered = await frame();
69
+
70
+ // Assert
71
+ expect(rendered).toContain(" const blocked = full < 0.6;");
72
+ expect(rendered).toContain(' return <text>{blocked ? "blocked" : "clear"}</text>;');
67
73
  });
68
74
 
69
75
  test("the block carries its language tag", async () => {
70
- const f = await frame();
71
- expect(f).toContain("tsx");
76
+ // Arrange
77
+ const rendered = await frame();
78
+
79
+ // Assert
80
+ expect(rendered).toContain("tsx");
72
81
  });
73
82
  });
74
83
 
75
84
  describe("block spacing", () => {
85
+ // the review rail paints box edges on the right, so read the document
86
+ // column (everything left of the first rail glyph) before asserting spacing
87
+ const planColumn = (line: string): string => line.split(/[│╭╰]/)[0]!.trimEnd();
88
+
76
89
  test("a code block never glues to the list item above it", async () => {
77
- const lines = (await frame()).split("\n").map((l) => l.trimEnd());
78
- const li = lines.findIndex((l) => l.includes("wire the duck over MQTT"));
79
- const codeHeader = lines.findIndex((l) => l.trim().startsWith("tsx"));
80
- expect(li).toBeGreaterThan(-1);
81
- expect(codeHeader).toBeGreaterThan(li);
82
- expect(lines.slice(li + 1, codeHeader).some((l) => l === "")).toBe(true);
90
+ // Arrange
91
+ const lines = (await frame()).split("\n").map(planColumn);
92
+ const listItemLineIndex = lines.findIndex((line) => line.includes("wire the duck over MQTT"));
93
+ const codeHeader = lines.findIndex((line) => line.trim().startsWith("tsx"));
94
+
95
+ // Assert
96
+ expect(listItemLineIndex).toBeGreaterThan(-1);
97
+ expect(codeHeader).toBeGreaterThan(listItemLineIndex);
98
+ expect(lines.slice(listItemLineIndex + 1, codeHeader).some((line) => line === "")).toBe(true);
83
99
  });
84
100
 
85
101
  test("a heading never sits directly on the previous block", async () => {
86
- const lines = (await frame()).split("\n").map((l) => l.trimEnd());
87
- const gate = lines.findIndex((l) => l.trim() === "Gate");
102
+ // Arrange
103
+ const lines = (await frame()).split("\n").map(planColumn);
104
+ const gate = lines.findIndex((line) => line.trim() === "Gate");
105
+
106
+ // Assert
88
107
  expect(gate).toBeGreaterThan(0);
89
108
  expect(lines[gate - 1]).toBe("");
90
109
  });
91
110
 
92
111
  test("consecutive list items stay tight", async () => {
112
+ // Arrange
93
113
  const listPlan = "# T\n\n- one\n- two\n";
94
114
  const s2 = server.core.sessionCreate({
95
115
  workspace: { repoRoot: "/repo", branch: "main" },
96
116
  artifact: { type: "plan", content: listPlan, meta: {} },
97
117
  });
98
- const setup = await testRender(<App home={home} sessionId={s2.id} />, { width: 120, height: 30 });
99
- const deadline = Date.now() + 10_000;
100
- while (Date.now() < deadline && !setup.captureCharFrame().includes("one")) {
101
- await Bun.sleep(25);
102
- await setup.renderOnce();
103
- }
104
- const lines = setup.captureCharFrame().split("\n").map((l) => l.trimEnd());
105
- const one = lines.findIndex((l) => l.includes("- one"));
106
- expect(lines[one + 1]).toContain("- two");
118
+ const setup = await renderReadyApp(<App home={home} sessionId={s2.id} />, {
119
+ width: 120,
120
+ height: 30,
121
+ });
122
+
123
+ await waitForText(setup, "one");
124
+
125
+ // Assert
126
+ const lines = setup
127
+ .captureCharFrame()
128
+ .split("\n")
129
+ .map((line) => line.trimEnd());
130
+ const one = lines.findIndex((line) => line.includes("· one"));
131
+
132
+ expect(lines[one + 1]).toContain("· two");
107
133
  });
108
134
  });