@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
@@ -1,4 +1,4 @@
1
- /** Post-submit completion overlay: prompt, opt-in persistence, countdown, dismissal. */
1
+ /** Post-submit completion overlay: prompt, opt-in persistence, countdown, dismissal. Countdown seconds tick on an injected ManualClock, so tests advance time instead of waiting it out. */
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
4
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -6,113 +6,139 @@ import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import React from "react";
8
8
  import { testRender } from "@opentui/react/test-utils";
9
+ import { ManualClock } from "@opentui/core/testing";
9
10
  import { DaemonServer } from "@cueloop/daemon";
10
- import type { ReviewSession } from "@cueloop/schema";
11
+ import type { Thread } from "@cueloop/schema";
11
12
  import { App } from "./App";
13
+ import { isolateUserConfig, press, settle, waitForText, pressKey } from "./test-support";
12
14
 
13
15
  const PLAN = "# Plan\n\nShip the thing.\n";
14
16
 
15
17
  let home: string;
16
18
  let server: DaemonServer;
17
- let session: ReviewSession;
19
+ let session: Thread;
18
20
  let configPath: string;
21
+ let restoreUserConfig: () => void;
22
+ let clock: ManualClock;
19
23
 
20
24
  beforeEach(() => {
21
25
  home = mkdtempSync(join(tmpdir(), "cueloop-close-"));
22
26
  configPath = join(home, "config.toml");
23
- process.env.CUELOOP_CONFIG = configPath;
27
+ restoreUserConfig = isolateUserConfig(home, "config.toml");
24
28
  server = new DaemonServer({ home, idleExitMs: 0 });
25
29
  server.start();
26
30
  session = server.core.sessionCreate({
27
31
  workspace: { repoRoot: "/repo", branch: "main" },
28
32
  artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
29
33
  });
34
+ clock = new ManualClock();
30
35
  });
31
36
  afterEach(() => {
32
- delete process.env.CUELOOP_CONFIG;
37
+ restoreUserConfig();
33
38
  server.stop();
34
39
  rmSync(home, { recursive: true, force: true });
35
40
  });
36
41
 
37
42
  async function renderApp(onExit?: (code: number) => void) {
38
- const setup = await testRender(<App home={home} sessionId={session.id} onExit={onExit} />, {
39
- width: 120,
40
- height: 30,
41
- });
42
- for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
43
- await Bun.sleep(25);
44
- await setup.renderOnce();
45
- }
43
+ const setup = await testRender(
44
+ <App home={home} sessionId={session.id} onExit={onExit} clock={clock} />,
45
+ {
46
+ width: 120,
47
+ height: 30,
48
+ },
49
+ );
50
+
51
+ await waitForText(setup, "cueloop");
52
+
46
53
  return setup;
47
54
  }
48
55
 
49
56
  type Setup = Awaited<ReturnType<typeof renderApp>>;
50
- async function press(setup: Setup, k: string): Promise<void> {
51
- if (k === "enter") setup.mockInput.pressKey("RETURN");
52
- else if (k === "escape") setup.mockInput.pressKey("ESCAPE");
53
- else await setup.mockInput.typeText(k);
54
- await Bun.sleep(15);
55
- await setup.renderOnce();
56
- }
57
57
 
58
58
  async function submitApprove(setup: Setup): Promise<void> {
59
- await press(setup, "enter"); // open submit (approve default: no pending items)
60
- await press(setup, "enter"); // confirm
61
- const deadline = Date.now() + 10_000;
62
- while (Date.now() < deadline && !setup.captureCharFrame().includes("review approved")) {
63
- await Bun.sleep(25);
64
- await setup.renderOnce();
65
- }
59
+ await pressKey(setup, "RETURN", { meta: true }); // open submit (approve default: no pending items)
60
+ await pressKey(setup, "RETURN", { meta: true }); // confirm
61
+ await waitForText(setup, "review approved");
66
62
  }
67
63
 
