@cueloop/client 0.1.0-alpha.9 → 0.1.0-alpha.90

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (316) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +42 -34
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +77 -51
  10. package/src/App.diff.test.tsx +259 -45
  11. package/src/App.export.test.tsx +58 -41
  12. package/src/App.focus.test.ts +89 -0
  13. package/src/App.identity.test.tsx +78 -0
  14. package/src/App.plan-render.test.tsx +209 -0
  15. package/src/App.plan-review.test.tsx +330 -161
  16. package/src/App.prototype-markdown.test.tsx +128 -0
  17. package/src/App.prototype.test.tsx +176 -0
  18. package/src/App.readonly.test.tsx +129 -67
  19. package/src/App.regression.test.tsx +94 -0
  20. package/src/App.rendering.test.tsx +61 -35
  21. package/src/App.share-dialog.test.tsx +172 -0
  22. package/src/App.submit-confirm.test.tsx +264 -0
  23. package/src/App.test.tsx +402 -126
  24. package/src/App.toast.test.tsx +98 -0
  25. package/src/App.tsx +1402 -1060
  26. package/src/App.visual.test.tsx +57 -0
  27. package/src/App.walk.test.tsx +299 -0
  28. package/src/App.workbench.test.tsx +774 -0
  29. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  30. package/src/annotation-palette.ts +62 -0
  31. package/src/ansi-screen.test.ts +28 -0
  32. package/src/ansi-screen.ts +72 -0
  33. package/src/app-key-state.ts +61 -0
  34. package/src/app-screens.tsx +577 -0
  35. package/src/app-view-model.ts +168 -0
  36. package/src/attribution.test.ts +31 -0
  37. package/src/attribution.ts +42 -0
  38. package/src/client-extension-registry.test.tsx +61 -0
  39. package/src/client-extension-registry.ts +159 -0
  40. package/src/clipboard.test.ts +23 -0
  41. package/src/clipboard.ts +33 -0
  42. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  43. package/src/components/AnnotatableFileView.test.tsx +102 -0
  44. package/src/components/AnnotatableFileView.tsx +134 -0
  45. package/src/components/AnnotationCards.stories.tsx +86 -0
  46. package/src/components/AnnotationCards.tsx +341 -0
  47. package/src/components/AnsiScreen.stories.tsx +12 -0
  48. package/src/components/AnsiScreen.tsx +28 -0
  49. package/src/components/AppHeader.stories.tsx +50 -0
  50. package/src/components/AppHeader.tsx +83 -0
  51. package/src/components/AppShell.brand.test.tsx +58 -0
  52. package/src/components/AppShell.extensions.test.tsx +191 -0
  53. package/src/components/AppShell.stories.tsx +89 -0
  54. package/src/components/AppShell.tsx +362 -0
  55. package/src/components/AppShell.zoom.test.tsx +135 -0
  56. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  57. package/src/components/BareWorkbenchFileView.tsx +64 -0
  58. package/src/components/Breadcrumb.stories.tsx +19 -0
  59. package/src/components/Breadcrumb.tsx +45 -0
  60. package/src/components/ChangesColumn.stories.tsx +44 -0
  61. package/src/components/ChangesColumn.tsx +199 -0
  62. package/src/components/ChangesFileTree.test.tsx +61 -0
  63. package/src/components/CodeBlock.stories.tsx +42 -0
  64. package/src/components/CodeBlock.tsx +84 -0
  65. package/src/components/CompletionOverlay.stories.tsx +37 -0
  66. package/src/components/CompletionOverlay.tsx +77 -0
  67. package/src/components/ConfirmCard.stories.tsx +37 -0
  68. package/src/components/ConfirmCard.tsx +150 -0
  69. package/src/components/ConfirmDialog.stories.tsx +21 -0
  70. package/src/components/ConfirmDialog.tsx +72 -0
  71. package/src/components/DiffContentView.scroll.test.tsx +149 -0
  72. package/src/components/DiffContentView.split.test.tsx +140 -0
  73. package/src/components/DiffContentView.stories.tsx +80 -0
  74. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  75. package/src/components/DiffContentView.tsx +906 -0
  76. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  77. package/src/components/DiscussionMarkerRail.tsx +186 -0
  78. package/src/components/EditorGrid.stories.tsx +59 -0
  79. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  80. package/src/components/EditorGrid.tsx +426 -0
  81. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  82. package/src/components/ExtensionZone.stories.tsx +28 -0
  83. package/src/components/ExtensionZone.tsx +145 -0
  84. package/src/components/FileContentsView.stories.tsx +16 -0
  85. package/src/components/FileContentsView.test.tsx +29 -0
  86. package/src/components/FileContentsView.tsx +98 -0
  87. package/src/components/GridTabContent.stories.tsx +59 -0
  88. package/src/components/GridTabContent.tsx +182 -0
  89. package/src/components/HandleChipsInput.stories.tsx +24 -0
  90. package/src/components/HandleChipsInput.test.tsx +35 -0
  91. package/src/components/HandleChipsInput.tsx +145 -0
  92. package/src/components/KeybindsDialog.stories.tsx +31 -0
  93. package/src/components/KeybindsDialog.tsx +103 -0
  94. package/src/components/MarkdownGridBlock.stories.tsx +33 -0
  95. package/src/components/MarkdownGridBlock.tsx +72 -0
  96. package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
  97. package/src/components/MarkdownThreadEditor.test.tsx +98 -0
  98. package/src/components/MarkdownThreadEditor.tsx +162 -0
  99. package/src/components/MenuBar.stories.tsx +28 -0
  100. package/src/components/MenuBar.tsx +44 -0
  101. package/src/components/NavModeHint.stories.tsx +40 -0
  102. package/src/components/NavModeHint.tsx +31 -0
  103. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  104. package/src/components/OverlayScrollbar.test.tsx +139 -0
  105. package/src/components/OverlayScrollbar.tsx +129 -0
  106. package/src/components/PaneGrid.stories.tsx +84 -0
  107. package/src/components/PaneGrid.tsx +128 -0
  108. package/src/components/PanelColumn.stories.tsx +25 -0
  109. package/src/components/PanelColumn.tsx +98 -0
  110. package/src/components/ProjectTreeView.stories.tsx +25 -0
  111. package/src/components/ProjectTreeView.test.tsx +75 -0
  112. package/src/components/ProjectTreeView.tsx +125 -0
  113. package/src/components/PromptDialog.stories.tsx +36 -0
  114. package/src/components/PromptDialog.test.tsx +61 -0
  115. package/src/components/PromptDialog.tsx +99 -0
  116. package/src/components/PrototypeContentView.stories.tsx +19 -0
  117. package/src/components/PrototypeContentView.tsx +446 -0
  118. package/src/components/RootOverlay.stories.tsx +45 -0
  119. package/src/components/RootOverlay.tsx +48 -0
  120. package/src/components/ScrollArea.stories.tsx +20 -0
  121. package/src/components/ScrollArea.tsx +44 -0
  122. package/src/components/SettingsDialog.stories.tsx +117 -0
  123. package/src/components/SettingsDialog.tsx +186 -0
  124. package/src/components/SettingsRows.stories.tsx +18 -0
  125. package/src/components/SettingsRows.tsx +112 -0
  126. package/src/components/ShareDialog.stories.tsx +121 -0
  127. package/src/components/ShareDialog.tsx +552 -0
  128. package/src/components/ShellHeader.stories.tsx +50 -0
  129. package/src/components/ShellHeader.tsx +137 -0
  130. package/src/components/SlashComposer.stories.tsx +37 -0
  131. package/src/components/SlashComposer.tsx +87 -0
  132. package/src/components/ThreadFooter.stories.tsx +30 -0
  133. package/src/components/ThreadFooter.test.tsx +47 -0
  134. package/src/components/ThreadFooter.tsx +76 -0
  135. package/src/components/ThreadTree.stories.tsx +46 -0
  136. package/src/components/ThreadTree.tsx +255 -0
  137. package/src/components/ThreadView.selection.test.tsx +951 -0
  138. package/src/components/ThreadView.slash.test.tsx +146 -0
  139. package/src/components/ThreadView.stories.tsx +26 -0
  140. package/src/components/ThreadView.tsx +525 -0
  141. package/src/components/Toast.stories.tsx +19 -0
  142. package/src/components/Toast.tsx +52 -0
  143. package/src/components/Tooltip.stories.tsx +26 -0
  144. package/src/components/Tooltip.tsx +72 -0
  145. package/src/components/TreePane.stories.tsx +106 -0
  146. package/src/components/TreePane.tsx +157 -0
  147. package/src/components/WalkWizard.stories.tsx +100 -0
  148. package/src/components/WalkWizard.tsx +150 -0
  149. package/src/components/WelcomePlayground.stories.tsx +12 -0
  150. package/src/components/WelcomePlayground.test.tsx +49 -0
  151. package/src/components/WelcomePlayground.tsx +139 -0
  152. package/src/components/WorkspacePanels.stories.tsx +36 -0
  153. package/src/components/WorkspacePanels.tsx +116 -0
  154. package/src/components/__snapshots__/stories.test.tsx.snap +2264 -0
  155. package/src/components/diff-content-view-layout.test.ts +198 -0
  156. package/src/components/diff-content-view-layout.ts +84 -0
  157. package/src/components/editor-grid.test.ts +86 -0
  158. package/src/components/editor-grid.ts +138 -0
  159. package/src/components/file-tree.test.ts +60 -0
  160. package/src/components/file-tree.ts +112 -0
  161. package/src/components/menu-control.stories.tsx +35 -0
  162. package/src/components/menu-control.tsx +43 -0
  163. package/src/components/pages/Workspace.stories.tsx +181 -0
  164. package/src/components/primitives/Button.stories.tsx +32 -0
  165. package/src/components/primitives/Button.tsx +59 -0
  166. package/src/components/primitives/Card.stories.tsx +25 -0
  167. package/src/components/primitives/Card.tsx +63 -0
  168. package/src/components/primitives/Dialog.stories.tsx +24 -0
  169. package/src/components/primitives/Dialog.test.tsx +37 -0
  170. package/src/components/primitives/Dialog.tsx +71 -0
  171. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  172. package/src/components/primitives/DialogActions.tsx +39 -0
  173. package/src/components/primitives/IconButton.stories.tsx +36 -0
  174. package/src/components/primitives/IconButton.tsx +62 -0
  175. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  176. package/src/components/primitives/StatusBar.tsx +23 -0
  177. package/src/components/primitives/Switch.stories.tsx +17 -0
  178. package/src/components/primitives/Switch.tsx +32 -0
  179. package/src/components/primitives/Tabs.stories.tsx +29 -0
  180. package/src/components/primitives/Tabs.tsx +102 -0
  181. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  182. package/src/components/primitives/Toolbar.tsx +14 -0
  183. package/src/components/primitives/Tree.stories.tsx +124 -0
  184. package/src/components/primitives/Tree.tsx +100 -0
  185. package/src/components/primitives/frame.test.ts +12 -0
  186. package/src/components/primitives/frame.ts +17 -0
  187. package/src/components/primitives/icons.ts +61 -0
  188. package/src/components/primitives/tree-model.test.ts +99 -0
  189. package/src/components/primitives/tree-model.ts +137 -0
  190. package/src/components/quick-actions-editor.stories.tsx +58 -0
  191. package/src/components/quick-actions-editor.test.tsx +117 -0
  192. package/src/components/quick-actions-editor.tsx +226 -0
  193. package/src/components/share-dialog-store.ts +58 -0
  194. package/src/components/stories-app.tsx +193 -0
  195. package/src/components/stories.test.tsx +74 -0
  196. package/src/components/story-fixtures.ts +102 -0
  197. package/src/components/story-tree.test.ts +108 -0
  198. package/src/components/story.ts +159 -0
  199. package/src/components/syntax-highlight.ts +133 -0
  200. package/src/components/terminal-pane.test.tsx +40 -0
  201. package/src/components/terminal-pane.ts +199 -0
  202. package/src/components/theme-context.stories.tsx +46 -0
  203. package/src/components/theme-context.tsx +37 -0
  204. package/src/components/thread-tree.test.ts +134 -0
  205. package/src/components/thread-tree.ts +109 -0
  206. package/src/components/truncate-text.ts +7 -0
  207. package/src/components/truncate-title.test.ts +31 -0
  208. package/src/components/truncate-title.ts +15 -0
  209. package/src/config-identity.test.ts +78 -0
  210. package/src/config.test.ts +579 -34
  211. package/src/config.ts +533 -57
  212. package/src/diff-fold.test.ts +78 -0
  213. package/src/diff-fold.ts +126 -0
  214. package/src/diff-hunk-curate.test.ts +315 -0
  215. package/src/diff-hunk-curate.ts +153 -0
  216. package/src/diff-intraline.test.ts +238 -0
  217. package/src/diff-intraline.ts +236 -0
  218. package/src/diff-syntax.test.ts +112 -0
  219. package/src/diff-syntax.ts +179 -0
  220. package/src/discussions.ts +79 -0
  221. package/src/editor.test.ts +149 -0
  222. package/src/editor.ts +139 -9
  223. package/src/ghostty-terminal.test.ts +85 -0
  224. package/src/ghostty-terminal.ts +199 -0
  225. package/src/github-identity.test.ts +58 -0
  226. package/src/github-identity.ts +41 -0
  227. package/src/herdr.test.ts +9 -34
  228. package/src/herdr.ts +25 -21
  229. package/src/index.ts +28 -0
  230. package/src/integrations.test.ts +41 -0
  231. package/src/integrations.ts +32 -0
  232. package/src/intent-dispatch.test.ts +724 -0
  233. package/src/intent-dispatch.ts +690 -0
  234. package/src/key-bindings.test.ts +156 -0
  235. package/src/key-bindings.ts +459 -0
  236. package/src/keymap.test.ts +456 -115
  237. package/src/keymap.ts +315 -81
  238. package/src/kitty-image.test.ts +66 -0
  239. package/src/kitty-image.ts +138 -0
  240. package/src/launch-layout.test.ts +67 -0
  241. package/src/launch-layout.ts +47 -0
  242. package/src/mark-runs.ts +121 -0
  243. package/src/markdown-editor-syntax-style.ts +50 -0
  244. package/src/markdown-grid.test.ts +101 -0
  245. package/src/markdown-grid.ts +203 -0
  246. package/src/markdown-highlight.test.ts +61 -0
  247. package/src/markdown-highlight.ts +122 -0
  248. package/src/pasted-image.test.ts +67 -0
  249. package/src/pasted-image.ts +60 -0
  250. package/src/perf/otlp-trace.test.ts +116 -0
  251. package/src/perf/otlp-trace.ts +130 -0
  252. package/src/perf/perf-report.ts +17 -0
  253. package/src/perf/perf-timings.test.ts +18 -0
  254. package/src/perf/perf-timings.ts +38 -0
  255. package/src/prototype-browser.test.ts +81 -0
  256. package/src/prototype-browser.ts +329 -0
  257. package/src/prototype-pixels.tsx +35 -0
  258. package/src/pty.test.ts +81 -0
  259. package/src/pty.ts +245 -0
  260. package/src/ready-signal.test.ts +85 -0
  261. package/src/ready-signal.ts +52 -0
  262. package/src/run.ts +85 -12
  263. package/src/serve.test.ts +24 -12
  264. package/src/serve.ts +52 -10
  265. package/src/share.ts +237 -0
  266. package/src/skills.test.ts +74 -0
  267. package/src/skills.ts +75 -0
  268. package/src/slash-palette.test.ts +103 -0
  269. package/src/slash-palette.ts +129 -0
  270. package/src/split-diff.test.ts +110 -0
  271. package/src/split-diff.ts +149 -0
  272. package/src/test-support.test.ts +264 -0
  273. package/src/test-support.ts +369 -0
  274. package/src/theme-presets.test.ts +51 -0
  275. package/src/theme-presets.ts +219 -0
  276. package/src/theme.ts +88 -19
  277. package/src/thread-chords.test.ts +79 -0
  278. package/src/thread-chords.ts +163 -0
  279. package/src/thread-code-syntax.ts +45 -0
  280. package/src/thread-controller.curate.test.ts +476 -0
  281. package/src/thread-controller.derivation.test.ts +62 -0
  282. package/src/thread-controller.live-diff.test.ts +322 -0
  283. package/src/thread-controller.optimistic.test.ts +69 -0
  284. package/src/thread-controller.share.test.ts +561 -0
  285. package/src/thread-controller.switch-cache.test.ts +176 -0
  286. package/src/thread-controller.tree.test.ts +261 -0
  287. package/src/thread-controller.ts +2050 -0
  288. package/src/thread-selection.test.ts +210 -0
  289. package/src/thread-selection.ts +243 -0
  290. package/src/tree-view.test.ts +128 -0
  291. package/src/tree-view.ts +153 -0
  292. package/src/use-annotation-surface.tsx +975 -0
  293. package/src/use-changes-workbench.ts +190 -0
  294. package/src/use-frame-measure.ts +41 -0
  295. package/src/use-remember-layout.ts +31 -0
  296. package/src/use-repo-changes.ts +87 -0
  297. package/src/use-settings-dialog.tsx +364 -0
  298. package/src/use-terminal-virtualizer.ts +259 -0
  299. package/src/use-thread-body-editing.tsx +58 -0
  300. package/src/version.ts +5 -0
  301. package/src/view-diff.test.ts +279 -9
  302. package/src/view-diff.ts +460 -24
  303. package/src/view-plan.regression.test.ts +60 -0
  304. package/src/view-plan.test.ts +545 -0
  305. package/src/view-plan.ts +1019 -0
  306. package/src/visual/contrast.test.ts +35 -0
  307. package/src/visual/contrast.ts +33 -0
  308. package/src/walk.test.ts +121 -0
  309. package/src/walk.ts +74 -0
  310. package/src/workbench-snapshot.test.ts +99 -0
  311. package/src/workbench-snapshot.ts +59 -0
  312. package/src/session-controller.ts +0 -398
  313. package/src/syntax.test.ts +0 -41
  314. package/src/syntax.ts +0 -88
  315. package/src/view.test.ts +0 -157
  316. package/src/view.ts +0 -364
