@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +39 -32
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +76 -50
  10. package/src/App.diff.test.tsx +156 -42
  11. package/src/App.export.test.tsx +58 -40
  12. package/src/App.plan-review.test.tsx +363 -0
  13. package/src/App.prototype-markdown.test.tsx +92 -0
  14. package/src/App.prototype.test.tsx +176 -0
  15. package/src/App.readonly.test.tsx +128 -67
  16. package/src/App.rendering.test.tsx +61 -35
  17. package/src/App.submit-confirm.test.tsx +191 -0
  18. package/src/App.test.tsx +310 -114
  19. package/src/App.toast.test.tsx +89 -0
  20. package/src/App.tsx +1039 -799
  21. package/src/App.walk.test.tsx +298 -0
  22. package/src/App.workbench.test.tsx +461 -0
  23. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  24. package/src/annotation-palette.ts +61 -0
  25. package/src/app-key-state.ts +61 -0
  26. package/src/app-screens.tsx +432 -0
  27. package/src/app-view-model.ts +159 -0
  28. package/src/attribution.test.ts +31 -0
  29. package/src/attribution.ts +42 -0
  30. package/src/clipboard.test.ts +23 -0
  31. package/src/clipboard.ts +33 -0
  32. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  33. package/src/components/AnnotatableFileView.test.tsx +102 -0
  34. package/src/components/AnnotatableFileView.tsx +126 -0
  35. package/src/components/AnnotationCards.stories.tsx +87 -0
  36. package/src/components/AnnotationCards.tsx +303 -0
  37. package/src/components/AppHeader.stories.tsx +50 -0
  38. package/src/components/AppHeader.tsx +83 -0
  39. package/src/components/AppShell.stories.tsx +89 -0
  40. package/src/components/AppShell.tsx +286 -0
  41. package/src/components/AppShell.zoom.test.tsx +94 -0
  42. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  43. package/src/components/BareWorkbenchFileView.tsx +64 -0
  44. package/src/components/Breadcrumb.stories.tsx +19 -0
  45. package/src/components/Breadcrumb.tsx +45 -0
  46. package/src/components/ChangesColumn.stories.tsx +39 -0
  47. package/src/components/ChangesColumn.tsx +171 -0
  48. package/src/components/CodeBlock.stories.tsx +42 -0
  49. package/src/components/CodeBlock.tsx +84 -0
  50. package/src/components/CompletionOverlay.stories.tsx +23 -0
  51. package/src/components/CompletionOverlay.tsx +60 -0
  52. package/src/components/ConfirmCard.stories.tsx +35 -0
  53. package/src/components/ConfirmCard.tsx +128 -0
  54. package/src/components/ConfirmDialog.stories.tsx +21 -0
  55. package/src/components/ConfirmDialog.tsx +66 -0
  56. package/src/components/DiffContentView.scroll.test.tsx +121 -0
  57. package/src/components/DiffContentView.split.test.tsx +140 -0
  58. package/src/components/DiffContentView.stories.tsx +80 -0
  59. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  60. package/src/components/DiffContentView.tsx +859 -0
  61. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  62. package/src/components/DiscussionMarkerRail.tsx +181 -0
  63. package/src/components/EditorGrid.stories.tsx +59 -0
  64. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  65. package/src/components/EditorGrid.tsx +368 -0
  66. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  67. package/src/components/FileContentsView.stories.tsx +16 -0
  68. package/src/components/FileContentsView.test.tsx +29 -0
  69. package/src/components/FileContentsView.tsx +98 -0
  70. package/src/components/KeybindsDialog.stories.tsx +31 -0
  71. package/src/components/KeybindsDialog.tsx +103 -0
  72. package/src/components/MenuBar.stories.tsx +28 -0
  73. package/src/components/MenuBar.tsx +44 -0
  74. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  75. package/src/components/OverlayScrollbar.test.tsx +139 -0
  76. package/src/components/OverlayScrollbar.tsx +129 -0
  77. package/src/components/PaneGrid.stories.tsx +84 -0
  78. package/src/components/PaneGrid.tsx +128 -0
  79. package/src/components/PanelColumn.stories.tsx +25 -0
  80. package/src/components/PanelColumn.tsx +95 -0
  81. package/src/components/ProjectTreeView.stories.tsx +25 -0
  82. package/src/components/ProjectTreeView.test.tsx +32 -0
  83. package/src/components/ProjectTreeView.tsx +83 -0
  84. package/src/components/PromptDialog.stories.tsx +36 -0
  85. package/src/components/PromptDialog.tsx +87 -0
  86. package/src/components/PrototypeContentView.stories.tsx +19 -0
  87. package/src/components/PrototypeContentView.tsx +446 -0
  88. package/src/components/RootOverlay.stories.tsx +44 -0
  89. package/src/components/RootOverlay.tsx +35 -0
  90. package/src/components/ScrollArea.stories.tsx +20 -0
  91. package/src/components/ScrollArea.tsx +44 -0
  92. package/src/components/SettingsDialog.stories.tsx +117 -0
  93. package/src/components/SettingsDialog.tsx +195 -0
  94. package/src/components/SettingsRows.stories.tsx +18 -0
  95. package/src/components/SettingsRows.tsx +112 -0
  96. package/src/components/ShellHeader.stories.tsx +50 -0
  97. package/src/components/ShellHeader.tsx +137 -0
  98. package/src/components/ThreadFooter.stories.tsx +30 -0
  99. package/src/components/ThreadFooter.test.tsx +31 -0
  100. package/src/components/ThreadFooter.tsx +54 -0
  101. package/src/components/ThreadTree.stories.tsx +46 -0
  102. package/src/components/ThreadTree.tsx +219 -0
  103. package/src/components/ThreadView.selection.test.tsx +812 -0
  104. package/src/components/ThreadView.slash.test.tsx +158 -0
  105. package/src/components/ThreadView.stories.tsx +26 -0
  106. package/src/components/ThreadView.tsx +330 -0
  107. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  108. package/src/components/ThreadsSidebar.tsx +71 -0
  109. package/src/components/Toast.stories.tsx +12 -0
  110. package/src/components/Toast.tsx +45 -0
  111. package/src/components/Tooltip.stories.tsx +26 -0
  112. package/src/components/Tooltip.tsx +72 -0
  113. package/src/components/TreePane.stories.tsx +106 -0
  114. package/src/components/TreePane.tsx +157 -0
  115. package/src/components/WalkWizard.stories.tsx +100 -0
  116. package/src/components/WalkWizard.tsx +152 -0
  117. package/src/components/WelcomePlayground.stories.tsx +12 -0
  118. package/src/components/WelcomePlayground.test.tsx +47 -0
  119. package/src/components/WelcomePlayground.tsx +140 -0
  120. package/src/components/__snapshots__/stories.test.tsx.snap +1845 -0
  121. package/src/components/agent-launcher.stories.tsx +26 -0
  122. package/src/components/agent-launcher.test.tsx +50 -0
  123. package/src/components/agent-launcher.tsx +149 -0
  124. package/src/components/diff-content-view-layout.test.ts +198 -0
  125. package/src/components/diff-content-view-layout.ts +84 -0
  126. package/src/components/editor-grid.test.ts +86 -0
  127. package/src/components/editor-grid.ts +138 -0
  128. package/src/components/fade-title.test.ts +49 -0
  129. package/src/components/fade-title.ts +61 -0
  130. package/src/components/file-tree.test.ts +60 -0
  131. package/src/components/file-tree.ts +112 -0
  132. package/src/components/pages/Workspace.stories.tsx +181 -0
  133. package/src/components/primitives/Button.stories.tsx +32 -0
  134. package/src/components/primitives/Button.tsx +52 -0
  135. package/src/components/primitives/Card.stories.tsx +25 -0
  136. package/src/components/primitives/Card.tsx +60 -0
  137. package/src/components/primitives/Dialog.stories.tsx +24 -0
  138. package/src/components/primitives/Dialog.tsx +66 -0
  139. package/src/components/primitives/IconButton.stories.tsx +36 -0
  140. package/src/components/primitives/IconButton.tsx +62 -0
  141. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  142. package/src/components/primitives/StatusBar.tsx +23 -0
  143. package/src/components/primitives/Tabs.stories.tsx +29 -0
  144. package/src/components/primitives/Tabs.tsx +102 -0
  145. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  146. package/src/components/primitives/Toolbar.tsx +14 -0
  147. package/src/components/primitives/Tree.stories.tsx +124 -0
  148. package/src/components/primitives/Tree.tsx +96 -0
  149. package/src/components/primitives/frame.test.ts +12 -0
  150. package/src/components/primitives/frame.ts +14 -0
  151. package/src/components/primitives/icons.ts +61 -0
  152. package/src/components/primitives/tree-model.test.ts +99 -0
  153. package/src/components/primitives/tree-model.ts +137 -0
  154. package/src/components/quick-actions-editor.stories.tsx +54 -0
  155. package/src/components/quick-actions-editor.test.tsx +49 -0
  156. package/src/components/quick-actions-editor.tsx +150 -0
  157. package/src/components/session-tree.test.ts +134 -0
  158. package/src/components/session-tree.ts +108 -0
  159. package/src/components/stories-app.tsx +184 -0
  160. package/src/components/stories.test.tsx +66 -0
  161. package/src/components/story-fixtures.ts +102 -0
  162. package/src/components/story-tree.test.ts +100 -0
  163. package/src/components/story.ts +142 -0
  164. package/src/components/syntax-highlight.ts +110 -0
  165. package/src/components/terminal-pane.test.tsx +40 -0
  166. package/src/components/terminal-pane.ts +199 -0
  167. package/src/components/theme-context.stories.tsx +46 -0
  168. package/src/components/theme-context.tsx +37 -0
  169. package/src/components/truncate-text.ts +7 -0
  170. package/src/config.test.ts +461 -31
  171. package/src/config.ts +389 -57
  172. package/src/diff-fold.test.ts +78 -0
  173. package/src/diff-fold.ts +126 -0
  174. package/src/diff-hunk-curate.test.ts +315 -0
  175. package/src/diff-hunk-curate.ts +153 -0
  176. package/src/diff-intraline.test.ts +165 -0
  177. package/src/diff-intraline.ts +146 -0
  178. package/src/diff-syntax.test.ts +112 -0
  179. package/src/diff-syntax.ts +179 -0
  180. package/src/discussions.ts +79 -0
  181. package/src/editor.test.ts +149 -0
  182. package/src/editor.ts +139 -9
  183. package/src/ghostty-terminal.test.ts +85 -0
  184. package/src/ghostty-terminal.ts +199 -0
  185. package/src/herdr.test.ts +9 -34
  186. package/src/herdr.ts +25 -21
  187. package/src/index.ts +11 -0
  188. package/src/integrations.test.ts +41 -0
  189. package/src/integrations.ts +32 -0
  190. package/src/intent-dispatch.test.ts +698 -0
  191. package/src/intent-dispatch.ts +700 -0
  192. package/src/key-bindings.test.ts +156 -0
  193. package/src/key-bindings.ts +465 -0
  194. package/src/keymap.test.ts +611 -0
  195. package/src/keymap.ts +410 -0
  196. package/src/kitty-image.test.ts +66 -0
  197. package/src/kitty-image.ts +138 -0
  198. package/src/mark-runs.ts +120 -0
  199. package/src/prototype-browser.test.ts +56 -0
  200. package/src/prototype-browser.ts +307 -0
  201. package/src/prototype-pixels.tsx +35 -0
  202. package/src/pty.test.ts +81 -0
  203. package/src/pty.ts +245 -0
  204. package/src/ready-signal.test.ts +85 -0
  205. package/src/ready-signal.ts +52 -0
  206. package/src/run.ts +30 -9
  207. package/src/serve.test.ts +24 -12
  208. package/src/serve.ts +20 -9
  209. package/src/share.ts +215 -0
  210. package/src/skills.test.ts +74 -0
  211. package/src/skills.ts +68 -0
  212. package/src/slash-palette.test.ts +54 -0
  213. package/src/slash-palette.ts +104 -0
  214. package/src/split-diff.test.ts +110 -0
  215. package/src/split-diff.ts +149 -0
  216. package/src/test-support.test.ts +264 -0
  217. package/src/test-support.ts +355 -0
  218. package/src/theme-presets.test.ts +51 -0
  219. package/src/theme-presets.ts +214 -0
  220. package/src/theme.ts +84 -19
  221. package/src/thread-chords.test.ts +170 -0
  222. package/src/thread-chords.ts +169 -0
  223. package/src/thread-controller.curate.test.ts +438 -0
  224. package/src/thread-controller.live-diff.test.ts +199 -0
  225. package/src/thread-controller.share.test.ts +472 -0
  226. package/src/thread-controller.tree.test.ts +255 -0
  227. package/src/thread-controller.ts +1509 -0
  228. package/src/thread-selection.test.ts +210 -0
  229. package/src/thread-selection.ts +243 -0
  230. package/src/tree-view.test.ts +128 -0
  231. package/src/tree-view.ts +148 -0
  232. package/src/use-annotation-surface.tsx +851 -0
  233. package/src/use-changes-workbench.ts +151 -0
  234. package/src/use-frame-measure.ts +38 -0
  235. package/src/use-settings-dialog.tsx +274 -0
  236. package/src/use-terminal-virtualizer.ts +233 -0
  237. package/src/version.ts +5 -0
  238. package/src/view-diff.test.ts +66 -9
  239. package/src/view-diff.ts +231 -23
  240. package/src/view-plan.regression.test.ts +60 -0
  241. package/src/view-plan.test.ts +406 -0
  242. package/src/view-plan.ts +570 -0
  243. package/src/walk.test.ts +121 -0
  244. package/src/walk.ts +74 -0
  245. package/src/syntax.test.ts +0 -41
  246. package/src/syntax.ts +0 -88
  247. package/src/view.test.ts +0 -157
  248. package/src/view.ts +0 -312