68
64
  describe("completion overlay", () => {
69
- test("submit with auto-close off shows the prompt; ⏎ exits", async () => {
65
+ test("submit with auto-close off counts down from 5; ⏎ exits now", async () => {
66
+ // Arrange
70
67
  let exited = -1;
71
68
  const setup = await renderApp((code) => (exited = code));
69
+
70
+ // Act
72
71
  await submitApprove(setup);
73
- const frame = setup.captureCharFrame();
74
- expect(frame).toContain("✓ review approved");
75
- expect(frame).toContain("a always close after submit");
72
+
73
+ // Assert
74
+ const frame = await waitForText(setup, "closing in 5s");
75
+
76
+ expect(frame).toContain("review approved");
77
+ expect(frame).toContain("close");
78
+ expect(frame).toContain("back to plan");
79
+
80
+ // Act
76
81
  await press(setup, "enter");
82
+
83
+ // Assert
77
84
  expect(exited).toBe(0);
78
85
  });
79
86
 
80
- test("a opts in: persists the config and starts the countdown", async () => {
87
+ test("a remembers the countdown as the persisted default", async () => {
88
+ // Arrange
81
89
  const setup = await renderApp();
90
+
82
91
  await submitApprove(setup);
92
+ await waitForText(setup, "closing in 5s");
93
+
94
+ // Act
83
95
  await press(setup, "a");
84
- expect(setup.captureCharFrame()).toContain("closing in 3s");
96
+
97
+ // Assert
85
98
  expect(existsSync(configPath)).toBe(true);
86
- expect(readFileSync(configPath, "utf8")).toContain("auto_close = 3");
99
+ expect(readFileSync(configPath, "utf8")).toContain("auto_close = 5");
100
+
101
+ // Act
102
+ // the countdown keeps running; each second is one manual-clock tick
103
+ clock.advance(1000);
104
+
105
+ // Assert
106
+ await waitForText(setup, "closing in 4s");
87
107
  });
88
108
 
89
109
  test("esc dismisses to the resolved read-only view", async () => {
110
+ // Arrange
90
111
  const setup = await renderApp();
112
+
91
113
  await submitApprove(setup);
92
- await press(setup, "escape");
114
+ await waitForText(setup, "closing in 5s");
115
+
116
+ // Act
93
117
  // a bare ESC sits in the input parser's escape-sequence disambiguation
94
- // window before it is delivered - poll for the dismissal
95
- const deadline = Date.now() + 5_000;
96
- while (Date.now() < deadline && !setup.captureCharFrame().includes("resolved: approve")) {
97
- await Bun.sleep(50);
98
- await setup.renderOnce();
99
- }
100
- const frame = setup.captureCharFrame();
101
- expect(frame).toContain("resolved: approve");
102
- expect(frame).not.toContain("always close after submit");
118
+ // window before it is delivered - the frame wait absorbs it
119
+ await press(setup, "escape");
120
+
121
+ // Assert - the countdown is cancelled and we are back in the (resolved) thread view
122
+ const frame = await waitForText(setup, "Ship the thing");
123
+
124
+ expect(frame).not.toContain("closing in");
103
125
  });
104
126
 
105
127
  test("configured auto_close counts down and exits without interaction", async () => {
128
+ // Arrange
106
129
  writeFileSync(configPath, "[ui]\nauto_close = 1\n");
107
130
  let exited = -1;
108
131
  const setup = await renderApp((code) => (exited = code));
132
+
109
133
  await submitApprove(setup);
110
- expect(setup.captureCharFrame()).toContain("closing in 1s");
111
- const deadline = Date.now() + 10_000;
112
- while (Date.now() < deadline && exited === -1) {
113
- await Bun.sleep(100);
114
- await setup.renderOnce();
115
- }
134
+ await waitForText(setup, "closing in 1s");
135
+ expect(exited).toBe(-1);
136
+
137
+ // Act
138
+ clock.advance(1000);
139
+ await settle(setup);
140
+
141
+ // Assert
116
142
  expect(exited).toBe(0);
117
- }, 20_000);
143
+ });
118
144
  });
@@ -1,14 +1,28 @@
1
- /** Diff-review flow in the virtual terminal (slice 3). */
1
+ /** Diff-review flow in the virtual terminal: the diff sheet annotates like the thread view. */
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
4
  import { mkdtempSync, rmSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import React from "react";
8
- import { testRender } from "@opentui/react/test-utils";
9
8
  import { DaemonServer } from "@cueloop/daemon";
10
- import type { ReviewSession } from "@cueloop/schema";
9
+ import type { Thread } from "@cueloop/schema";
11
10
  import { App } from "./App";
11
+ import { pullRequestReviewLayout } from "./launch-layout";
12
+ import { DARK } from "./theme";
13
+ import { NERD } from "./components/primitives/icons";
14
+ import {
15
+ clickText,
16
+ dragText,
17
+ isolateUserConfig,
18
+ navCommand,
19
+ press,
20
+ pressKey,
21
+ renderReadyApp,
22
+ typeText,
23
+ waitForState,
24
+ waitForText,
25
+ } from "./test-support";
12
26
 
13
27
  const PATCH = `diff --git a/src/store.ts b/src/store.ts
14
28
  index 111..222 100644
@@ -22,11 +36,13 @@ index 111..222 100644
22
36
  `;
23
37
 
24
38
  let home: string;
39
+ let restoreUserConfig: () => void;
25
40
  let server: DaemonServer;
26
- let session: ReviewSession;
41
+ let session: Thread;
27
42
 
28
43
  beforeEach(() => {
29
44
  home = mkdtempSync(join(tmpdir(), "cueloop-diff-"));
45
+ restoreUserConfig = isolateUserConfig(home);
30
46
  server = new DaemonServer({ home, idleExitMs: 0 });
31
47
  server.start();
32
48
  session = server.core.sessionCreate({
@@ -35,65 +51,263 @@ beforeEach(() => {
35
51
  });
36
52
  });
37
53
  afterEach(() => {
54
+ restoreUserConfig();
38
55
  server.stop();
39
56
  rmSync(home, { recursive: true, force: true });
40
57
  });
41
58
 
42
- async function renderApp() {
43
- const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 30 });
44
- for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
45
- await Bun.sleep(25);
46
- await setup.renderOnce();
47
- }
48
- await setup.renderOnce();
49
- return setup;
50
- }
59
+ async function renderApp(sessionId = session.id) {
60
+ const setup = await renderReadyApp(<App home={home} sessionId={sessionId} />, {
61
+ width: 120,
62
+ height: 30,
63
+ });
64
+
65
+ await waitForText(setup, "new Map()");
51
66
 
52
- type Setup = Awaited<ReturnType<typeof renderApp>>;
53
- async function press(setup: Setup, k: string): Promise<void> {
54
- if (k === "enter") setup.mockInput.pressKey("RETURN");
55
- else await setup.mockInput.typeText(k);
56
- await Bun.sleep(15);
57
- await setup.renderOnce();
67
+ return setup;
58
68
  }
59
69
 
60
70
  describe("diff review", () => {
71
+ test("a submitted JJ review uses the existing refresh action and shows its source", async () => {
72
+ const jjReview = server.core.sessionCreate({
73
+ workspace: { repoRoot: "/repo", branch: "jj" },
74
+ artifact: {
75
+ type: "diff",
76
+ content: PATCH,
77
+ meta: { title: "JJ change", vcs: "jj", vcsChangeId: "abcdefghijklmnopqrstuvwxyzaaaaaa" },
78
+ },
79
+ });
80
+ const setup = await renderReadyApp(<App home={home} sessionId={jjReview.id} />, {
81
+ width: 120,
82
+ height: 30,
83
+ });
84
+
85
+ await waitForText(setup, "new Map()");
86
+ expect(setup.captureCharFrame()).toContain("vcs: jj");
87
+ expect(setup.captureCharFrame()).toContain("refresh");
88
+ });
89
+
90
+ test("pull request review shows its brief beside Changes and signals a moved head", async () => {
91
+ const pullRequest = server.core.sessionCreate({
92
+ workspace: { repoRoot: "/repo", branch: "detached" },
93
+ artifact: {
94
+ type: "diff",
95
+ content: PATCH,
96
+ meta: {
97
+ title: "Fix store",
98
+ pr: "org/repo#42",
99
+ prBrief: "# Fix store\n\n## PR description\n\nUse a map.",
100
+ prHeadSha: "head-1",
101
+ prRefreshHeadSha: "head-2",
102
+ },
103
+ },
104
+ });
105
+ const setup = await renderReadyApp(
106
+ <App home={home} sessionId={pullRequest.id} layout={pullRequestReviewLayout()} />,
107
+ { width: 160, height: 30 },
108
+ );
109
+
110
+ await waitForText(setup, "Use a map.");
111
+ await waitForText(setup, "new Map()");
112
+ await waitForText(setup, "refresh");
113
+ const refreshSpan = setup
114
+ .captureSpans()
115
+ .lines.flatMap((line) => line.spans)
116
+ .find((span) => span.text.includes("refresh"));
117
+ const refreshColor = refreshSpan?.fg
118
+ .toInts()
119
+ .slice(0, 3)
120
+ .map((part) => part.toString(16).padStart(2, "0"))
121
+ .join("");
122
+
123
+ expect(`#${refreshColor}`).toBe(DARK.warning);
124
+ });
125
+
126
+ test("a resolved pull request review does not offer refresh", async () => {
127
+ const pullRequest = server.core.sessionCreate({
128
+ workspace: { repoRoot: "/repo", branch: "detached" },
129
+ artifact: {
130
+ type: "diff",
131
+ content: PATCH,
132
+ meta: {
133
+ title: "Fix store",
134
+ pr: "org/repo#42",
135
+ prHeadSha: "head-1",
136
+ prRefreshHeadSha: "head-2",
137
+ },
138
+ },
139
+ });
140
+
141
+ server.core.sessionSendMessage(pullRequest.id, "approved", "Ready to merge.");
142
+ const setup = await renderReadyApp(
143
+ <App home={home} sessionId={pullRequest.id} layout={pullRequestReviewLayout()} />,
144
+ { width: 160, height: 30 },
145
+ );
146
+
147
+ await waitForText(setup, "new Map()");
148
+ expect(setup.captureCharFrame()).not.toContain("refresh");
149
+ });
150
+
61
151
  test("renders file header, hunks, and signed lines", async () => {
152
+ // Arrange
62
153
  const setup = await renderApp();
154
+
155
+ // Assert
63
156
  const frame = setup.captureCharFrame();
64
- expect(frame).toContain("■ src/store.ts");
157
+
158
+ expect(frame).toContain("src/store.ts");
65
159
  expect(frame).toContain("@@ -1,4 +1,4 @@");
66
- expect(frame).toContain("- private items = [];");
67
- expect(frame).toContain("+ private items = new Map();");
160
+ expect(frame).toMatch(/- {2,}private items = \[\];/);
161
+ expect(frame).toMatch(/\+ {2,}private items = new Map\(\);/);
68
162
  });
69
163
 
70
- test("line comment: quote-anchored, lands in feedback with the code line", async () => {
164
+ test("a drag marks the code, typing comments on it, and the comment lands in feedback", async () => {
165
+ // Arrange
71
166
  const setup = await renderApp();
72
- // rows: file(0), hunk(1), ctx(2), del(3), add(4)
73
- for (let i = 0; i < 4; i++) await press(setup, "j");
74
- await press(setup, "c");
75
- expect(setup.captureCharFrame()).toContain("COMMENT ON");
76
- await setup.mockInput.typeText("Map needs an eviction story.");
77
- await press(setup, "enter");
78
- await Bun.sleep(80);
79
- await setup.renderOnce();
80
- const stored = server.core.sessionGet(session.id);
81
- expect(stored.annotations.length).toBe(1);
82
- expect(stored.annotations[0]!.anchor.quote).toContain("new Map()");
83
- // inline annotation card rendered under the line
84
- expect(setup.captureCharFrame()).toContain("◆ Map needs an eviction story.");
85
-
86
- await press(setup, "enter"); // submit
87
- await press(setup, "enter"); // confirm request_changes
88
- await Bun.sleep(100);
167
+
168
+ // Act - mark "new Map()" on the added line and just start typing, as in the thread view
169
+ await dragText(setup, "new Map()", "new Map()", "new Map()".length);
170
+ await typeText(setup, "Map needs an eviction story.");
171
+ await waitForText(setup, "● Map needs an eviction story.");
172
+ await pressKey(setup, "RETURN", { meta: true });
173
+
174
+ // Assert - the discussion card hangs under the line; the anchor is the marked words
175
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
176
+ await waitForText(setup, "● Map needs an eviction story.");
177
+ expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe("new Map()");
178
+
179
+ // Act - submit with the session chord (cmd+enter, no composer open), cycle to request changes
180
+ await pressKey(setup, "RETURN", { meta: true });
181
+ await waitForText(setup, "[approve]");
182
+ await press(setup, "right");
183
+ await waitForText(setup, "[changes]");
184
+ await pressKey(setup, "RETURN", { meta: true });
185
+
186
+ // Assert
187
+ await waitForText(setup, "feedback sent");
89
188
  const resolved = server.core.sessionGet(session.id);
90
- expect(resolved.verdict!.feedback).toContain("new Map()");
91
- expect(resolved.verdict!.feedback).toContain("Map needs an eviction story.");
189
+
190
+ expect(resolved.message!.body).toContain("new Map()");
191
+ expect(resolved.message!.body).toContain("Map needs an eviction story.");
192
+ });
193
+
194
+ test("pasting an image into a diff comment drops in an [Image #n] placeholder", async () => {
195
+ // Arrange - mark the added line and open the inline comment composer
196
+ const setup = await renderApp();
197
+ await dragText(setup, "new Map()", "new Map()", "new Map()".length);
198
+ await typeText(setup, "see ");
199
+
200
+ // Act - a burst of control bytes stands in for the binary an image paste delivers
201
+ await setup.mockInput.pasteBracketedText("");
202
+
203
+ // Assert - the same placeholder the summary composer shows
204
+ await waitForText(setup, "see [Image #1]");
205
+ });
206
+
207
+ test("a comment reopens with its mark painted on the code", async () => {
208
+ // Arrange - a stored comment anchored to the added line's words
209
+ server.core.sessionAnnotate(session.id, {
210
+ id: "a_existing",
211
+ kind: "comment",
212
+ anchor: { quote: "new Map()", prefix: " private items = ", suffix: ";" },
213
+ body: "Existing note.",
214
+ });
215
+
216
+ // Act
217
+ const setup = await renderApp();
218
+
219
+ // Assert
220
+ await waitForText(setup, "● Existing note.");
221
+ });
222
+
223
+ test("option+c folds the caret's file to its band; the chevron restores its body", async () => {
224
+ // Arrange - the caret opens on the file's first code line
225
+ const setup = await renderApp();
226
+
227
+ // Act
228
+ await navCommand(setup, "c");
229
+
230
+ // Assert - the body is gone but the band (with its counts) remains
231
+ await waitForState(setup, () => !setup.captureCharFrame().includes("new Map()"));
232
+ const collapsed = setup.captureCharFrame();
233
+
234
+ expect(collapsed).toContain("src/store.ts");
235
+ expect(collapsed).toContain("+1");
236
+
237
+ // Act - the band's chevron unfolds it again
238
+ await clickText(setup, NERD.chevronRight);
239
+
240
+ // Assert
241
+ await waitForText(setup, "new Map()");
92
242
  });
93
243
 
94
- test("plan verbs are guarded in diff sessions", async () => {
244
+ test("curation needs full file contents; a legacy diff answers", async () => {
245
+ // Arrange - the session carries no artifact.files
95
246
  const setup = await renderApp();
96
- await press(setup, "x");
97
- expect(setup.captureCharFrame()).toContain("plan-only verb");
247
+
248
+ // Act
249
+ await navCommand(setup, "x");
250
+
251
+ // Assert
252
+ await waitForText(setup, "hunk curation needs full file contents");
253
+ });
254
+
255
+ test("rejecting the only change empties the curated working copy", async () => {
256
+ // Arrange - a diff session that carries full file contents
257
+ const withFiles = server.core.sessionCreate({
258
+ workspace: { repoRoot: "/repo", branch: "main" },
259
+ artifact: {
260
+ type: "diff",
261
+ content: PATCH,
262
+ meta: { title: "working tree" },
263
+ files: [
264
+ {
265
+ path: "src/store.ts",
266
+ oldContents: "export class Store {\n private items = [];\n}\n",
267
+ newContents: "export class Store {\n private items = new Map();\n}\n",
268
+ status: "modified",
269
+ },
270
+ ],
271
+ },
272
+ });
273
+ const setup = await renderApp(withFiles.id);
274
+
275
+ // Act - place the caret on the added line and reject its change
276
+ await clickText(setup, "new Map()");
277
+ await navCommand(setup, "x");
278
+
279
+ // Assert - the single change is gone, so the curated working copy is empty
280
+ await waitForState(setup, () => server.core.sessionGet(withFiles.id).workingCopy === "");
281
+ const stored = server.core.sessionGet(withFiles.id);
282
+
283
+ expect(stored.workingCopy).toBe("");
284
+ expect(setup.captureCharFrame()).not.toContain("change rejected");
285
+ });
286
+
287
+ test("a diff opens with the Changes column listing every changed file", async () => {
288
+ // Arrange - two changed files; config.ts is not in the patch text, so it can
289
+ // only appear here by way of the Changes column reading artifact.files
290
+ const withFiles = server.core.sessionCreate({
291
+ workspace: { repoRoot: "/repo", branch: "main" },
292
+ artifact: {
293
+ type: "diff",
294
+ content: PATCH,
295
+ meta: { title: "working tree" },
296
+ files: [
297
+ { path: "src/store.ts", oldContents: "a", newContents: "b", status: "modified" },
298
+ { path: "src/config.ts", oldContents: "", newContents: "c", status: "added" },
299
+ ],
300
+ },
301
+ });
302
+ const setup = await renderReadyApp(<App home={home} sessionId={withFiles.id} />, {
303
+ width: 120,
304
+ height: 30,
305
+ });
306
+
307
+ await waitForText(setup, "cueloop");
308
+
309
+ // Assert - the context default opens the column for a diff, so both files show
310
+ await waitForText(setup, "config.ts");
311
+ expect(setup.captureCharFrame()).toContain("store.ts");
98
312
  });
99
313
  });