package/src/App.test.tsx CHANGED
@@ -1,18 +1,28 @@
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 { DARK } from "./theme";
12
+ import {
13
+ clickText,
14
+ dragText,
15
+ frameRow,
16
+ isolateUserConfig,
17
+ locateText,
18
+ press,
19
+ navCommand,
20
+ pressKey,
21
+ renderReadyApp,
22
+ typeText as type,
23
+ waitForState,
24
+ waitForText,
25
+ } from "./test-support";
16
26
 
17
27
  const PLAN = `# Migration Plan
18
28
 
@@ -24,195 +34,461 @@ The daemon persists sessions to disk atomically.
24
34
 
25
35
  - move the store
26
36
  - add recovery
37
+
38
+ Use the **safe** mode.
27
39
  `;
28
40
 
29
41
  let home: string;
42
+ let restoreUserConfig: () => void;
30
43
  let server: DaemonServer;
31
- let session: ReviewSession;
44
+ let session: Thread;
32
45
 
33
46
  beforeEach(() => {
34
47
  home = mkdtempSync(join(tmpdir(), "cueloop-app-"));
48
+ restoreUserConfig = isolateUserConfig(home);
35
49
  server = new DaemonServer({ home, idleExitMs: 0 });
36
50
  server.start();
37
51
  session = server.core.sessionCreate({
38
52
  workspace: { repoRoot: "/repo", branch: "main" },
39
- artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
53
+ artifact: {
54
+ type: "plan",
55
+ content: PLAN,
56
+ meta: { title: "Migration Plan", planPath: "plan.md" },
57
+ },
40
58
  });
41
59
  });