package/src/App.test.tsx CHANGED
@@ -1,18 +1,26 @@
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
+ pressKey,
19
+ renderReadyApp,
20
+ typeText as type,
21
+ waitForState,
22
+ waitForText,
23
+ } from "./test-support";
16
24
 
17
25
  const PLAN = `# Migration Plan
18
26
 
@@ -27,139 +35,154 @@ The daemon persists sessions to disk atomically.
27
35
  `;
28
36
 
29
37
  let home: string;
38
+ let restoreUserConfig: () => void;
30
39
  let server: DaemonServer;
31
- let session: ReviewSession;
40
+ let session: Thread;
32
41
 
33
42
  beforeEach(() => {
34
43
  home = mkdtempSync(join(tmpdir(), "cueloop-app-"));
44
+ restoreUserConfig = isolateUserConfig(home);
35
45
  server = new DaemonServer({ home, idleExitMs: 0 });
36
46
  server.start();
37
47
  session = server.core.sessionCreate({
38
48
  workspace: { repoRoot: "/repo", branch: "main" },
39
- artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
49
+ artifact: {
50
+ type: "plan",
51
+ content: PLAN,
52
+ meta: { title: "Migration Plan", planPath: "plan.md" },
53
+ },
40
54
  });
41
55
  });
42
56
  afterEach(() => {
57
+ restoreUserConfig();
43
58
  server.stop();
44
59
  rmSync(home, { recursive: true, force: true });
45
60
  });
