@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,21 +1,28 @@
1
- /**
2
- * The plan review surface v2 (#71): native selection feeds the annotation
3
- * quote, the inline compose box keeps its anchor painted, the rail edits what
4
- * the document selects, and edit-exit reconciliation orphans annotations
5
- * whose passage was removed. Char-frame + styled-span assertions over the
6
- * real App and a real in-process daemon.
7
- */
1
+ /** The plan review surface v2: native selection feeds the annotation quote, the inline compose box keeps its anchor painted, the rail edits what the document selects, and edit-exit reconciliation orphans annotations whose passage was removed. Char-frame + styled-span assertions over the real App and a real in-process daemon. */
8
2
 
9
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
- import { mkdtempSync, rmSync } from "node:fs";
4
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
11
5
  import { tmpdir } from "node:os";
12
6
  import { join } from "node:path";
13
7
  import React from "react";
14
- import { testRender } from "@opentui/react/test-utils";
15
8
  import { DaemonServer } from "@cueloop/daemon";
16
- import type { ReviewSession } from "@cueloop/schema";
9
+ import type { Thread } from "@cueloop/schema";
17
10
  import { App } from "./App";
18
- import { DARK as T } from "./theme";
11
+ import { DEFAULT_QUICK_ACTIONS } from "./config";
12
+ import { slashItemsFrom } from "./slash-palette";
13
+ import {
14
+ clickText,
15
+ dragText,
16
+ isolateUserConfig,
17
+ navCommand,
18
+ press,
19
+ pressKey,
20
+ renderReadyApp,
21
+ typeText as type,
22
+ waitForState,
23
+ waitForText,
24
+ waitForTextGone,
25
+ } from "./test-support";
19
26
 
