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

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
package/src/App.test.tsx CHANGED
@@ -1,18 +1,27 @@
1
- /**
2
- * Virtual-terminal component tests (tier 2): the real App over a real
3
- * in-process daemon in a temp home. Char-frame assertions + mock keys -
4
- * the whole review loop drivable without a terminal.
5
- */
1
+ /** Virtual-terminal component tests (tier 2): the real App over a real in-process daemon in a temp home. Char-frame assertions + mock keys - the whole review loop drivable without a terminal. */
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 type { ReviewSession } from "@cueloop/schema";
9
+ import type { Thread } from "@cueloop/schema";
15
10
  import { App } from "./App";
11
+ import {
12
+ clickText,
13
+ dragText,
14
+ frameRow,
15
+ isolateUserConfig,
16
+ locateText,
17
+ press,
18
+ navCommand,
19
+ pressKey,
20
+ renderReadyApp,
21
+ typeText as type,
22
+ waitForState,
23
+ waitForText,
24
+ } from "./test-support";
16
25
 
17
26
  const PLAN = `# Migration Plan
18
27
 
@@ -27,192 +36,385 @@ The daemon persists sessions to disk atomically.
27
36
  `;
28
37
 
29
38
  let home: string;
39
+ let restoreUserConfig: () => void;
30
40
  let server: DaemonServer;
31
- let session: ReviewSession;
41
+ let session: Thread;
32
42
 
33
43
  beforeEach(() => {
34
44
  home = mkdtempSync(join(tmpdir(), "cueloop-app-"));
45
+ restoreUserConfig = isolateUserConfig(home);
35
46
  server = new DaemonServer({ home, idleExitMs: 0 });
36
47
  server.start();
37
48
  session = server.core.sessionCreate({
38
49
  workspace: { repoRoot: "/repo", branch: "main" },
39
- artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
50
+ artifact: {
51
+ type: "plan",
52
+ content: PLAN,
53
+ meta: { title: "Migration Plan", planPath: "plan.md" },
54
+ },
40
55
  });
41
56
  });
42
57
  afterEach(() => {
58
+ restoreUserConfig();
43
59
  server.stop();
44
60
  rmSync(home, { recursive: true, force: true });
45
61
  });
46
62
 