42
60
  afterEach(() => {
61
+ restoreUserConfig();
43
62
  server.stop();
44
63
  rmSync(home, { recursive: true, force: true });
45
64
  });
46
65
 
47
66
  async function renderApp(sessionId?: string) {
48
- const setup = await testRender(<App home={home} sessionId={sessionId ?? session.id} />, {
67
+ return renderReadyApp(<App home={home} sessionId={sessionId ?? session.id} />, {
49
68
  width: 120,
50
69
  height: 32,
51
70
  });
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
71
  }
60
72
 
61
73
  type Setup = Awaited<ReturnType<typeof renderApp>>;
62
74
 
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
- }
75
+ function foregroundsOf(setup: Setup, needle: string): string[] {
76
+ const foregrounds: string[] = [];
77
+
78
+ for (const line of setup.captureSpans().lines) {
79
+ for (const span of line.spans) {
80
+ if (!span.text.includes(needle)) continue;
81
+ const [red, green, blue] = span.fg.toInts();
74
82
 
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();
83
+ foregrounds.push(
84
+ "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""),
85
+ );
86
+ }
87
+ }
88
+
89
+ return foregrounds;
79
90
  }
80
91
 
81
92
  describe("plan rendering", () => {
82
- test("renders the plan with headings, list markers, and the rail", async () => {
93
+ test("renders the plan with headings, list markers, and the footer", async () => {
94
+ // Arrange
83
95
  const setup = await renderApp();
96
+
97
+ // Assert
84
98
  const frame = setup.captureCharFrame();
99
+
85
100
  expect(frame).toContain("Migration Plan");
86
101
  expect(frame).toContain("Context");
87
102
  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)");
103
+ expect(frame).toContain("· move the store");
104
+ expect(frame).toContain("Send message (");
105
+ });
106
+
107
+ test("a direct open still populates the Threads sidebar with other pending reviews", async () => {
108
+ // Arrange - a second pending review exists alongside the one opened directly
109
+ server.core.sessionCreate({
110
+ workspace: { repoRoot: "/repo", branch: "main" },
111
+ artifact: { type: "plan", content: "# Other Plan\n", meta: { title: "Other Plan" } },
112
+ });
113
+ const setup = await renderApp();
114
+
115
+ // Act - open the collapsed Threads sidebar (the mirrored panel toggle by the gear)
116
+ await setup.mockMouse.click(4, 0);
117
+
118
+ // Assert - the sidebar lists the other pending review, so you can jump to it
119
+ await waitForText(setup, "Other Plan");
91
120
  });
92
121
  });
93
122
 
94
- describe("keyboard grammar", () => {
95
- test("j/k moves the cursor glyph between blocks", async () => {
123
+ describe("thread view grammar", () => {
124
+ test("↓ moves the caret between blocks and typing anchors to the block under it", async () => {
125
+ // Arrange
96
126
  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");
127
+
128
+ // Act: two blocks down lands on the paragraph; a printable opens a draft there
129
+ await pressKey(setup, "ARROW_DOWN");
130
+ await pressKey(setup, "ARROW_DOWN");
131
+ await type(setup, "x");
132
+
133
+ // Assert: the draft card sits right under the paragraph's row
134
+ await waitForText(setup, "● x");
135
+ expect(frameRow(setup, "● x")).toBeGreaterThan(frameRow(setup, "persists sessions"));
136
+ expect(frameRow(setup, "● x")).toBeLessThan(frameRow(setup, "Steps"));
103
137
  });
104
138
 
105
- test("comment flow: c types a body, ⏎ saves to the daemon", async () => {
139
+ test("comment flow: click a word, type a body, cmd+⏎ saves to the daemon", async () => {
140
+ // Arrange
106
141
  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 "The daemon');
142
+
143
+ // Act
144
+ await clickText(setup, "daemon");
112
145
  await type(setup, "Define atomically.");
113
- await press(setup, "enter");
114
- await Bun.sleep(80);
115
- await setup.renderOnce();
146
+ await pressKey(setup, "RETURN", { meta: true });
147
+
148
+ // Assert
149
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
116
150
  const stored = server.core.sessionGet(session.id);
151
+
117
152
  expect(stored.annotations.length).toBe(1);
118
153
  expect(stored.annotations[0]!.body).toBe("Define atomically.");
119
- expect(setup.captureCharFrame()).toContain("COMMENT");
154
+ expect(stored.annotations[0]!.anchor.quote).toBe("daemon");
120
155
  });
121
156
 
122
- test("span mode: v + l selects words, c anchors the exact span", async () => {
157
+ test("a drag marks the exact span the comment anchors to", async () => {
158
+ // Arrange
123
159
  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");
160
+
161
+ // Act: from the start of "The" to the end of "daemon"
162
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
129
163
  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");
164
+ await pressKey(setup, "RETURN", { meta: true });
165
+
166
+ // Assert
167
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
168
+ expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe("The daemon");
134
169
  });
135
170
 
136
- test("x cuts a block into the working copy; x restores it", async () => {
171
+ test("option+x cuts the caret's block into the working copy; option+x restores it", async () => {
172
+ // Arrange
137
173
  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
- }
174
+
175
+ await clickText(setup, "move the store");
176
+
177
+ // Act
178
+ await navCommand(setup, "x");
179
+
180
+ // Assert - the cut lands in the working copy
181
+ await waitForState(
182
+ setup,
183
+ () => !(server.core.sessionGet(session.id).workingCopy ?? "").includes("move the store"),
184
+ );
185
+
186
+ // Act
187
+ await navCommand(setup, "x");
188
+
189
+ // Assert
190
+ await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
191
+ });
192
+
193
+ test("Cut removes only the marked characters instead of their whole block", async () => {
194
+ const setup = await renderApp();
195
+
196
+ await dragText(setup, "move the store", "move the store", "move".length);
197
+ await navCommand(setup, "x");
198
+ await waitForState(setup, () =>
199
+ (server.core.sessionGet(session.id).workingCopy ?? "").includes("- the store"),
200
+ );
201
+ const working = server.core.sessionGet(session.id).workingCopy ?? "";
202
+ const textCuts = server.core.sessionGet(session.id).textCuts;
203
+
204
+ expect(working).not.toContain("move the store");
205
+ expect(working).toContain("- the store");
206
+ expect(working).toContain("- add recovery");
207
+ expect(textCuts).toHaveLength(1);
208
+ expect(textCuts?.[0]?.quote).toBe("move");
209
+ expect(foregroundsOf(setup, "the store")).not.toContain(DARK.green);
210
+ expect(setup.captureCharFrame()).not.toContain("selection cut");
211
+
212
+ await pressKey(setup, "x");
213
+ await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
214
+
215
+ expect(server.core.sessionGet(session.id).textCuts).toBeUndefined();
216
+ });
217
+
218
+ test("a large partial Cut keeps its surviving prefix plain in one modified block", async () => {
219
+ const original = "A seeded plan so cueloop dev always has a thread to open.";
220
+ const shortened = server.core.sessionCreate({
221
+ workspace: { repoRoot: "/repo", branch: "main" },
222
+ artifact: { type: "plan", content: original, meta: { title: "Partial Cut" } },
223
+ });
224
+
225
+ server.core.sessionSetWorkingCopy(shortened.id, "A seed");
226
+ const setup = await renderApp(shortened.id);
227
+
228
+ await waitForText(setup, "A seed");
229
+ expect(foregroundsOf(setup, "seed")).toContain(DARK.text);
230
+ expect(foregroundsOf(setup, "seed")).not.toContain(DARK.green);
231
+ });
232
+
233
+ test("Cut maps rendered inline Markdown back to its exact source characters", async () => {
234
+ const setup = await renderApp();
235
+
236
+ await dragText(setup, "safe", "safe", "safe".length);
237
+ await navCommand(setup, "x");
238
+ await waitForState(setup, () =>
239
+ (server.core.sessionGet(session.id).workingCopy ?? "").includes("Use the **** mode."),
240
+ );
241
+
242
+ expect(server.core.sessionGet(session.id).workingCopy).toContain("Use the **** mode.");
243
+ });
244
+
245
+ test("ctrl+e opens the inline editor; the header toggles to normal and leaving tracks the edit", async () => {
246
+ // Arrange
247
+ const setup = await renderApp();
248
+
249
+ // Act - open the inline markdown editor over the thread body
250
+ await pressKey(setup, "e", { ctrl: true });
251
+
252
+ // Assert - the editor owns the pane (its save hint shows) and the header offers the way back
253
+ await waitForText(setup, "save & close");
254
+ expect(setup.captureCharFrame()).toContain("normal");
255
+
256
+ // Act - type into the body, then leave through the header toggle
257
+ await type(setup, "MORE");
258
+ await clickText(setup, "normal");
259
+
260
+ // Assert - the edit is tracked into the working copy, and the read-only view is back
261
+ await waitForState(setup, () =>
262
+ (server.core.sessionGet(session.id).workingCopy ?? "").includes("MORE"),
263
+ );
166
264
  });
167
265
  });
168
266
 
169
267
  describe("submit", () => {
170
- test("⏎ opens the verdict bar; verdict + summary resolve the session", async () => {
268
+ test("cmd+⏎ opens the rail confirm card; message + summary resolve the session", async () => {
269
+ // Arrange
171
270
  const setup = await renderApp();
172
- await press(setup, "j");
173
- await press(setup, "j");
174
- await press(setup, "c");
271
+
272
+ await clickText(setup, "daemon");
175
273
  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]");
274
+ await pressKey(setup, "RETURN", { meta: true });
275
+ await waitForText(setup, "Needs a phase list");
276
+
277
+ // Act: with no composer open the same chord opens submit
278
+ await pressKey(setup, "RETURN", { meta: true });
279
+
280
+ // Assert - the card opens on the default message, approve
281
+ await waitForText(setup, "[approve]");
282
+
283
+ // Act - cycle to request changes, then send with a summary
284
+ await press(setup, "right");
285
+ await waitForText(setup, "[changes]");
181
286
  await type(setup, "Expand the steps.");
182
- await press(setup, "enter");
183
- await Bun.sleep(120);
184
- await setup.renderOnce();
287
+ await pressKey(setup, "RETURN", { meta: true });
288
+
289
+ // Assert
290
+ await waitForText(setup, "feedback sent");
185
291
  const stored = server.core.sessionGet(session.id);
292
+
186
293
  expect(stored.status).toBe("resolved");
187
- expect(stored.verdict!.kind).toBe("request_changes");
188
- expect(stored.verdict!.feedback).toContain("Needs a phase list.");
294
+ expect(stored.message!.outcome).toBe("changes_requested");
295
+ expect(stored.message!.body).toContain("Needs a phase list.");
189
296
  // submit hands the reviewer back to the agent via the completion overlay
190
- expect(setup.captureCharFrame()).toContain("✎ feedback sent");
297
+ expect(setup.captureCharFrame()).toContain("feedback sent");
191
298
  });
192
299
 
193
- test("approve via ←/→ verdict cycling", async () => {
300
+ test("approve via ←/→ message cycling", async () => {
301
+ // Arrange
194
302
  const setup = await renderApp();
303
+
304
+ // Act
305
+ await pressKey(setup, "RETURN", { meta: true });
306
+
307
+ // Assert
308
+ await waitForText(setup, "[approve]"); // no pending items → approve default
309
+
310
+ // Act
311
+ await pressKey(setup, "RETURN", { meta: true });
312
+
313
+ // Assert
314
+ await waitForState(setup, () => server.core.sessionGet(session.id).message !== undefined);
315
+ expect(server.core.sessionGet(session.id).message!.outcome).toBe("approved");
316
+ });
317
+ });
318
+
319
+ describe("no-thread shell", () => {
320
+ test("opening with nothing selected lands in the shell with a Welcome tab and the Threads sidebar", async () => {
321
+ // Arrange
322
+ const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
323
+
324
+ // the Welcome playground measures its width before it paints, so wait for its copy, not the header
325
+ await waitForText(setup, "Getting started");
326
+
327
+ // Assert - the same shell header, the disposable Welcome tab, and the pending thread
328
+ const frame = setup.captureCharFrame();
329
+
330
+ expect(frame).toContain("Getting started");
331
+ expect(frame).toContain("Migration Plan");
332
+ expect(frame).not.toContain("· resume"); // the bespoke inbox header is retired
333
+
334
+ // Act
195
335
  await press(setup, "enter");
196
- await setup.renderOnce();
197
- expect(setup.captureCharFrame()).toContain("[Approve]"); // no pending items → approve default
336
+
337
+ // Assert
338
+ await waitForText(setup, "Send message (");
339
+ });
340
+
341
+ test("picking a thread from the no-thread shell keeps the Threads sidebar open", async () => {
342
+ // Arrange - a second pending thread so the sidebar has a row that never shows
343
+ // in the opened thread's own view
344
+ server.core.sessionCreate({
345
+ workspace: { repoRoot: "/repo", branch: "main" },
346
+ artifact: { type: "plan", content: "# Other Plan\n", meta: { title: "Other Plan" } },
347
+ });
348
+ const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
349
+
350
+ await waitForText(setup, "Getting started");
351
+ await waitForText(setup, "Other Plan"); // the sidebar opens by default here
352
+
353
+ // Act - open the thread under the cursor
198
354
  await press(setup, "enter");
199
- await Bun.sleep(120);
200
- expect(server.core.sessionGet(session.id).verdict!.kind).toBe("approve");
355
+ await waitForText(setup, "Send message (");
356
+
357
+ // Assert - the sidebar stayed open across the swap: both titles are on screen,
358
+ // and the non-opened one can only come from the still-open sidebar
359
+ const frame = setup.captureCharFrame();
360
+
361
+ expect(frame).toContain("Migration Plan");
362
+ expect(frame).toContain("Other Plan");
201
363
  });
202
- });
203
364
 
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");
365
+ test("the row kebab menu stars a thread into a Starred section", async () => {
366
+ // Arrange
367
+ const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
368
+
369
+ await waitForText(setup, "Migration Plan");
370
+
371
+ // Act - hover the row to reveal its kebab, open the menu, and pick Star
372
+ const row = locateText(setup, "Migration Plan");
373
+
374
+ await setup.mockMouse.moveTo(row.column, row.row);
375
+ await waitForText(setup, "⋮");
376
+ await clickText(setup, "⋮");
377
+ // "star" alone collides with "Getting started" in the welcome pane, so target the menu via its
378
+ // unique "rename" row - the star action sits directly above it
379
+ await waitForText(setup, "rename");
380
+ const rename = locateText(setup, "rename");
381
+ await setup.mockMouse.click(rename.column, rename.row - 1);
382
+
383
+ // Assert - a Starred section now holds the thread
384
+ await waitForText(setup, "Starred");
385
+ expect(frameRow(setup, "Migration Plan")).toBeGreaterThan(frameRow(setup, "Starred"));
386
+ });
387
+
388
+ test("a bare launch shows the Welcome surface with the right region collapsed", async () => {
389
+ // Arrange
390
+ const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
391
+
392
+ await waitForText(setup, "Getting started");
393
+
394
+ // Assert - Welcome fills the thread pane (not a Changes tab); the right region stays collapsed
395
+ const frame = setup.captureCharFrame();
396
+
397
+ expect(frame).toContain("Getting started");
398
+ expect(frame).not.toContain("Changes"); // the Changes editor is closed on a bare launch
399
+ expect(frame).toContain("Migration Plan"); // the sidebar lists the pending thread
400
+ });
401
+
402
+ test("the menu opens from the shell gear and escape is not a trap", async () => {
403
+ // Arrange
404
+ const setup = await renderReadyApp(<App home={home} />, { width: 120, height: 32 });
405
+
406
+ await waitForText(setup, "Getting started");
407
+
408
+ // Act - open the settings dialog from the top-left gear (the Threads panel header, row 0)
409
+ await setup.mockMouse.click(1, 0);
410
+
411
+ // Assert - the settings dialog with its Keybinds leaf appears
412
+ await waitForText(setup, "Keybinds");
413
+ expect(setup.captureCharFrame()).toContain("settings");
414
+
415
+ // Act - escape closes the menu (not a trap), and the thread nav still works
416
+ await press(setup, "escape");
417
+ await waitForState(setup, () => !setup.captureCharFrame().includes("Keybinds"));
213
418
  await press(setup, "enter");
214
- await Bun.sleep(80);
215
- await setup.renderOnce();
216
- expect(setup.captureCharFrame()).toContain("Submit review (0)");
419
+
420
+ // Assert
421
+ await waitForText(setup, "Send message (");
422
+ });
423
+ });
424
+
425
+ describe("the thread view and the menu", () => {
426
+ test("the keybinds dialog lists the thread view grammar, keeps keys away from the text, and closes on escape", async () => {
427
+ // Arrange
428
+ const setup = await renderApp();
429
+
430
+ await waitForText(setup, "The daemon persists");
431
+
432
+ // Act - open the menu from the top-left settings gear, then the keybinds dialog
433
+ await setup.mockMouse.click(1, 0);
434
+ await waitForText(setup, "Keybinds");
435
+ const dropUp = setup.captureCharFrame().split("\n");
436
+ const keybindsRow = dropUp.findIndex((line) => line.includes("Keybinds"));
437
+
438
+ await setup.mockMouse.click(dropUp[keybindsRow]!.indexOf("Keybinds") + 1, keybindsRow);
439
+ await waitForText(setup, "mark text, across blocks");
440
+
441
+ // Assert - the thread grammar, not the plan sheet's
442
+ const dialog = setup.captureCharFrame();
443
+
444
+ expect(dialog).toContain("nav mode");
445
+ expect(dialog).toContain("place the caret");
446
+ expect(dialog).not.toContain("grow/shrink");
447
+
448
+ // Act - a printable behind the dialog must not open a comment; escape closes it
449
+ await press(setup, "x");
450
+ expect(setup.captureCharFrame()).not.toContain("● x");
451
+ await press(setup, "escape");
452
+
453
+ // Assert
454
+ await waitForState(setup, () => !setup.captureCharFrame().includes("mark text, across blocks"));
455
+ expect(setup.captureCharFrame()).not.toContain("● x");
456
+ });
457
+
458
+ test("clicking the Settings nav group is inert and never corrupts the active category", async () => {
459
+ // Arrange - open the settings dialog from the gear
460
+ const setup = await renderApp();
461
+
462
+ await setup.mockMouse.click(1, 0);
463
+ await waitForText(setup, "Keybinds");
464
+
465
+ // the nav folder is the last "settings" on screen (the first is the dialog title)
466
+ const lines = setup.captureCharFrame().split("\n");
467
+ let folderRow = -1;
468
+ let folderColumn = -1;
469
+
470
+ lines.forEach((line, row) => {
471
+ const column = line.indexOf("settings");
472
+
473
+ if (column !== -1) {
474
+ folderColumn = column;
475
+ folderRow = row;
476
+ }
477
+ });
478
+
479
+ // Act - click the synthetic Settings group folder, then press a nav key. The
480
+ // key handler dereferences the active category, so a corrupt id would throw
481
+ // (the global key handler swallows the throw into console.error).
482
+ const keyHandlerErrors: string[] = [];
483
+ const originalConsoleError = console.error;
484
+
485
+ console.error = (...args: unknown[]) => keyHandlerErrors.push(args.map(String).join(" "));
486
+ await setup.mockMouse.click(folderColumn + 1, folderRow);
487
+ await pressKey(setup, "ARROW_DOWN");
488
+ console.error = originalConsoleError;
489
+
490
+ // Assert - no key-handler crash, and the dialog is still up
491
+ expect(keyHandlerErrors.filter((line) => line.includes("keypress handler"))).toEqual([]);
492
+ expect(setup.captureCharFrame()).toContain("Keybinds");
217
493
  });
218
494
  });