20
27
  const PLAN = `# Migration Plan
21
28
 
@@ -31,53 +38,41 @@ The daemon persists sessions to disk atomically.
31
38
 
32
39
  let home: string;
33
40
  let server: DaemonServer;
34
- let session: ReviewSession;
41
+ let session: Thread;
42
+ let restoreUserConfig: () => void;
35
43
 
36
44
  beforeEach(() => {
37
45
  home = mkdtempSync(join(tmpdir(), "cueloop-plan-review-"));
46
+ restoreUserConfig = isolateUserConfig(home);
38
47
  server = new DaemonServer({ home, idleExitMs: 0 });
39
48
  server.start();
40
49
  session = server.core.sessionCreate({
41
50
  workspace: { repoRoot: "/repo", branch: "main" },
42
- artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md", agent: "agent/worker-3" } },
51
+ artifact: {
52
+ type: "plan",
53
+ content: PLAN,
54
+ meta: { title: "Migration Plan", planPath: "plan.md", agent: "agent/worker-3" },
55
+ },
43
56
  });
44
57
  });
45
58
  afterEach(() => {
59
+ restoreUserConfig();
46
60
  server.stop();
47
61
  rmSync(home, { recursive: true, force: true });
48
62
  });
49
63
 
50
64
  async function renderApp() {
51
- const setup = await testRender(<App home={home} sessionId={session.id} />, {
65
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
52
66
  width: 120,
53
67
  height: 32,
54
68
  });
55
- for (let attempt = 0; attempt < 40 && !setup.captureCharFrame().includes("cueloop"); attempt++) {
56
- await Bun.sleep(25);
57
- await setup.renderOnce();
58
- }
59
- await setup.renderOnce();
60
- return setup;
61
- }
62
69
 
63
- type Setup = Awaited<ReturnType<typeof renderApp>>;
70
+ await waitForText(setup, "cueloop");
64
71
 
65
- async function press(setup: Setup, k: string): Promise<void> {
66
- if (k === "enter") setup.mockInput.pressKey("RETURN");
67
- else if (k === "escape") {
68
- setup.mockInput.pressKey("ESCAPE");
69
- // a bare ESC byte is an ambiguous sequence prefix; the parser settles late
70
- await Bun.sleep(120);
71
- } else await type(setup, k);
72
- await Bun.sleep(15);
73
- await setup.renderOnce();
72
+ return setup;
74
73
  }
75
74
 
76
- async function type(setup: Setup, text: string): Promise<void> {
77
- await setup.mockInput.typeText(text);
78
- await Bun.sleep(15);
79
- await setup.renderOnce();
80
- }
75
+ type Setup = Awaited<ReturnType<typeof renderApp>>;
81
76
 
82
77
  /**
83
78
  * Background colors (hex) of every styled span containing the needle - the
@@ -85,175 +80,349 @@ async function type(setup: Setup, text: string): Promise<void> {
85
80
  */
86
81
  function backgroundsOf(setup: Setup, needle: string): string[] {
87
82
  const backgrounds: string[] = [];
83
+
88
84
  for (const line of setup.captureSpans().lines) {
89
85
  for (const span of line.spans) {
90
86
  if (!span.text.includes(needle)) continue;
91
87
  const [red, green, blue] = span.bg.toInts();
92
- backgrounds.push("#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""));
88
+
89
+ backgrounds.push(
90
+ "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""),
91
+ );
93
92
  }
94
93
  }
94
+
95
95
  return backgrounds;
96
96
  }
97
97
 
98
- /** Move the cursor to "The daemon persists sessions to disk atomically." */
99
- async function toContextParagraph(setup: Setup): Promise<void> {
100
- await press(setup, "j");
101
- await press(setup, "j");
102
- }
98
+ describe("share button", () => {
99
+ test("the owner's plan header shows the Share button next to Edit", async () => {
100
+ // Arrange / Act
101
+ const setup = await renderApp();
103
102
 
104
- describe("selection feeds the annotation quote", () => {
105
- test("v anchors, l extends, c composes with the char-precise quote", async () => {
103
+ // Assert
104
+ // both word-buttons ride the header's thread segment, so share sits next to edit
105
+ const headerLine = setup
106
+ .captureCharFrame()
107
+ .split("\n")
108
+ .find((line) => line.includes("edit"));
109
+
110
+ expect(headerLine).toContain("share");
111
+ });
112
+
113
+ test("a read-only viewer (a plan shared over ssh) never sees the Share button", async () => {
114
+ // Arrange / Act
115
+ const viewer = await renderReadyApp(<App home={home} sessionId={session.id} readOnly />, {
116
+ width: 120,
117
+ height: 32,
118
+ });
119
+
120
+ await waitForText(viewer, "cueloop");
121
+
122
+ // Assert
123
+ expect(viewer.captureCharFrame()).not.toContain(" share ");
124
+ });
125
+
126
+ test("a resolved plan hides Edit and Share (no re-sharing a finished review)", async () => {
127
+ // Arrange - resolve the session before opening it
128
+ server.core.sessionSendMessage(session.id, "approved", "");
129
+
130
+ // Act
106
131
  const setup = await renderApp();
107
- await toContextParagraph(setup);
108
- await press(setup, "v");
109
- await press(setup, "l"); // "The daemon"
110
- await press(setup, "c");
111
- expect(setup.captureCharFrame()).toContain('comment on "The daemon"');
132
+
133
+ await waitForText(setup, "Migration Plan");
134
+
135
+ // Assert - the owner toolbar is gone once the review is resolved
136
+ const frame = setup.captureCharFrame();
137
+
138
+ expect(frame).not.toContain(" edit ");
139
+ expect(frame).not.toContain(" share ");
140
+ });
141
+ });
142
+
143
+ describe("edit affordance", () => {
144
+ test("the owner sees the Edit button", async () => {
145
+ // Arrange / Act
146
+ const owner = await renderApp();
147
+
148
+ // Assert
149
+ expect(owner.captureCharFrame()).toContain(" edit ");
150
+ });
151
+
152
+ test("a read-only viewer (a plan shared over ssh) never sees the Edit button", async () => {
153
+ // Arrange / Act
154
+ const viewer = await renderReadyApp(<App home={home} sessionId={session.id} readOnly />, {
155
+ width: 120,
156
+ height: 32,
157
+ });
158
+
159
+ await waitForText(viewer, "cueloop");
160
+
161
+ // Assert
162
+ expect(viewer.captureCharFrame()).not.toContain(" edit ");
163
+ });
164
+ });
165
+
166
+ describe("marks feed the comment anchor", () => {
167
+ test("a drag marks a character-precise span and typing anchors the comment to it", async () => {
168
+ // Arrange
169
+ const setup = await renderApp();
170
+
171
+ // Act
172
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
112
173
  await type(setup, "Which daemon?");
113
- await press(setup, "enter");
114
- await Bun.sleep(80);
115
- await setup.renderOnce();
116
- const stored = server.core.sessionGet(session.id);
117
- expect(stored.annotations.length).toBe(1);
118
- expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
174
+ await pressKey(setup, "RETURN", { meta: true });
175
+
176
+ // Assert
177
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
178
+ expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe("The daemon");
119
179
  });
120
180
 
121
- test("a mouse drag over the text becomes the compose quote", async () => {
181
+ test("a drag that ends mid-sentence keeps exactly the dragged words", async () => {
182
+ // Arrange
122
183
  const setup = await renderApp();
123
- const lines = setup.captureCharFrame().split("\n");
124
- const row = lines.findIndex((line) => line.includes("persists sessions"));
125
- const startColumn = lines[row]!.indexOf("persists");
126
- expect(row).toBeGreaterThan(-1);
127
- // the drag's end cell is exclusive: release one past the last character
128
- await setup.mockMouse.drag(startColumn, row, startColumn + "persists sessions".length, row);
129
- await setup.renderOnce();
130
- await press(setup, "c");
131
- expect(setup.captureCharFrame()).toContain('comment on "persists sessions"');
184
+
185
+ // Act: release one past the last character of "sessions"
186
+ await dragText(setup, "persists", "sessions", "sessions".length);
132
187
  await type(setup, "sessions plural?");
133
- await press(setup, "enter");
134
- await Bun.sleep(80);
135
- const stored = server.core.sessionGet(session.id);
136
- expect(stored.annotations[0]!.anchor.quote).toBe("persists sessions");
188
+ await pressKey(setup, "RETURN", { meta: true });
189
+
190
+ // Assert
191
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
192
+ expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe(
193
+ "persists sessions",
194
+ );
137
195
  });
138
196
  });
139
197
 
140
- describe("inline compose keeps the anchor painted", () => {
141
- test("the selection stays painted while composing; cancel un-paints; save converts to the comment highlight", async () => {
198
+ /** The thread view's mark backdrop in the dark palette. */
199
+ const THREAD_MARK = "#463852";
200
+
201
+ describe("the mark stays painted while composing", () => {
202
+ test("the mark paints while composing, escape un-paints, save keeps it as the comment's mark", async () => {
203
+ // Arrange
142
204
  const setup = await renderApp();
143
- await toContextParagraph(setup);
144
- await press(setup, "v");
145
- await press(setup, "l");
146
- await press(setup, "c");
147
- // compose open: the anchor is painted selection-style
148
- expect(setup.captureCharFrame()).toContain("Save ⏎");
149
- expect(setup.captureCharFrame()).toContain("Cancel esc");
150
- expect(backgroundsOf(setup, "The daemon")).toContain(T.accent);
151
- // cancel un-paints
205
+
206
+ // Act
207
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
208
+ await type(setup, "x");
209
+
210
+ // Assert: composing, the marked words carry the mark backdrop
211
+ await waitForText(setup, "● x");
212
+ expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
213
+
214
+ // Act: escape discards the draft and the mark stays for re-typing; the next escape
215
+ // enters nav mode with the mark still held; only an escape in nav drops it
216
+ await press(setup, "escape");
217
+ await waitForTextGone(setup, "● x");
218
+ expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
152
219
  await press(setup, "escape");
153
- expect(setup.captureCharFrame()).not.toContain('comment on "');
154
- expect(backgroundsOf(setup, "The daemon")).not.toContain(T.accent);
155
- // save converts the paint to the kind-colored annotation highlight
156
- await press(setup, "c");
220
+ await waitForText(setup, "type to leave");
221
+ expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
222
+ await press(setup, "escape");
223
+
224
+ // Assert
225
+ await waitForState(setup, () => !backgroundsOf(setup, "The daemon").includes(THREAD_MARK));
226
+
227
+ // Act: mark again, save
228
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
157
229
  await type(setup, "Which daemon?");
158
- await press(setup, "enter");
159
- await Bun.sleep(80);
160
- await setup.renderOnce();
161
- // the whole cursor block is the anchor here; check the highlight landed
162
- const stored = server.core.sessionGet(session.id);
163
- expect(stored.annotations.length).toBe(1);
164
- expect(backgroundsOf(setup, stored.annotations[0]!.anchor.quote.slice(0, 20))).toContain(T.markCommentBg);
165
- });
230
+ await pressKey(setup, "RETURN", { meta: true });
231
+
232
+ // Assert: the saved comment keeps the passage marked
233
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
234
+ await waitForState(setup, () => backgroundsOf(setup, "The daemon").includes(THREAD_MARK));
235
+ }, 60_000);
236
+
237
+ test("backspace on an empty draft dismisses the composer back to the mark", async () => {
238
+ // Arrange
239
+ const setup = await renderApp();
240
+
241
+ // Act: mark, type one character, then delete it and backspace again on the now-empty draft
242
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
243
+ await type(setup, "x");
244
+ await waitForText(setup, "● x");
245
+ await press(setup, "backspace");
246
+ await press(setup, "backspace");
247
+
248
+ // Assert: the composer is gone and the mark stays, ready to re-type
249
+ await waitForTextGone(setup, "● x");
250
+ expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
251
+ }, 60_000);
252
+
253
+ test("esc from type mode keeps the mark, so c in nav comments on the marked text", async () => {
254
+ // Arrange
255
+ const setup = await renderApp();
256
+
257
+ // Act: mark, enter nav with esc, open the composer with c, save
258
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
259
+ await navCommand(setup, "c");
260
+ await type(setup, "Which daemon?");
261
+ await waitForText(setup, "● Which daemon?");
262
+ await pressKey(setup, "RETURN", { meta: true });
263
+
264
+ // Assert: the comment anchors to the mark, not to the word under the caret
265
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
266
+ expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe("The daemon");
267
+ }, 60_000);
166
268
  });
167
269
 
168
- describe("the document selects, the rail edits", () => {
169
- test("e edits the selected card in place; enter saves through the controller", async () => {
270
+ describe("compose newline convention", () => {
271
+ test("enter breaks the line; cmd+enter saves the multiline body verbatim", async () => {
272
+ // Arrange
170
273
  const setup = await renderApp();
171
- await toContextParagraph(setup);
172
- await press(setup, "c");
173
- await type(setup, "Needs a citation.");
174
- await press(setup, "enter");
175
- await Bun.sleep(80);
176
- await setup.renderOnce();
177
- expect(setup.captureCharFrame()).toContain("COMMENT · pending");
178
- // the saved card is selected; e turns its body into an input in place
179
- await press(setup, "e");
180
- await type(setup, " And a link.");
274
+
275
+ await clickText(setup, "daemon");
276
+
277
+ // Act
278
+ await type(setup, "first line");
181
279
  await press(setup, "enter");
182
- await Bun.sleep(80);
183
- await setup.renderOnce();
184
- const stored = server.core.sessionGet(session.id);
185
- expect(stored.annotations.length).toBe(1);
186
- expect(stored.annotations[0]!.body).toBe("Needs a citation. And a link.");
280
+ await type(setup, "second line");
281
+
282
+ // Assert: still composing, the newline did not save
283
+ expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
284
+
285
+ // Act
286
+ await pressKey(setup, "RETURN", { meta: true });
287
+
288
+ // Assert
289
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
290
+ expect(server.core.sessionGet(session.id).annotations[0]!.body).toBe("first line\nsecond line");
187
291
  });
188
292
 
189
- test("x deletes the selected card and un-paints the document highlight", async () => {
293
+ test("escape cancels the composer without saving", async () => {
294
+ // Arrange
190
295
  const setup = await renderApp();
191
- await toContextParagraph(setup);
192
- await press(setup, "c");
193
- await type(setup, "Delete me.");
194
- await press(setup, "enter");
195
- await Bun.sleep(80);
196
- await setup.renderOnce();
197
- expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
198
- expect(backgroundsOf(setup, "persists sessions")).toContain(T.markCommentBg);
199
- await press(setup, "x");
200
- await Bun.sleep(80);
201
- await setup.renderOnce();
296
+
297
+ await clickText(setup, "daemon");
298
+ await type(setup, "never saved");
299
+ await waitForText(setup, "● never saved");
300
+
301
+ // Act
302
+ await press(setup, "escape");
303
+
304
+ // Assert
305
+ await waitForTextGone(setup, "● never saved");
202
306
  expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
203
- expect(setup.captureCharFrame()).not.toContain("COMMENT · pending");
204
- expect(backgroundsOf(setup, "persists sessions")).not.toContain(T.markCommentBg);
205
307
  });
206
308
  });
207
309
 
208
310
  describe("sheet header", () => {
209
- test("shows the submitting agent, the revision, and the Edit word-button", async () => {
311
+ test("mirrors the thread name and carries the Edit and Share word-buttons", async () => {
312
+ // Arrange
210
313
  const setup = await renderApp();
314
+
315
+ // Assert - the minimal header shows the thread title (mirror) plus the owner
316
+ // actions; the revision and submitter metadata are no longer in the header
211
317
  const frame = setup.captureCharFrame();
212
- expect(frame).toContain("submitted by agent/worker-3 · revision 1");
213
- expect(frame).toContain("Edit");
318
+
319
+ expect(frame).toContain("Migration Plan");
320
+ expect(frame).not.toContain("submitted by");
321
+ expect(frame).not.toContain("rev 1");
322
+ expect(frame).toContain(" edit ");
323
+ expect(frame).toContain(" share ");
214
324
  });
325
+ });
215
326
 
216
- test("the Agent tab shows the submitter, status, and revision", async () => {
327
+ describe("quick-actions settings editor", () => {
328
+ test("expanding an action edits its system prompt inline and persists it", async () => {
329
+ // Arrange
217
330
  const setup = await renderApp();
218
- const lines = setup.captureCharFrame().split("\n");
219
- const tabRow = lines.findIndex((line) => line.includes("Review (0)"));
220
- const tabColumn = lines[tabRow]!.indexOf("Agent");
221
- await setup.mockMouse.click(tabColumn + 1, tabRow);
331
+
332
+ // Act - open Settings from the top-left gear, enter Actions, expand the first action, type
333
+ await setup.mockMouse.click(1, 0);
334
+ await waitForText(setup, "Keybinds");
335
+ await clickText(setup, "settings");
336
+ await clickText(setup, "Actions");
337
+ await clickText(setup, "Zoom out, research in depth");
338
+ await type(setup, "CUSTOM");
222
339
  await setup.renderOnce();
223
- const frame = setup.captureCharFrame();
224
- expect(frame).toContain("agent/worker-3");
225
- expect(frame).toContain("status: pending");
226
- expect(frame).toContain("revision 1");
340
+
341
+ // Assert - the keystrokes reached the focused input (not swallowed by nav) and persisted
342
+ expect(setup.captureCharFrame()).toContain("CUSTOM");
343
+ await waitForState(setup, () =>
344
+ readFileSync(join(home, "no-config.toml"), "utf8").includes("CUSTOM"),
345
+ );
227
346
  });
228
347
  });
229
348
 
230
349
  describe("edit-exit reconciliation", () => {
231
350
  test("an edit that removes an anchored passage orphans the annotation and shows the banner", async () => {
232
- const script = join(home, "fake-editor.sh");
233
- await Bun.write(script, `#!/bin/sh\nsed -i '' '/^The daemon persists/d' "$1"\n`);
234
- Bun.spawnSync(["chmod", "+x", script]);
235
- process.env.CUELOOP_EDITOR = script;
236
- try {
237
- const setup = await renderApp();
238
- await toContextParagraph(setup);
239
- await press(setup, "c");
240
- await type(setup, "Anchor me to the doomed passage.");
241
- await press(setup, "enter");
242
- await Bun.sleep(80);
243
- await setup.renderOnce();
244
- expect(setup.captureCharFrame()).toContain("· pending");
245
- // deselect the card so e reaches the editor hand-off, then edit
246
- await press(setup, "escape");
247
- await press(setup, "e");
248
- await Bun.sleep(150);
249
- await setup.renderOnce();
250
- const frame = setup.captureCharFrame();
251
- expect(frame).toContain("1 annotation no longer match - the passage was removed.");
252
- expect(frame).toContain("· ORPHANED");
253
- // the annotation is NOT deleted: the feedback serializer handles orphans
254
- expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
255
- } finally {
256
- delete process.env.CUELOOP_EDITOR;
257
- }
351
+ // Arrange - anchor a comment to a passage
352
+ const setup = await renderApp();
353
+
354
+ await clickText(setup, "daemon");
355
+ await type(setup, "Anchor me to the doomed passage.");
356
+ await pressKey(setup, "RETURN", { meta: true });
357
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
358
+
359
+ // Act - open the inline editor, replace the whole body (dropping the passage), and leave
360
+ await pressKey(setup, "e", { ctrl: true });
361
+ await waitForText(setup, "save & close");
362
+ await pressKey(setup, "a", { meta: true });
363
+ await type(setup, "A fresh body with no anchored passage.");
364
+ await clickText(setup, "normal");
365
+
366
+ // Assert - the thread view banner reports the orphaned annotation
367
+ await waitForText(setup, "1 annotation no longer match - the passage was removed.");
368
+ // the annotation is NOT deleted: the feedback serializer handles orphans
369
+ expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
370
+ });
371
+ });
372
+
373
+ describe("thread switch preserves an open edit", () => {
374
+ test("clicking another thread while editing saves the body into the leaving thread", async () => {
375
+ // Arrange - a second thread sits in the sidebar to switch to
376
+ server.core.sessionCreate({
377
+ workspace: { repoRoot: "/repo", branch: "main" },
378
+ artifact: {
379
+ type: "plan",
380
+ content: "# Second Thread\n\nAnother body.\n",
381
+ meta: { title: "Second Thread", planPath: "plan.md", agent: "agent/worker-3" },
382
+ },
383
+ });
384
+ const setup = await renderApp();
385
+
386
+ // open the Threads sidebar so the other thread is on screen to click
387
+ await setup.mockMouse.click(4, 0);
388
+ await waitForText(setup, "Second Thread");
389
+
390
+ // Act - open the inline editor, rewrite the body, then click away before an explicit save
391
+ await pressKey(setup, "e", { ctrl: true });
392
+ await waitForText(setup, "save & close");
393
+ await pressKey(setup, "a", { meta: true });
394
+ await type(setup, "Body kept across a thread switch.");
395
+ await clickText(setup, "Second Thread");
396
+
397
+ // Assert - the leaving thread's working copy holds the edit rather than dropping it
398
+ await waitForState(setup, () =>
399
+ (server.core.sessionGet(session.id).workingCopy ?? "").includes(
400
+ "kept across a thread switch",
401
+ ),
402
+ );
403
+ });
404
+ });
405
+
406
+ describe("the quick-action palette", () => {
407
+ test("/ lists the quick actions and a pick inserts the reference, not the body", async () => {
408
+ // Arrange
409
+ const setup = await renderApp();
410
+
411
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
412
+
413
+ // Act - a leading slash opens the palette; step to the second default and pick it
414
+ await type(setup, "/");
415
+ await waitForText(setup, "zoom-out-research-in-depth");
416
+ await pressKey(setup, "ARROW_DOWN");
417
+ await press(setup, "enter");
418
+ await pressKey(setup, "RETURN", { meta: true });
419
+
420
+ // Assert - the comment holds the /name reference, not the expanded body
421
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
422
+ const stored = server.core.sessionGet(session.id);
423
+ const secondName = slashItemsFrom(DEFAULT_QUICK_ACTIONS)[1]!.name;
424
+
425
+ expect(stored.annotations[0]!.kind).toBe("comment");
426
+ expect(stored.annotations[0]!.body.trim()).toBe(`/${secondName}`);
258
427
  });
259
428
  });