47
63
  async function renderApp(sessionId?: string) {
48
- const setup = await testRender(<App home={home} sessionId={sessionId ?? session.id} />, {
64
+ return renderReadyApp(<App home={home} sessionId={sessionId ?? session.id} />, {
49
65
  width: 120,
50
66
  height: 32,
51
67
  });
52
- // let the async daemon connect + first fetch land
53
- for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
54
- await Bun.sleep(25);
55
- await setup.renderOnce();
56
- }
57
- await setup.renderOnce();
58
- return setup;
59
- }
60
-
61
- type Setup = Awaited<ReturnType<typeof renderApp>>;
62
-
63
- /** Drive one key press: letters type as text; named keys use KeyCodes ids. */
64
- async function press(setup: Setup, k: string): Promise<void> {
65
- if (k === "enter") setup.mockInput.pressKey("RETURN");
66
- else if (k === "escape") setup.mockInput.pressKey("ESCAPE");
67
- else if (k === "backspace") setup.mockInput.pressKey("BACKSPACE");
68
- else if (k === "left") setup.mockInput.pressKey("ARROW_LEFT");
69
- else if (k === "right") setup.mockInput.pressKey("ARROW_RIGHT");
70
- else await type(setup, k);
71
- await Bun.sleep(15);
72
- await setup.renderOnce();
73
- }
74
-
75
- async function type(setup: Setup, text: string): Promise<void> {
76
- await setup.mockInput.typeText(text);
77
- await Bun.sleep(15);
78
- await setup.renderOnce();
79
68
  }
80
69
 
81
70
  describe("plan rendering", () => {
82
- test("renders the plan with headings, list markers, and the rail", async () => {
71
+ test("renders the plan with headings, list markers, and the footer", async () => {
72
+ // Arrange
83
73
  const setup = await renderApp();
74
+
75
+ // Assert
84
76
  const frame = setup.captureCharFrame();
77
+
85
78
  expect(frame).toContain("Migration Plan");
86
79
  expect(frame).toContain("Context");
87
80
  expect(frame).toContain("persists sessions to disk atomically");
88
- expect(frame).toContain("- move the store");
89
- expect(frame).toContain("REVIEW (0)");
90
- expect(frame).toContain("Submit review (0)");
81
+ expect(frame).toContain("· move the store");
82
+ expect(frame).toContain("Send message (");
83
+ });
84
+
85
+ test("a direct open still populates the Threads sidebar with other pending reviews", async () => {
86
+ // Arrange - a second pending review exists alongside the one opened directly
87
+ server.core.sessionCreate({
88
+ workspace: { repoRoot: "/repo", branch: "main" },
89
+ artifact: { type: "plan", content: "# Other Plan\n", meta: { title: "Other Plan" } },
90
+ });
91
+ const setup = await renderApp();
92
+
93
+ // Act - open the collapsed Threads sidebar (the mirrored panel toggle by the gear)
94
+ await setup.mockMouse.click(4, 0);
95
+
96
+ // Assert - the sidebar lists the other pending review, so you can jump to it
97
+ await waitForText(setup, "Other Plan");
91
98
  });
92
99
  });
93
100
 
94
- describe("keyboard grammar", () => {
95
- test("j/k moves the cursor glyph between blocks", async () => {
101
+ describe("thread view grammar", () => {
102
+ test("↓ moves the caret between blocks and typing anchors to the block under it", async () => {
103
+ // Arrange
96
104
  const setup = await renderApp();
97
- await press(setup, "j");
98
- await press(setup, "j");
99
- await setup.renderOnce();
100
- const lines = setup.captureCharFrame().split("\n");
101
- const cursorLine = lines.find((l) => l.includes("▎"))!;
102
- expect(cursorLine).toContain("persists sessions");
105
+
106
+ // Act: two blocks down lands on the paragraph; a printable opens a draft there
107
+ await pressKey(setup, "ARROW_DOWN");
108
+ await pressKey(setup, "ARROW_DOWN");
109
+ await type(setup, "x");
110
+
111
+ // Assert: the draft card sits right under the paragraph's row
112
+ await waitForText(setup, "● x");
113
+ expect(frameRow(setup, "● x")).toBeGreaterThan(frameRow(setup, "persists sessions"));
114
+ expect(frameRow(setup, "● x")).toBeLessThan(frameRow(setup, "Steps"));
103
115
  });
104
116
 
105
- test("comment flow: c types a body, ⏎ saves to the daemon", async () => {
117
+ test("comment flow: click a word, type a body, cmd+⏎ saves to the daemon", async () => {
118
+ // Arrange
106
119
  const setup = await renderApp();
107
- await press(setup, "j");
108
- await press(setup, "j");
109
- await press(setup, "c");
110
- await setup.renderOnce();
111
- expect(setup.captureCharFrame()).toContain("COMMENT ON");
120
+
121
+ // Act
122
+ await clickText(setup, "daemon");
112
123
  await type(setup, "Define atomically.");
113
- await press(setup, "enter");
114
- await Bun.sleep(80);
115
- await setup.renderOnce();
124
+ await pressKey(setup, "RETURN", { meta: true });
125
+
126
+ // Assert
127
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
116
128
  const stored = server.core.sessionGet(session.id);
129
+
117
130
  expect(stored.annotations.length).toBe(1);
118
131
  expect(stored.annotations[0]!.body).toBe("Define atomically.");
119
- expect(setup.captureCharFrame()).toContain("COMMENT");
132
+ expect(stored.annotations[0]!.anchor.quote).toBe("daemon");
120
133
  });
121
134
 
122
- test("span mode: v + l selects words, c anchors the exact span", async () => {
135
+ test("a drag marks the exact span the comment anchors to", async () => {
136
+ // Arrange
123
137
  const setup = await renderApp();
124
- await press(setup, "j");
125
- await press(setup, "j");
126
- await press(setup, "v");
127
- await press(setup, "l"); // "The daemon"
128
- await press(setup, "c");
138
+
139
+ // Act: from the start of "The" to the end of "daemon"
140
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
129
141
  await type(setup, "Which daemon?");
130
- await press(setup, "enter");
131
- await Bun.sleep(80);
132
- const stored = server.core.sessionGet(session.id);
133
- expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
142
+ await pressKey(setup, "RETURN", { meta: true });
143
+
144
+ // Assert
145
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
146
+ expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe("The daemon");
134
147
  });
135
148
 
136
- test("x cuts a block into the working copy; x restores it", async () => {
149
+ test("option+x cuts the caret's block into the working copy; option+x restores it", async () => {
150
+ // Arrange
137
151
  const setup = await renderApp();
138
- // move to "- move the store" (h1, h2, p, h2 = 4 steps in)
139
- for (let i = 0; i < 4; i++) await press(setup, "j");
140
- await press(setup, "x");
141
- await Bun.sleep(80);
142
- await setup.renderOnce();
143
- expect(server.core.sessionGet(session.id).workingCopy).not.toContain("move the store");
144
- expect(setup.captureCharFrame()).toContain("[cut]");
145
- await press(setup, "x");
146
- await Bun.sleep(80);
147
- await setup.renderOnce();
148
- expect(server.core.sessionGet(session.id).workingCopy).toBeUndefined();
149
- });
150
-
151
- test("e runs $EDITOR on the working copy and tracks the diff", async () => {
152
- const script = join(home, "fake-editor.sh");
153
- await Bun.write(script, `#!/bin/sh\nsed -i '' 's/atomically/very atomically/' "$1"\n`);
154
- Bun.spawnSync(["chmod", "+x", script]);
155
- process.env.CUELOOP_EDITOR = script;
156
- try {
157
- const setup = await renderApp();
158
- await press(setup, "e");
159
- await Bun.sleep(150);
160
- await setup.renderOnce();
161
- expect(server.core.sessionGet(session.id).workingCopy).toContain("very atomically");
162
- expect(setup.captureCharFrame()).toContain("[edited]");
163
- } finally {
164
- delete process.env.CUELOOP_EDITOR;
165
- }
152
+
153
+ await clickText(setup, "move the store");
154
+
155
+ // Act
156
+ await navCommand(setup, "x");
157
+
158
+ // Assert - the cut lands in the working copy
159
+ await waitForState(
160
+ setup,
161
+ () => !(server.core.sessionGet(session.id).workingCopy ?? "").includes("move the store"),
162
+ );
163
+
164
+ // Act
165
+ await navCommand(setup, "x");
166
+
167
+ // Assert
168
+ await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
169
+ });
170
+
171
+ test("ctrl+e opens the inline editor; the header toggles to normal and leaving tracks the edit", async () => {
172
+ // Arrange
173
+ const setup = await renderApp();
174
+
175
+ // Act - open the inline markdown editor over the thread body
176
+ await pressKey(setup, "e", { ctrl: true });
177
+
178
+ // Assert - the editor owns the pane (its save hint shows) and the header offers the way back
179
+ await waitForText(setup, "save & close");
180
+ expect(setup.captureCharFrame()).toContain("normal");
181
+
182
+ // Act - type into the body, then leave through the header toggle
183
+ await type(setup, "MORE");
184
+ await clickText(setup, "normal");
185
+
186
+ // Assert - the edit is tracked into the working copy, and the read-only view is back
187
+ await waitForState(setup, () =>
188
+ (server.core.sessionGet(session.id).workingCopy ?? "").includes("MORE"),
189
+ );
166
190
  });
167
191
  });
168
192
 
169
193
  describe("submit", () => {
170
- test("⏎ opens the verdict bar; verdict + summary resolve the session", async () => {
194
+ test("cmd+⏎ opens the rail confirm card; message + summary resolve the session", async () => {
195
+ // Arrange
171
196
  const setup = await renderApp();
172
- await press(setup, "j");
173
- await press(setup, "j");
174
- await press(setup, "c");
197
+
198
+ await clickText(setup, "daemon");
175
199
  await type(setup, "Needs a phase list.");
176
- await press(setup, "enter");
177
- await Bun.sleep(80);
178
- await press(setup, "enter"); // open submit
179
- await setup.renderOnce();
180
- expect(setup.captureCharFrame()).toContain("[Request changes]");
200
+ await pressKey(setup, "RETURN", { meta: true });
201
+ await waitForText(setup, "Needs a phase list");
202
+
203
+ // Act: with no composer open the same chord opens submit
204
+ await pressKey(setup, "RETURN", { meta: true });
205
+
206
+ // Assert - the card opens on the default message, approve
207
+ await waitForText(setup, "[Approve]");
208
+
209
+ // Act - cycle to request changes, then send with a summary
210
+ await press(setup, "right");
211
+ await waitForText(setup, "[Changes]");
181
212
  await type(setup, "Expand the steps.");
182
- await press(setup, "enter");
183
- await Bun.sleep(120);
184
- await setup.renderOnce();
213
+ await pressKey(setup, "RETURN", { meta: true });
214
+
215
+ // Assert
216
+ await waitForText(setup, "feedback sent");
185
217
  const stored = server.core.sessionGet(session.id);
218
+
186
219
  expect(stored.status).toBe("resolved");
187
- expect(stored.verdict!.kind).toBe("request_changes");
188
- expect(stored.verdict!.feedback).toContain("Needs a phase list.");
220
+ expect(stored.message!.outcome).toBe("changes_requested");
221
+ expect(stored.message!.body).toContain("Needs a phase list.");
189
222
  // submit hands the reviewer back to the agent via the completion overlay
190
- expect(setup.captureCharFrame()).toContain("✎ feedback sent");
223
+ expect(setup.captureCharFrame()).toContain("feedback sent");
191
224
  });
192
225
 
193
- test("approve via ←/→ verdict cycling", async () => {
226
+ test("approve via ←/→ message cycling", async () => {
227
+ // Arrange
194
228
  const setup = await renderApp();
229
+
230
+ // Act
231
+ await pressKey(setup, "RETURN", { meta: true });
232
+
233
+ // Assert
234
+ await waitForText(setup, "[Approve]"); // no pending items → approve default
235
+
236
+ // Act
237
+ await pressKey(setup, "RETURN", { meta: true });
238
+
239
+ // Assert
240
+ await waitForState(setup, () => server.core.sessionGet(session.id).message !== undefined);
241
+ expect(server.core.sessionGet(session.id).message!.outcome).toBe("approved");
242
+ });
243
+ });
244
+
245
+ describe("no-thread shell", () => {
246
+ test("opening with nothing selected lands in the shell with a Welcome tab and the Threads sidebar", async () => {
247
+ // Arrange
248
+ const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
249
+
250
+ // the Welcome playground measures its width before it paints, so wait for its copy, not the header
251
+ await waitForText(setup, "Getting started");
252
+
253
+ // Assert - the same shell header, the disposable Welcome tab, and the pending thread
254
+ const frame = setup.captureCharFrame();
255
+
256
+ expect(frame).toContain("Getting started");
257
+ expect(frame).toContain("Migration Plan");
258
+ expect(frame).not.toContain("· resume"); // the bespoke inbox header is retired
259
+
260
+ // Act
195
261
  await press(setup, "enter");
196
- await setup.renderOnce();
197
- expect(setup.captureCharFrame()).toContain("[Approve]"); // no pending items → approve default
262
+
263
+ // Assert
264
+ await waitForText(setup, "Send message (");
265
+ });
266
+
267
+ test("picking a thread from the no-thread shell keeps the Threads sidebar open", async () => {
268
+ // Arrange - a second pending thread so the sidebar has a row that never shows
269
+ // in the opened thread's own view
270
+ server.core.sessionCreate({
271
+ workspace: { repoRoot: "/repo", branch: "main" },
272
+ artifact: { type: "plan", content: "# Other Plan\n", meta: { title: "Other Plan" } },
273
+ });
274
+ const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
275
+
276
+ await waitForText(setup, "Getting started");
277
+ await waitForText(setup, "Other Plan"); // the sidebar opens by default here
278
+
279
+ // Act - open the thread under the cursor
198
280
  await press(setup, "enter");
199
- await Bun.sleep(120);
200
- expect(server.core.sessionGet(session.id).verdict!.kind).toBe("approve");
281
+ await waitForText(setup, "Send message (");
282
+
283
+ // Assert - the sidebar stayed open across the swap: both titles are on screen,
284
+ // and the non-opened one can only come from the still-open sidebar
285
+ const frame = setup.captureCharFrame();
286
+
287
+ expect(frame).toContain("Migration Plan");
288
+ expect(frame).toContain("Other Plan");
201
289
  });
202
- });
203
290
 
204
- describe("inbox", () => {
205
- test("inbox mode renders and opens a session", async () => {
206
- const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
207
- for (let i = 0; i < 40 && !setup.captureCharFrame().includes("inbox"); i++) {
208
- await Bun.sleep(25);
209
- await setup.renderOnce();
210
- }
211
- expect(setup.captureCharFrame()).toContain("inbox (1 pending)");
212
- expect(setup.captureCharFrame()).toContain("Migration Plan");
291
+ test("the row kebab menu stars a thread into a Starred section", async () => {
292
+ // Arrange
293
+ const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
294
+
295
+ await waitForText(setup, "Migration Plan");
296
+
297
+ // Act - hover the row to reveal its kebab, open the menu, and pick Star
298
+ const row = locateText(setup, "Migration Plan");
299
+
300
+ await setup.mockMouse.moveTo(row.column, row.row);
301
+ await waitForText(setup, "⋮");
302
+ await clickText(setup, "⋮");
303
+ // "star" alone collides with "Getting started" in the welcome pane, so target the menu via its
304
+ // unique "rename" row - the star action sits directly above it
305
+ await waitForText(setup, "rename");
306
+ const rename = locateText(setup, "rename");
307
+ await setup.mockMouse.click(rename.column, rename.row - 1);
308
+
309
+ // Assert - a Starred section now holds the thread
310
+ await waitForText(setup, "Starred");
311
+ expect(frameRow(setup, "Migration Plan")).toBeGreaterThan(frameRow(setup, "Starred"));
312
+ });
313
+
314
+ test("a bare launch shows the Welcome surface with the right region collapsed", async () => {
315
+ // Arrange
316
+ const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
317
+
318
+ await waitForText(setup, "Getting started");
319
+
320
+ // Assert - Welcome fills the thread pane (not a Changes tab); the right region stays collapsed
321
+ const frame = setup.captureCharFrame();
322
+
323
+ expect(frame).toContain("Getting started");
324
+ expect(frame).not.toContain("Changes"); // the Changes editor is closed on a bare launch
325
+ expect(frame).toContain("Migration Plan"); // the sidebar lists the pending thread
326
+ });
327
+
328
+ test("the menu opens from the shell gear and escape is not a trap", async () => {
329
+ // Arrange
330
+ const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
331
+
332
+ await waitForText(setup, "Getting started");
333
+
334
+ // Act - open the settings dialog from the top-left gear (the Threads panel header, row 0)
335
+ await setup.mockMouse.click(1, 0);
336
+
337
+ // Assert - the settings dialog with its Keybinds leaf appears
338
+ await waitForText(setup, "Keybinds");
339
+ expect(setup.captureCharFrame()).toContain("settings");
340
+
341
+ // Act - escape closes the menu (not a trap), and the thread nav still works
342
+ await press(setup, "escape");
343
+ await waitForState(setup, () => !setup.captureCharFrame().includes("Keybinds"));
213
344
  await press(setup, "enter");
214
- await Bun.sleep(80);
215
- await setup.renderOnce();
216
- expect(setup.captureCharFrame()).toContain("Submit review (0)");
345
+
346
+ // Assert
347
+ await waitForText(setup, "Send message (");
348
+ });
349
+ });
350
+
351
+ describe("the thread view and the menu", () => {
352
+ test("the keybinds dialog lists the thread view grammar, keeps keys away from the text, and closes on escape", async () => {
353
+ // Arrange
354
+ const setup = await renderApp();
355
+
356
+ await waitForText(setup, "The daemon persists");
357
+
358
+ // Act - open the menu from the top-left settings gear, then the keybinds dialog
359
+ await setup.mockMouse.click(1, 0);
360
+ await waitForText(setup, "Keybinds");
361
+ const dropUp = setup.captureCharFrame().split("\n");
362
+ const keybindsRow = dropUp.findIndex((line) => line.includes("Keybinds"));
363
+
364
+ await setup.mockMouse.click(dropUp[keybindsRow]!.indexOf("Keybinds") + 1, keybindsRow);
365
+ await waitForText(setup, "mark text, across blocks");
366
+
367
+ // Assert - the thread grammar, not the plan sheet's
368
+ const dialog = setup.captureCharFrame();
369
+
370
+ expect(dialog).toContain("nav mode");
371
+ expect(dialog).toContain("place the caret");
372
+ expect(dialog).not.toContain("grow/shrink");
373
+
374
+ // Act - a printable behind the dialog must not open a comment; escape closes it
375
+ await press(setup, "x");
376
+ expect(setup.captureCharFrame()).not.toContain("● x");
377
+ await press(setup, "escape");
378
+
379
+ // Assert
380
+ await waitForState(setup, () => !setup.captureCharFrame().includes("mark text, across blocks"));
381
+ expect(setup.captureCharFrame()).not.toContain("● x");
382
+ });
383
+
384
+ test("clicking the Settings nav group is inert and never corrupts the active category", async () => {
385
+ // Arrange - open the settings dialog from the gear
386
+ const setup = await renderApp();
387
+
388
+ await setup.mockMouse.click(1, 0);
389
+ await waitForText(setup, "Keybinds");
390
+
391
+ // the nav folder is the last "settings" on screen (the first is the dialog title)
392
+ const lines = setup.captureCharFrame().split("\n");
393
+ let folderRow = -1;
394
+ let folderColumn = -1;
395
+
396
+ lines.forEach((line, row) => {
397
+ const column = line.indexOf("settings");
398
+
399
+ if (column !== -1) {
400
+ folderColumn = column;
401
+ folderRow = row;
402
+ }
403
+ });
404
+
405
+ // Act - click the synthetic Settings group folder, then press a nav key. The
406
+ // key handler dereferences the active category, so a corrupt id would throw
407
+ // (the global key handler swallows the throw into console.error).
408
+ const keyHandlerErrors: string[] = [];
409
+ const originalConsoleError = console.error;
410
+
411
+ console.error = (...args: unknown[]) => keyHandlerErrors.push(args.map(String).join(" "));
412
+ await setup.mockMouse.click(folderColumn + 1, folderRow);
413
+ await pressKey(setup, "ARROW_DOWN");
414
+ console.error = originalConsoleError;
415
+
416
+ // Assert - no key-handler crash, and the dialog is still up
417
+ expect(keyHandlerErrors.filter((line) => line.includes("keypress handler"))).toEqual([]);
418
+ expect(setup.captureCharFrame()).toContain("Keybinds");
217
419
  });
218
420
  });
@@ -0,0 +1,98 @@
1
+ /** The share toast is non-modal: while it is up, escape still cancels an open overlay. */
2
+
3
+ import { afterEach, beforeEach, describe, mock, 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 { testRender } from "@opentui/react/test-utils";
9
+ import { DaemonServer } from "@cueloop/daemon";
10
+ import type { Thread } from "@cueloop/schema";
11
+ import { App } from "./App";
12
+ import type { ShareTransport } from "./thread-controller";
13
+ import {
14
+ clickText,
15
+ isolateUserConfig,
16
+ press,
17
+ pressKey,
18
+ typeText,
19
+ waitForText,
20
+ waitForTextGone,
21
+ } from "./test-support";
22
+
23
+ const publishShare = mock(async () => ({ line: "ssh p_share01@cueloop.dev", copied: true }));
24
+ const shareTransport: ShareTransport = {
25
+ publish: publishShare,
26
+ pull: mock(async () => {
27
+ throw new Error("Unexpected share pull");
28
+ }),
29
+ push: mock(async () => {}),
30
+ watch: () => () => {},
31
+ revoke: async () => {},
32
+ parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
33
+ formatShareLine: (id: string) => "ssh " + id + "@cueloop.dev",
34
+ collaboratorAnnotations: () => [],
35
+ mergeFromShare: () => ({ annotations: [] }),
36
+ };
37
+
38
+ const PLAN = `# Migration Plan\n\n## Context\n\nThe daemon persists sessions to disk atomically.\n`;
39
+
40
+ let home: string;
41
+ let restoreUserConfig: () => void;
42
+ let server: DaemonServer;
43
+ let session: Thread;
44
+
45
+ beforeEach(() => {
46
+ home = mkdtempSync(join(tmpdir(), "cueloop-toast-"));
47
+ restoreUserConfig = isolateUserConfig(home);
48
+ server = new DaemonServer({ home, idleExitMs: 0 });
49
+ server.start();
50
+ session = server.core.sessionCreate({
51
+ workspace: { repoRoot: "/repo", branch: "main" },
52
+ artifact: {
53
+ type: "plan",
54
+ content: PLAN,
55
+ meta: { title: "Migration Plan", planPath: "plan.md" },
56
+ },
57
+ });
58
+ });
59
+ afterEach(() => {
60
+ restoreUserConfig();
61
+ server.stop();
62
+ rmSync(home, { recursive: true, force: true });
63
+ });
64
+
65
+ describe("share toast", () => {
66
+ test("escape cancels an open composer even while the toast is up", async () => {
67
+ // Arrange
68
+ const setup = await testRender(
69
+ <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
70
+ {
71
+ width: 120,
72
+ height: 32,
73
+ },
74
+ );
75
+
76
+ await waitForText(setup, "cueloop");
77
+
78
+ // Act: share opens the dialog, the new-link wizard publishes and raises the toast, then a composer under it
79
+ await pressKey(setup, "s", { ctrl: true });
80
+ await waitForText(setup, "+ new link");
81
+ await press(setup, "enter"); // step into the links body
82
+ await press(setup, "enter"); // activate "+ new link" - the wizard opens
83
+ await waitForText(setup, "link name");
84
+ await press(setup, "enter"); // the name field submits and creates the link
85
+ await waitForText(setup, "link copied");
86
+ await press(setup, "escape"); // close the dialog; the non-modal toast stays up
87
+ await waitForTextGone(setup, "share externally");
88
+ await clickText(setup, "daemon");
89
+ await typeText(setup, "x");
90
+ await waitForText(setup, "● x");
91
+
92
+ // Act: escape must reach the composer, not get eaten by the toast
93
+ await press(setup, "escape");
94
+
95
+ // Assert: the composer closed on the first escape
96
+ await waitForTextGone(setup, "● x");
97
+ });
98
+ });