46
61
 
47
62
  async function renderApp(sessionId?: string) {
48
- const setup = await testRender(<App home={home} sessionId={sessionId ?? session.id} />, {
63
+ return renderReadyApp(<App home={home} sessionId={sessionId ?? session.id} />, {
49
64
  width: 120,
50
65
  height: 32,
51
66
  });
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
67
  }
80
68
 
81
69
  describe("plan rendering", () => {
82
- test("renders the plan with headings, list markers, and the rail", async () => {
70
+ test("renders the plan with headings, list markers, and the footer", async () => {
71
+ // Arrange
83
72
  const setup = await renderApp();
73
+
74
+ // Assert
84
75
  const frame = setup.captureCharFrame();
76
+
85
77
  expect(frame).toContain("Migration Plan");
86
78
  expect(frame).toContain("Context");
87
79
  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)");
80
+ expect(frame).toContain("· move the store");
81
+ expect(frame).toContain("send message");
82
+ });
83
+
84
+ test("a direct open still populates the Threads sidebar with other pending reviews", async () => {
85
+ // Arrange - a second pending review exists alongside the one opened directly
86
+ server.core.sessionCreate({
87
+ workspace: { repoRoot: "/repo", branch: "main" },
88
+ artifact: { type: "plan", content: "# Other Plan\n", meta: { title: "Other Plan" } },
89
+ });
90
+ const setup = await renderApp();
91
+
92
+ // Act - open the collapsed Threads sidebar (the mirrored panel toggle by the gear)
93
+ await setup.mockMouse.click(4, 0);
94
+
95
+ // Assert - the sidebar lists the other pending review, so you can jump to it
96
+ await waitForText(setup, "Other Plan");
91
97
  });
92
98
  });
93
99
 
94
- describe("keyboard grammar", () => {
95
- test("j/k moves the cursor glyph between blocks", async () => {
100
+ describe("thread view grammar", () => {
101
+ test("↓ moves the caret between blocks and typing anchors to the block under it", async () => {
102
+ // Arrange
96
103
  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");
104
+
105
+ // Act: two blocks down lands on the paragraph; a printable opens a draft there
106
+ await pressKey(setup, "ARROW_DOWN");
107
+ await pressKey(setup, "ARROW_DOWN");
108
+ await type(setup, "x");
109
+
110
+ // Assert: the draft card sits right under the paragraph's row
111
+ await waitForText(setup, "● x");
112
+ expect(frameRow(setup, "● x")).toBeGreaterThan(frameRow(setup, "persists sessions"));
113
+ expect(frameRow(setup, "● x")).toBeLessThan(frameRow(setup, "Steps"));
103
114
  });
104
115
 
105
- test("comment flow: c types a body, ⏎ saves to the daemon", async () => {
116
+ test("comment flow: click a word, type a body, cmd+⏎ saves to the daemon", async () => {
117
+ // Arrange
106
118
  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");
119
+
120
+ // Act
121
+ await clickText(setup, "daemon");
112
122
  await type(setup, "Define atomically.");
113
- await press(setup, "enter");
114
- await Bun.sleep(80);
115
- await setup.renderOnce();
123
+ await pressKey(setup, "RETURN", { meta: true });
124
+
125
+ // Assert
126
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
116
127
  const stored = server.core.sessionGet(session.id);
128
+
117
129
  expect(stored.annotations.length).toBe(1);
118
130
  expect(stored.annotations[0]!.body).toBe("Define atomically.");
119
- expect(setup.captureCharFrame()).toContain("COMMENT");
131
+ expect(stored.annotations[0]!.anchor.quote).toBe("daemon");
120
132
  });
121
133
 
122
- test("span mode: v + l selects words, c anchors the exact span", async () => {
134
+ test("a drag marks the exact span the comment anchors to", async () => {
135
+ // Arrange
123
136
  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");
137
+
138
+ // Act: from the start of "The" to the end of "daemon"
139
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
129
140
  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");
141
+ await pressKey(setup, "RETURN", { meta: true });
142
+
143
+ // Assert
144
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
145
+ expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe("The daemon");
134
146
  });
135
147
 
136
- test("x cuts a block into the working copy; x restores it", async () => {
148
+ test("option+x cuts the caret's block into the working copy; option+x restores it", async () => {
149
+ // Arrange
137
150
  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();
151
+
152
+ await clickText(setup, "move the store");
153
+
154
+ // Act
155
+ await pressKey(setup, "x", { meta: true });
156
+
157
+ // Assert - the cut lands in the working copy
158
+ await waitForState(
159
+ setup,
160
+ () => !(server.core.sessionGet(session.id).workingCopy ?? "").includes("move the store"),
161
+ );
162
+
163
+ // Act
164
+ await pressKey(setup, "x", { meta: true });
165
+
166
+ // Assert
167
+ await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
149
168
  });
150
169
 
151
- test("e runs $EDITOR on the working copy and tracks the diff", async () => {
170
+ test("ctrl+e runs $EDITOR on the working copy and tracks the diff", async () => {
152
171
  const script = join(home, "fake-editor.sh");
172
+
153
173
  await Bun.write(script, `#!/bin/sh\nsed -i '' 's/atomically/very atomically/' "$1"\n`);
154
174
  Bun.spawnSync(["chmod", "+x", script]);
155
175
  process.env.CUELOOP_EDITOR = script;
156
176
  try {
177
+ // Arrange
157
178
  const setup = await renderApp();
158
- await press(setup, "e");
159
- await Bun.sleep(150);
160
- await setup.renderOnce();
179
+
180
+ // Act
181
+ await pressKey(setup, "e", { ctrl: true });
182
+
183
+ // Assert
184
+ await waitForText(setup, "[edited]");
161
185
  expect(server.core.sessionGet(session.id).workingCopy).toContain("very atomically");
162
- expect(setup.captureCharFrame()).toContain("[edited]");
163
186
  } finally {
164
187
  delete process.env.CUELOOP_EDITOR;
165
188
  }
@@ -167,52 +190,225 @@ describe("keyboard grammar", () => {
167
190
  });
168
191
 
169
192
  describe("submit", () => {
170
- test("⏎ opens the verdict bar; verdict + summary resolve the session", async () => {
193
+ test("cmd+⏎ opens the rail confirm card; verdict + summary resolve the session", async () => {
194
+ // Arrange
171
195
  const setup = await renderApp();
172
- await press(setup, "j");
173
- await press(setup, "j");
174
- await press(setup, "c");
196
+
197
+ await clickText(setup, "daemon");
175
198
  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]");
199
+ await pressKey(setup, "RETURN", { meta: true });
200
+ await waitForText(setup, "Needs a phase list");
201
+
202
+ // Act: with no composer open the same chord opens submit
203
+ await pressKey(setup, "RETURN", { meta: true });
204
+
205
+ // Assert
206
+ await waitForText(setup, "[Changes]");
207
+
208
+ // Act
181
209
  await type(setup, "Expand the steps.");
182
210
  await press(setup, "enter");
183
- await Bun.sleep(120);
184
- await setup.renderOnce();
211
+
212
+ // Assert
213
+ await waitForText(setup, "feedback sent");
185
214
  const stored = server.core.sessionGet(session.id);
215
+
186
216
  expect(stored.status).toBe("resolved");
187
217
  expect(stored.verdict!.kind).toBe("request_changes");
188
218
  expect(stored.verdict!.feedback).toContain("Needs a phase list.");
189
219
  // submit hands the reviewer back to the agent via the completion overlay
190
- expect(setup.captureCharFrame()).toContain("✎ feedback sent");
220
+ expect(setup.captureCharFrame()).toContain("feedback sent");
191
221
  });
192
222
 
193
223
  test("approve via ←/→ verdict cycling", async () => {
224
+ // Arrange
194
225
  const setup = await renderApp();
226
+
227
+ // Act
228
+ await pressKey(setup, "RETURN", { meta: true });
229
+
230
+ // Assert
231
+ await waitForText(setup, "[Approve]"); // no pending items → approve default
232
+
233
+ // Act
195
234
  await press(setup, "enter");
196
- await setup.renderOnce();
197
- expect(setup.captureCharFrame()).toContain("[Approve]"); // no pending items → approve default
198
- await press(setup, "enter");
199
- await Bun.sleep(120);
235
+
236
+ // Assert
237
+ await waitForState(setup, () => server.core.sessionGet(session.id).verdict !== undefined);
200
238
  expect(server.core.sessionGet(session.id).verdict!.kind).toBe("approve");
201
239
  });
202
240
  });
203
241
 
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");
242
+ describe("no-thread shell", () => {
243
+ test("opening with nothing selected lands in the shell with a Welcome tab and the Threads sidebar", async () => {
244
+ // Arrange
245
+ const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
246
+
247
+ // the Welcome playground measures its width before it paints, so wait for its copy, not the header
248
+ await waitForText(setup, "Getting started");
249
+
250
+ // Assert - the same shell header, the disposable Welcome tab, and the pending thread
251
+ const frame = setup.captureCharFrame();
252
+
253
+ expect(frame).toContain("Getting started");
254
+ expect(frame).toContain("Migration Plan");
255
+ expect(frame).not.toContain("· resume"); // the bespoke inbox header is retired
256
+
257
+ // Act
258
+ await press(setup, "enter");
259
+
260
+ // Assert
261
+ await waitForText(setup, "send message");
262
+ });
263
+
264
+ test("picking a thread from the no-thread shell keeps the Threads sidebar open", async () => {
265
+ // Arrange - a second pending thread so the sidebar has a row that never shows
266
+ // in the opened thread's own view
267
+ server.core.sessionCreate({
268
+ workspace: { repoRoot: "/repo", branch: "main" },
269
+ artifact: { type: "plan", content: "# Other Plan\n", meta: { title: "Other Plan" } },
270
+ });
271
+ const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
272
+
273
+ await waitForText(setup, "Getting started");
274
+ await waitForText(setup, "Other Plan"); // the sidebar opens by default here
275
+
276
+ // Act - open the thread under the cursor
277
+ await press(setup, "enter");
278
+ await waitForText(setup, "send message");
279
+
280
+ // Assert - the sidebar stayed open across the swap: both titles are on screen,
281
+ // and the non-opened one can only come from the still-open sidebar
282
+ const frame = setup.captureCharFrame();
283
+
284
+ expect(frame).toContain("Migration Plan");
285
+ expect(frame).toContain("Other Plan");
286
+ });
287
+
288
+ test("the row kebab menu pins a thread into a Pinned section", async () => {
289
+ // Arrange
290
+ const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
291
+
292
+ await waitForText(setup, "Migration Plan");
293
+
294
+ // Act - hover the row to reveal its kebab, open the menu, and pick Pin
295
+ const row = locateText(setup, "Migration Plan");
296
+
297
+ await setup.mockMouse.moveTo(row.column, row.row);
298
+ await waitForText(setup, "⋮");
299
+ await clickText(setup, "⋮");
300
+ await waitForText(setup, "Pin");
301
+ await clickText(setup, "Pin");
302
+
303
+ // Assert - a Pinned section now holds the thread
304
+ await waitForText(setup, "Pinned");
305
+ expect(frameRow(setup, "Migration Plan")).toBeGreaterThan(frameRow(setup, "Pinned"));
306
+ });
307
+
308
+ test("a bare launch shows the Welcome surface with the right region collapsed", async () => {
309
+ // Arrange
310
+ const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
311
+
312
+ await waitForText(setup, "Getting started");
313
+
314
+ // Assert - Welcome fills the thread pane (not a Changes tab); the right region stays collapsed
315
+ const frame = setup.captureCharFrame();
316
+
317
+ expect(frame).toContain("Getting started");
318
+ expect(frame).not.toContain("Changes"); // the Changes editor is closed on a bare launch
319
+ expect(frame).toContain("Migration Plan"); // the sidebar lists the pending thread
320
+ });
321
+
322
+ test("the menu opens from the shell gear and escape is not a trap", async () => {
323
+ // Arrange
324
+ const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
325
+
326
+ await waitForText(setup, "Getting started");
327
+
328
+ // Act - open the settings dialog from the top-left gear (the Threads panel header, row 0)
329
+ await setup.mockMouse.click(1, 0);
330
+
331
+ // Assert - the settings dialog with its Keybinds leaf appears
332
+ await waitForText(setup, "Keybinds");
333
+ expect(setup.captureCharFrame()).toContain("Settings");
334
+
335
+ // Act - escape closes the menu (not a trap), and the thread nav still works
336
+ await press(setup, "escape");
337
+ await waitForState(setup, () => !setup.captureCharFrame().includes("Keybinds"));
213
338
  await press(setup, "enter");
214
- await Bun.sleep(80);
215
- await setup.renderOnce();
216
- expect(setup.captureCharFrame()).toContain("Submit review (0)");
339
+
340
+ // Assert
341
+ await waitForText(setup, "send message");
342
+ });
343
+ });
344
+
345
+ describe("the thread view and the menu", () => {
346
+ test("the keybinds dialog lists the thread view grammar, keeps keys away from the text, and closes on escape", async () => {
347
+ // Arrange
348
+ const setup = await renderApp();
349
+
350
+ await waitForText(setup, "The daemon persists");
351
+
352
+ // Act - open the menu from the top-left settings gear, then the keybinds dialog
353
+ await setup.mockMouse.click(1, 0);
354
+ await waitForText(setup, "Keybinds");
355
+ const dropUp = setup.captureCharFrame().split("\n");
356
+ const keybindsRow = dropUp.findIndex((line) => line.includes("Keybinds"));
357
+
358
+ await setup.mockMouse.click(dropUp[keybindsRow]!.indexOf("Keybinds") + 1, keybindsRow);
359
+ await waitForText(setup, "mark text, across blocks");
360
+
361
+ // Assert - the thread grammar, not the plan sheet's
362
+ const dialog = setup.captureCharFrame();
363
+
364
+ expect(dialog).toContain("⌘⌥m");
365
+ expect(dialog).toContain("place the caret");
366
+ expect(dialog).not.toContain("grow/shrink");
367
+
368
+ // Act - a printable behind the dialog must not open a comment; escape closes it
369
+ await press(setup, "x");
370
+ expect(setup.captureCharFrame()).not.toContain("● x");
371
+ await press(setup, "escape");
372
+
373
+ // Assert
374
+ await waitForState(setup, () => !setup.captureCharFrame().includes("mark text, across blocks"));
375
+ expect(setup.captureCharFrame()).not.toContain("● x");
376
+ });
377
+
378
+ test("clicking the Settings nav group is inert and never corrupts the active category", async () => {
379
+ // Arrange - open the settings dialog from the gear
380
+ const setup = await renderApp();
381
+
382
+ await setup.mockMouse.click(1, 0);
383
+ await waitForText(setup, "Keybinds");
384
+
385
+ // the nav folder is the last "Settings" on screen (the first is the dialog title)
386
+ const lines = setup.captureCharFrame().split("\n");
387
+ let folderRow = -1;
388
+ let folderColumn = -1;
389
+
390
+ lines.forEach((line, row) => {
391
+ const column = line.indexOf("Settings");
392
+
393
+ if (column !== -1) {
394
+ folderColumn = column;
395
+ folderRow = row;
396
+ }
397
+ });
398
+
399
+ // Act - click the synthetic Settings group folder, then press a nav key. The
400
+ // key handler dereferences the active category, so a corrupt id would throw
401
+ // (the global key handler swallows the throw into console.error).
402
+ const keyHandlerErrors: string[] = [];
403
+ const originalConsoleError = console.error;
404
+
405
+ console.error = (...args: unknown[]) => keyHandlerErrors.push(args.map(String).join(" "));
406
+ await setup.mockMouse.click(folderColumn + 1, folderRow);
407
+ await pressKey(setup, "ARROW_DOWN");
408
+ console.error = originalConsoleError;
409
+
410
+ // Assert - no key-handler crash, and the dialog is still up
411
+ expect(keyHandlerErrors.filter((line) => line.includes("keypress handler"))).toEqual([]);
412
+ expect(setup.captureCharFrame()).toContain("Keybinds");
217
413
  });
218
414
  });
@@ -0,0 +1,89 @@
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
+ parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
32
+ collaboratorAnnotations: () => [],
33
+ mergeFromShare: () => ({ annotations: [] }),
34
+ };
35
+
36
+ const PLAN = `# Migration Plan\n\n## Context\n\nThe daemon persists sessions to disk atomically.\n`;
37
+
38
+ let home: string;
39
+ let restoreUserConfig: () => void;
40
+ let server: DaemonServer;
41
+ let session: Thread;
42
+
43
+ beforeEach(() => {
44
+ home = mkdtempSync(join(tmpdir(), "cueloop-toast-"));
45
+ restoreUserConfig = isolateUserConfig(home);
46
+ server = new DaemonServer({ home, idleExitMs: 0 });
47
+ server.start();
48
+ session = server.core.sessionCreate({
49
+ workspace: { repoRoot: "/repo", branch: "main" },
50
+ artifact: {
51
+ type: "plan",
52
+ content: PLAN,
53
+ meta: { title: "Migration Plan", planPath: "plan.md" },
54
+ },
55
+ });
56
+ });
57
+ afterEach(() => {
58
+ restoreUserConfig();
59
+ server.stop();
60
+ rmSync(home, { recursive: true, force: true });
61
+ });
62
+
63
+ describe("share toast", () => {
64
+ test("escape cancels an open composer even while the toast is up", async () => {
65
+ // Arrange
66
+ const setup = await testRender(
67
+ <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
68
+ {
69
+ width: 120,
70
+ height: 32,
71
+ },
72
+ );
73
+
74
+ await waitForText(setup, "cueloop");
75
+
76
+ // Act: share raises the toast, then open a composer under it by typing
77
+ await pressKey(setup, "s", { ctrl: true });
78
+ await waitForText(setup, "share link copied");
79
+ await clickText(setup, "daemon");
80
+ await typeText(setup, "x");
81
+ await waitForText(setup, "● x");
82
+
83
+ // Act: escape must reach the composer, not get eaten by the toast
84
+ await press(setup, "escape");
85
+
86
+ // Assert: the composer closed on the first escape
87
+ await waitForTextGone(setup, "● x");
88
+ });
89
+ });