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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +39 -32
  8. package/src/App.appearance.test.tsx +53 -0
  9. package/src/App.autoclose.test.tsx +76 -50
  10. package/src/App.diff.test.tsx +156 -42
  11. package/src/App.export.test.tsx +58 -40
  12. package/src/App.plan-review.test.tsx +363 -0
  13. package/src/App.prototype-markdown.test.tsx +92 -0
  14. package/src/App.prototype.test.tsx +176 -0
  15. package/src/App.readonly.test.tsx +128 -67
  16. package/src/App.rendering.test.tsx +61 -35
  17. package/src/App.submit-confirm.test.tsx +191 -0
  18. package/src/App.test.tsx +310 -114
  19. package/src/App.toast.test.tsx +89 -0
  20. package/src/App.tsx +1043 -798
  21. package/src/App.walk.test.tsx +298 -0
  22. package/src/App.workbench.test.tsx +495 -0
  23. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  24. package/src/annotation-palette.ts +61 -0
  25. package/src/app-key-state.ts +61 -0
  26. package/src/app-screens.tsx +432 -0
  27. package/src/app-view-model.ts +159 -0
  28. package/src/attribution.test.ts +31 -0
  29. package/src/attribution.ts +42 -0
  30. package/src/clipboard.test.ts +23 -0
  31. package/src/clipboard.ts +33 -0
  32. package/src/components/AnnotatableFileView.stories.tsx +42 -0
  33. package/src/components/AnnotatableFileView.test.tsx +102 -0
  34. package/src/components/AnnotatableFileView.tsx +126 -0
  35. package/src/components/AnnotationCards.stories.tsx +87 -0
  36. package/src/components/AnnotationCards.tsx +303 -0
  37. package/src/components/AppHeader.stories.tsx +50 -0
  38. package/src/components/AppHeader.tsx +83 -0
  39. package/src/components/AppShell.stories.tsx +89 -0
  40. package/src/components/AppShell.tsx +290 -0
  41. package/src/components/AppShell.zoom.test.tsx +135 -0
  42. package/src/components/BareWorkbenchFileView.stories.tsx +27 -0
  43. package/src/components/BareWorkbenchFileView.tsx +64 -0
  44. package/src/components/Breadcrumb.stories.tsx +19 -0
  45. package/src/components/Breadcrumb.tsx +45 -0
  46. package/src/components/ChangesColumn.stories.tsx +39 -0
  47. package/src/components/ChangesColumn.tsx +171 -0
  48. package/src/components/CodeBlock.stories.tsx +42 -0
  49. package/src/components/CodeBlock.tsx +84 -0
  50. package/src/components/CompletionOverlay.stories.tsx +23 -0
  51. package/src/components/CompletionOverlay.tsx +60 -0
  52. package/src/components/ConfirmCard.stories.tsx +35 -0
  53. package/src/components/ConfirmCard.tsx +128 -0
  54. package/src/components/ConfirmDialog.stories.tsx +21 -0
  55. package/src/components/ConfirmDialog.tsx +66 -0
  56. package/src/components/DiffContentView.scroll.test.tsx +121 -0
  57. package/src/components/DiffContentView.split.test.tsx +140 -0
  58. package/src/components/DiffContentView.stories.tsx +80 -0
  59. package/src/components/DiffContentView.syntax.test.tsx +82 -0
  60. package/src/components/DiffContentView.tsx +859 -0
  61. package/src/components/DiscussionMarkerRail.stories.tsx +54 -0
  62. package/src/components/DiscussionMarkerRail.tsx +181 -0
  63. package/src/components/EditorGrid.stories.tsx +59 -0
  64. package/src/components/EditorGrid.tabs.test.tsx +118 -0
  65. package/src/components/EditorGrid.tsx +368 -0
  66. package/src/components/EditorGrid.zoom.test.tsx +68 -0
  67. package/src/components/FileContentsView.stories.tsx +16 -0
  68. package/src/components/FileContentsView.test.tsx +29 -0
  69. package/src/components/FileContentsView.tsx +98 -0
  70. package/src/components/KeybindsDialog.stories.tsx +31 -0
  71. package/src/components/KeybindsDialog.tsx +103 -0
  72. package/src/components/MenuBar.stories.tsx +28 -0
  73. package/src/components/MenuBar.tsx +44 -0
  74. package/src/components/OverlayScrollbar.stories.tsx +33 -0
  75. package/src/components/OverlayScrollbar.test.tsx +139 -0
  76. package/src/components/OverlayScrollbar.tsx +129 -0
  77. package/src/components/PaneGrid.stories.tsx +84 -0
  78. package/src/components/PaneGrid.tsx +128 -0
  79. package/src/components/PanelColumn.stories.tsx +25 -0
  80. package/src/components/PanelColumn.tsx +95 -0
  81. package/src/components/ProjectTreeView.stories.tsx +25 -0
  82. package/src/components/ProjectTreeView.test.tsx +32 -0
  83. package/src/components/ProjectTreeView.tsx +83 -0
  84. package/src/components/PromptDialog.stories.tsx +36 -0
  85. package/src/components/PromptDialog.tsx +87 -0
  86. package/src/components/PrototypeContentView.stories.tsx +19 -0
  87. package/src/components/PrototypeContentView.tsx +446 -0
  88. package/src/components/RootOverlay.stories.tsx +44 -0
  89. package/src/components/RootOverlay.tsx +35 -0
  90. package/src/components/ScrollArea.stories.tsx +20 -0
  91. package/src/components/ScrollArea.tsx +44 -0
  92. package/src/components/SettingsDialog.stories.tsx +117 -0
  93. package/src/components/SettingsDialog.tsx +195 -0
  94. package/src/components/SettingsRows.stories.tsx +18 -0
  95. package/src/components/SettingsRows.tsx +112 -0
  96. package/src/components/ShellHeader.stories.tsx +50 -0
  97. package/src/components/ShellHeader.tsx +137 -0
  98. package/src/components/ThreadFooter.stories.tsx +30 -0
  99. package/src/components/ThreadFooter.test.tsx +31 -0
  100. package/src/components/ThreadFooter.tsx +54 -0
  101. package/src/components/ThreadTree.stories.tsx +46 -0
  102. package/src/components/ThreadTree.tsx +219 -0
  103. package/src/components/ThreadView.selection.test.tsx +812 -0
  104. package/src/components/ThreadView.slash.test.tsx +158 -0
  105. package/src/components/ThreadView.stories.tsx +26 -0
  106. package/src/components/ThreadView.tsx +330 -0
  107. package/src/components/ThreadsSidebar.stories.tsx +36 -0
  108. package/src/components/ThreadsSidebar.tsx +71 -0
  109. package/src/components/Toast.stories.tsx +12 -0
  110. package/src/components/Toast.tsx +45 -0
  111. package/src/components/Tooltip.stories.tsx +26 -0
  112. package/src/components/Tooltip.tsx +72 -0
  113. package/src/components/TreePane.stories.tsx +106 -0
  114. package/src/components/TreePane.tsx +157 -0
  115. package/src/components/WalkWizard.stories.tsx +100 -0
  116. package/src/components/WalkWizard.tsx +152 -0
  117. package/src/components/WelcomePlayground.stories.tsx +12 -0
  118. package/src/components/WelcomePlayground.test.tsx +47 -0
  119. package/src/components/WelcomePlayground.tsx +140 -0
  120. package/src/components/__snapshots__/stories.test.tsx.snap +1845 -0
  121. package/src/components/agent-launcher.stories.tsx +26 -0
  122. package/src/components/agent-launcher.test.tsx +50 -0
  123. package/src/components/agent-launcher.tsx +149 -0
  124. package/src/components/diff-content-view-layout.test.ts +198 -0
  125. package/src/components/diff-content-view-layout.ts +84 -0
  126. package/src/components/editor-grid.test.ts +86 -0
  127. package/src/components/editor-grid.ts +138 -0
  128. package/src/components/fade-title.test.ts +49 -0
  129. package/src/components/fade-title.ts +61 -0
  130. package/src/components/file-tree.test.ts +60 -0
  131. package/src/components/file-tree.ts +112 -0
  132. package/src/components/pages/Workspace.stories.tsx +181 -0
  133. package/src/components/primitives/Button.stories.tsx +32 -0
  134. package/src/components/primitives/Button.tsx +52 -0
  135. package/src/components/primitives/Card.stories.tsx +25 -0
  136. package/src/components/primitives/Card.tsx +60 -0
  137. package/src/components/primitives/Dialog.stories.tsx +24 -0
  138. package/src/components/primitives/Dialog.tsx +66 -0
  139. package/src/components/primitives/IconButton.stories.tsx +36 -0
  140. package/src/components/primitives/IconButton.tsx +62 -0
  141. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  142. package/src/components/primitives/StatusBar.tsx +23 -0
  143. package/src/components/primitives/Tabs.stories.tsx +29 -0
  144. package/src/components/primitives/Tabs.tsx +102 -0
  145. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  146. package/src/components/primitives/Toolbar.tsx +14 -0
  147. package/src/components/primitives/Tree.stories.tsx +124 -0
  148. package/src/components/primitives/Tree.tsx +96 -0
  149. package/src/components/primitives/frame.test.ts +12 -0
  150. package/src/components/primitives/frame.ts +14 -0
  151. package/src/components/primitives/icons.ts +61 -0
  152. package/src/components/primitives/tree-model.test.ts +99 -0
  153. package/src/components/primitives/tree-model.ts +137 -0
  154. package/src/components/quick-actions-editor.stories.tsx +54 -0
  155. package/src/components/quick-actions-editor.test.tsx +49 -0
  156. package/src/components/quick-actions-editor.tsx +150 -0
  157. package/src/components/session-tree.test.ts +134 -0
  158. package/src/components/session-tree.ts +108 -0
  159. package/src/components/stories-app.tsx +184 -0
  160. package/src/components/stories.test.tsx +66 -0
  161. package/src/components/story-fixtures.ts +102 -0
  162. package/src/components/story-tree.test.ts +100 -0
  163. package/src/components/story.ts +142 -0
  164. package/src/components/syntax-highlight.ts +110 -0
  165. package/src/components/terminal-pane.test.tsx +40 -0
  166. package/src/components/terminal-pane.ts +199 -0
  167. package/src/components/theme-context.stories.tsx +46 -0
  168. package/src/components/theme-context.tsx +37 -0
  169. package/src/components/truncate-text.ts +7 -0
  170. package/src/config.test.ts +461 -31
  171. package/src/config.ts +389 -57
  172. package/src/diff-fold.test.ts +78 -0
  173. package/src/diff-fold.ts +126 -0
  174. package/src/diff-hunk-curate.test.ts +315 -0
  175. package/src/diff-hunk-curate.ts +153 -0
  176. package/src/diff-intraline.test.ts +165 -0
  177. package/src/diff-intraline.ts +146 -0
  178. package/src/diff-syntax.test.ts +112 -0
  179. package/src/diff-syntax.ts +179 -0
  180. package/src/discussions.ts +79 -0
  181. package/src/editor.test.ts +149 -0
  182. package/src/editor.ts +139 -9
  183. package/src/ghostty-terminal.test.ts +85 -0
  184. package/src/ghostty-terminal.ts +199 -0
  185. package/src/herdr.test.ts +9 -34
  186. package/src/herdr.ts +25 -21
  187. package/src/index.ts +11 -0
  188. package/src/integrations.test.ts +41 -0
  189. package/src/integrations.ts +32 -0
  190. package/src/intent-dispatch.test.ts +698 -0
  191. package/src/intent-dispatch.ts +700 -0
  192. package/src/key-bindings.test.ts +156 -0
  193. package/src/key-bindings.ts +465 -0
  194. package/src/keymap.test.ts +611 -0
  195. package/src/keymap.ts +410 -0
  196. package/src/kitty-image.test.ts +66 -0
  197. package/src/kitty-image.ts +138 -0
  198. package/src/mark-runs.ts +120 -0
  199. package/src/prototype-browser.test.ts +56 -0
  200. package/src/prototype-browser.ts +307 -0
  201. package/src/prototype-pixels.tsx +35 -0
  202. package/src/pty.test.ts +81 -0
  203. package/src/pty.ts +245 -0
  204. package/src/ready-signal.test.ts +85 -0
  205. package/src/ready-signal.ts +52 -0
  206. package/src/run.ts +30 -9
  207. package/src/serve.test.ts +24 -12
  208. package/src/serve.ts +20 -9
  209. package/src/share.ts +215 -0
  210. package/src/skills.test.ts +74 -0
  211. package/src/skills.ts +68 -0
  212. package/src/slash-palette.test.ts +54 -0
  213. package/src/slash-palette.ts +104 -0
  214. package/src/split-diff.test.ts +110 -0
  215. package/src/split-diff.ts +149 -0
  216. package/src/test-support.test.ts +264 -0
  217. package/src/test-support.ts +355 -0
  218. package/src/theme-presets.test.ts +51 -0
  219. package/src/theme-presets.ts +214 -0
  220. package/src/theme.ts +84 -19
  221. package/src/thread-chords.test.ts +170 -0
  222. package/src/thread-chords.ts +169 -0
  223. package/src/thread-controller.curate.test.ts +438 -0
  224. package/src/thread-controller.live-diff.test.ts +199 -0
  225. package/src/thread-controller.share.test.ts +472 -0
  226. package/src/thread-controller.tree.test.ts +255 -0
  227. package/src/thread-controller.ts +1509 -0
  228. package/src/thread-selection.test.ts +210 -0
  229. package/src/thread-selection.ts +243 -0
  230. package/src/tree-view.test.ts +128 -0
  231. package/src/tree-view.ts +148 -0
  232. package/src/use-annotation-surface.tsx +851 -0
  233. package/src/use-changes-workbench.ts +153 -0
  234. package/src/use-frame-measure.ts +38 -0
  235. package/src/use-settings-dialog.tsx +274 -0
  236. package/src/use-terminal-virtualizer.ts +233 -0
  237. package/src/version.ts +5 -0
  238. package/src/view-diff.test.ts +66 -9
  239. package/src/view-diff.ts +231 -23
  240. package/src/view-plan.regression.test.ts +60 -0
  241. package/src/view-plan.test.ts +406 -0
  242. package/src/view-plan.ts +570 -0
  243. package/src/walk.test.ts +121 -0
  244. package/src/walk.ts +74 -0
  245. package/src/syntax.test.ts +0 -41
  246. package/src/syntax.ts +0 -88
  247. package/src/view.test.ts +0 -157
  248. package/src/view.ts +0 -312
@@ -1,18 +1,23 @@
1
- /**
2
- * Observer mode (tier 2): the App with readOnly renders and navigates like the
3
- * controller's, but every mutating verb answers "observer - read-only" and
4
- * leaves daemon state untouched.
5
- */
1
+ /** Observer mode (tier 2): the App with readOnly renders and navigates like the controller's, but every mutating primitive answers "observer - read-only" and leaves daemon state untouched. */
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 { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
9
+ import { makeAnchor, parseBlocks, type Thread } from "@cueloop/schema";
15
10
  import { App } from "./App";
11
+ import {
12
+ clickText,
13
+ dragText,
14
+ frameRow,
15
+ isolateUserConfig,
16
+ pressKey,
17
+ renderReadyApp,
18
+ typeText,
19
+ waitForText,
20
+ } from "./test-support";
16
21
 
17
22
  const PLAN = `# Migration Plan
18
23
 
@@ -27,113 +32,164 @@ The daemon persists sessions to disk atomically.
27
32
  `;
28
33
 
29
34
  let home: string;
35
+ let restoreUserConfig: () => void;
30
36
  let server: DaemonServer;
31
- let session: ReviewSession;
37
+ let session: Thread;
32
38
 
33
39
  beforeEach(() => {
34
40
  home = mkdtempSync(join(tmpdir(), "cueloop-observer-"));
41
+ restoreUserConfig = isolateUserConfig(home);
35
42
  server = new DaemonServer({ home, idleExitMs: 0 });
36
43
  server.start();
37
44
  session = server.core.sessionCreate({
38
45
  workspace: { repoRoot: "/repo", branch: "main" },
39
- artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
46
+ artifact: {
47
+ type: "plan",
48
+ content: PLAN,
49
+ meta: { title: "Migration Plan", planPath: "plan.md" },
50
+ },
40
51
  });
41
52
  });
42
53
  afterEach(() => {
54
+ restoreUserConfig();
43
55
  server.stop();
44
56
  rmSync(home, { recursive: true, force: true });
45
57
  });
46
58
 
47
59
  async function renderObserver() {
48
- const setup = await testRender(<App home={home} sessionId={session.id} readOnly />, {
60
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} readOnly />, {
49
61
  width: 120,
50
62
  height: 32,
51
63
  });
52
- for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
53
- await Bun.sleep(25);
54
- await setup.renderOnce();
55
- }
56
- await setup.renderOnce();
64
+
65
+ await waitForText(setup, "cueloop");
66
+
57
67
  return setup;
58
68
  }
59
69
 
60
70
  type Setup = Awaited<ReturnType<typeof renderObserver>>;
61
71
 
62
- async function press(setup: Setup, k: string): Promise<void> {
63
- if (k === "enter") setup.mockInput.pressKey("RETURN");
64
- else await setup.mockInput.typeText(k);
65
- await Bun.sleep(15);
66
- await setup.renderOnce();
67
- }
68
-
69
- /** Session state that any mutating verb would change. */
72
+ /** Session state that any mutating primitive would change. */
70
73
  function snapshot() {
71
- const s = server.core.sessionGet(session.id);
72
- return { annotations: s.annotations.length, workingCopy: s.workingCopy, status: s.status };
74
+ const stored = server.core.sessionGet(session.id);
75
+
76
+ return {
77
+ annotations: stored.annotations.length,
78
+ workingCopy: stored.workingCopy,
79
+ status: stored.status,
80
+ };
73
81
  }
74
82
 
75
83
  describe("observer rendering", () => {
76
- test("shows the observer badge and read-only hint bar", async () => {
84
+ test("the minimal header mirrors the thread name, with no observer badge", async () => {
85
+ // Arrange
77
86
  const setup = await renderObserver();
87
+
88
+ // Assert - the header shows the thread title only; read-only is enforced by
89
+ // the blocked primitives below, not a header badge
78
90
  const frame = setup.captureCharFrame();
79
- expect(frame).toContain("· observer");
80
- expect(frame).toContain("observer - read-only · j/k move");
81
- expect(frame).not.toContain("⏎ submit");
91
+
92
+ expect(frame).toContain("Migration Plan");
93
+ expect(frame).not.toContain("· observer");
82
94
  });
83
95
  });
84
96
 
85
- describe("observer verbs are blocked", () => {
86
- for (const [key, verb] of [
87
- ["c", "comment"],
88
- ["s", "suggest"],
89
- ["x", "cut"],
90
- ["e", "edit"],
91
- ["enter", "submit"],
92
- ] as const) {
93
- test(`${verb} (${key}) answers observer - read-only and mutates nothing`, async () => {
97
+ describe("observer primitives are blocked", () => {
98
+ const attempts: Array<[string, (setup: Setup) => Promise<void>]> = [
99
+ ["comment (typing)", (setup) => typeText(setup, "c")],
100
+ ["cut (option+x)", (setup) => pressKey(setup, "x", { meta: true })],
101
+ ["edit (ctrl+e)", (setup) => pressKey(setup, "e", { ctrl: true })],
102
+ ["submit (cmd+enter)", (setup) => pressKey(setup, "RETURN", { meta: true })],
103
+ ];
104
+
105
+ for (const [primitive, attempt] of attempts) {
106
+ test(`${primitive} answers observer - read-only and mutates nothing`, async () => {
94
107
  const setup = await renderObserver();
95
108
  const before = snapshot();
96
- await press(setup, "j");
97
- await press(setup, "j");
98
- await press(setup, key);
99
- await Bun.sleep(60);
100
- await setup.renderOnce();
101
- const frame = setup.captureCharFrame();
102
- expect(frame).toContain("observer - read-only");
103
- // no overlay opened: compose/submit bars never appear
104
- expect(frame).not.toContain("COMMENT ON");
105
- expect(frame).not.toContain("SUGGEST REPLACEMENT");
106
- expect(frame).not.toContain("verdict ←/→");
109
+
110
+ await clickText(setup, "daemon");
111
+ await attempt(setup);
112
+ const frame = await waitForText(setup, "observer - read-only");
113
+
114
+ // no draft card and no submit card ever appear
115
+ expect(frame).not.toContain("● c");
116
+ expect(frame).not.toContain("[Approve]");
107
117
  expect(snapshot()).toEqual(before);
108
118
  });
109
119
  }
110
120
 
111
- test("span mode c/s is blocked too", async () => {
121
+ test("typing over a marked span is blocked too", async () => {
122
+ // Arrange
112
123
  const setup = await renderObserver();
113
- await press(setup, "j");
114
- await press(setup, "j");
115
- await press(setup, "v");
116
- await press(setup, "c");
117
- await setup.renderOnce();
118
- const frame = setup.captureCharFrame();
119
- expect(frame).toContain("observer - read-only");
120
- expect(frame).not.toContain("COMMENT ON");
124
+
125
+ // Act
126
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
127
+ await typeText(setup, "c");
128
+
129
+ // Assert
130
+ const frame = await waitForText(setup, "observer - read-only");
131
+
132
+ expect(frame).not.toContain("● c");
121
133
  expect(snapshot().annotations).toBe(0);
122
134
  });
123
135
  });
124
136
 
137
+ describe("a resolved review is read-only for its owner too", () => {
138
+ test("typing, deleting a card, and editing answer review submitted - read-only", async () => {
139
+ // Arrange: a comment exists and the verdict is in
140
+ server.core.sessionAnnotate(session.id, {
141
+ id: "a_done",
142
+ kind: "comment",
143
+ anchor: makeAnchor(parseBlocks(PLAN), 2, 0, 10),
144
+ body: "settled",
145
+ });
146
+ server.core.sessionResolve(session.id, "approve", "");
147
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
148
+ width: 120,
149
+ height: 32,
150
+ });
151
+
152
+ await waitForText(setup, "settled");
153
+ const before = snapshot();
154
+
155
+ // Act + Assert: a draft never opens
156
+ await clickText(setup, "daemon");
157
+ await typeText(setup, "c");
158
+ await waitForText(setup, "review submitted - read-only");
159
+ expect(setup.captureCharFrame()).not.toContain("● c");
160
+
161
+ // Act + Assert: the focused card cannot be deleted or edited
162
+ await pressKey(setup, "n", { meta: true });
163
+ await pressKey(setup, "BACKSPACE", { meta: true });
164
+ await pressKey(setup, "e", { meta: true });
165
+ await pressKey(setup, "x", { meta: true });
166
+ expect(snapshot()).toEqual(before);
167
+ expect(server.core.sessionGet(session.id).annotations).toHaveLength(1);
168
+ });
169
+ });
170
+
125
171
  describe("observer navigation still works", () => {
126
- test("j/k moves the cursor between blocks", async () => {
172
+ test("↓ moves the caret between blocks", async () => {
173
+ // Arrange
127
174
  const setup = await renderObserver();
128
- await press(setup, "j");
129
- await press(setup, "j");
175
+
176
+ // Act
177
+ await pressKey(setup, "ARROW_DOWN");
178
+ await pressKey(setup, "ARROW_DOWN");
130
179
  await setup.renderOnce();
131
- const lines = setup.captureCharFrame().split("\n");
132
- const cursorLine = lines.find((l) => l.includes("▎"))!;
133
- expect(cursorLine).toContain("persists sessions");
180
+
181
+ // Assert: the caret cell is painted on the paragraph's row
182
+ const caretRow = setup
183
+ .captureSpans()
184
+ .lines.findIndex((line) =>
185
+ line.spans.some((span) => span.bg.toInts().slice(0, 3).join() === "86,91,104"),
186
+ );
187
+
188
+ expect(caretRow).toBe(frameRow(setup, "persists sessions"));
134
189
  });
135
190
 
136
- test("n focuses annotations made by the controller", async () => {
191
+ test("option+n focuses annotations made by the controller", async () => {
192
+ // Arrange
137
193
  server.core.sessionAnnotate(session.id, {
138
194
  id: "a_obs1",
139
195
  kind: "comment",
@@ -141,8 +197,13 @@ describe("observer navigation still works", () => {
141
197
  body: "From the controller.",
142
198
  });
143
199
  const setup = await renderObserver();
144
- await press(setup, "n");
200
+
201
+ // Act - the observer navigates to the annotation without mutating anything
202
+ await pressKey(setup, "n", { meta: true });
145
203
  await setup.renderOnce();
146
- expect(setup.captureCharFrame()).toContain("▸ COMMENT");
204
+
205
+ // Assert - the observer reads the controller's comment inline in the thread
206
+ await waitForText(setup, "From the controller.");
207
+ expect(snapshot().annotations).toBe(1);
147
208
  });
148
209
  });
@@ -1,14 +1,14 @@
1
- /** Plan rendering (#67): code blocks verbatim + contained, spacing never collapses. */
1
+ /** Plan rendering: code blocks verbatim + contained, spacing never collapses. */
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 { isolateUserConfig, waitForText, renderReadyApp } from "./test-support";
12
12
 
13
13
  const PLAN = `# Render Plan
14
14
 
@@ -29,11 +29,13 @@ Deploys block below the threshold.
29
29
  `;
30
30
 
31
31
  let home: string;
32
+ let restoreUserConfig: () => void;
32
33
  let server: DaemonServer;
33
- let session: ReviewSession;
34
+ let session: Thread;
34
35
 
35
36
  beforeEach(() => {
36
37
  home = mkdtempSync(join(tmpdir(), "cueloop-render-"));
38
+ restoreUserConfig = isolateUserConfig(home);
37
39
  server = new DaemonServer({ home, idleExitMs: 0 });
38
40
  server.start();
39
41
  session = server.core.sessionCreate({
@@ -42,67 +44,91 @@ beforeEach(() => {
42
44
  });
43
45
  });
44
46
  afterEach(() => {
47
+ restoreUserConfig();
45
48
  server.stop();
46
49
  rmSync(home, { recursive: true, force: true });
47
50
  });
48
51
 
49
52
  async function frame(): Promise<string> {
50
- const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 36 });
51
- const deadline = Date.now() + 10_000;
52
- while (Date.now() < deadline && !setup.captureCharFrame().includes("Render Plan")) {
53
- await Bun.sleep(25);
54
- await setup.renderOnce();
55
- }
56
- // give the async highlighter a beat, then take the final frame
57
- await Bun.sleep(150);
58
- await setup.renderOnce();
53
+ const setup = await renderReadyApp(<App home={home} sessionId={session.id} />, {
54
+ width: 120,
55
+ height: 36,
56
+ });
57
+
58
+ await waitForText(setup, "Render Plan");
59
+ // async highlights settle within the visual-idle wait
60
+ await setup.waitForVisualIdle();
61
+
59
62
  return setup.captureCharFrame();
60
63
  }
61
64
 
62
65
  describe("code blocks", () => {
63
66
  test("lines render verbatim: indentation preserved, no word-wrap", async () => {
64
- const f = await frame();
65
- expect(f).toContain(" const blocked = full < 0.6;");
66
- expect(f).toContain(' return <text>{blocked ? "blocked" : "clear"}</text>;');
67
+ // Arrange
68
+ const rendered = await frame();
69
+
70
+ // Assert
71
+ expect(rendered).toContain(" const blocked = full < 0.6;");
72
+ expect(rendered).toContain(' return <text>{blocked ? "blocked" : "clear"}</text>;');
67
73
  });
68
74
 
69
75
  test("the block carries its language tag", async () => {
70
- const f = await frame();
71
- expect(f).toContain("tsx");
76
+ // Arrange
77
+ const rendered = await frame();
78
+
79
+ // Assert
80
+ expect(rendered).toContain("tsx");
72
81
  });
73
82
  });
74
83
 
75
84
  describe("block spacing", () => {
85
+ // the review rail paints box edges on the right, so read the document
86
+ // column (everything left of the first rail glyph) before asserting spacing
87
+ const planColumn = (line: string): string => line.split(/[│╭╰]/)[0]!.trimEnd();
88
+
76
89
  test("a code block never glues to the list item above it", async () => {
77
- const lines = (await frame()).split("\n").map((l) => l.trimEnd());
78
- const li = lines.findIndex((l) => l.includes("wire the duck over MQTT"));
79
- const codeHeader = lines.findIndex((l) => l.trim().startsWith("tsx"));
80
- expect(li).toBeGreaterThan(-1);
81
- expect(codeHeader).toBeGreaterThan(li);
82
- expect(lines.slice(li + 1, codeHeader).some((l) => l === "")).toBe(true);
90
+ // Arrange
91
+ const lines = (await frame()).split("\n").map(planColumn);
92
+ const listItemLineIndex = lines.findIndex((line) => line.includes("wire the duck over MQTT"));
93
+ const codeHeader = lines.findIndex((line) => line.trim().startsWith("tsx"));
94
+
95
+ // Assert
96
+ expect(listItemLineIndex).toBeGreaterThan(-1);
97
+ expect(codeHeader).toBeGreaterThan(listItemLineIndex);
98
+ expect(lines.slice(listItemLineIndex + 1, codeHeader).some((line) => line === "")).toBe(true);
83
99
  });
84
100
 
85
101
  test("a heading never sits directly on the previous block", async () => {
86
- const lines = (await frame()).split("\n").map((l) => l.trimEnd());
87
- const gate = lines.findIndex((l) => l.trim() === "Gate");
102
+ // Arrange
103
+ const lines = (await frame()).split("\n").map(planColumn);
104
+ const gate = lines.findIndex((line) => line.trim() === "Gate");
105
+
106
+ // Assert
88
107
  expect(gate).toBeGreaterThan(0);
89
108
  expect(lines[gate - 1]).toBe("");
90
109
  });
91
110
 
92
111
  test("consecutive list items stay tight", async () => {
112
+ // Arrange
93
113
  const listPlan = "# T\n\n- one\n- two\n";
94
114
  const s2 = server.core.sessionCreate({
95
115
  workspace: { repoRoot: "/repo", branch: "main" },
96
116
  artifact: { type: "plan", content: listPlan, meta: {} },
97
117
  });
98
- const setup = await testRender(<App home={home} sessionId={s2.id} />, { width: 120, height: 30 });
99
- const deadline = Date.now() + 10_000;
100
- while (Date.now() < deadline && !setup.captureCharFrame().includes("one")) {
101
- await Bun.sleep(25);
102
- await setup.renderOnce();
103
- }
104
- const lines = setup.captureCharFrame().split("\n").map((l) => l.trimEnd());
105
- const one = lines.findIndex((l) => l.includes("- one"));
106
- expect(lines[one + 1]).toContain("- two");
118
+ const setup = await renderReadyApp(<App home={home} sessionId={s2.id} />, {
119
+ width: 120,
120
+ height: 30,
121
+ });
122
+
123
+ await waitForText(setup, "one");
124
+
125
+ // Assert
126
+ const lines = setup
127
+ .captureCharFrame()
128
+ .split("\n")
129
+ .map((line) => line.trimEnd());
130
+ const one = lines.findIndex((line) => line.includes("· one"));
131
+
132
+ expect(lines[one + 1]).toContain("· two");
107
133
  });
108
134
  });
@@ -0,0 +1,191 @@
1
+ /** The send-message confirm: cmd+enter opens a centered overlay with a verdict selector, summary input, and word-buttons. Char-frame assertions over the real App and an in-process daemon, like App.test.tsx. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import React from "react";
8
+ import { testRender } from "@opentui/react/test-utils";
9
+ import { DaemonServer } from "@cueloop/daemon";
10
+ import { makeAnchor, parseBlocks, type Thread } from "@cueloop/schema";
11
+ import { App } from "./App";
12
+ import {
13
+ isolateUserConfig,
14
+ press,
15
+ pressKey,
16
+ settle,
17
+ waitForText,
18
+ waitForTextGone,
19
+ } from "./test-support";
20
+
21
+ const PLAN = `# Migration Plan
22
+
23
+ ## Context
24
+
25
+ The daemon persists sessions to disk atomically.
26
+
27
+ ## Steps
28
+
29
+ - move the store
30
+ - add recovery
31
+ `;
32
+
33
+ let home: string;
34
+ let restoreUserConfig: () => void;
35
+ let server: DaemonServer;
36
+ let session: Thread;
37
+
38
+ beforeEach(() => {
39
+ home = mkdtempSync(join(tmpdir(), "cueloop-confirm-"));
40
+ restoreUserConfig = isolateUserConfig(home);
41
+ server = new DaemonServer({ home, idleExitMs: 0 });
42
+ server.start();
43
+ session = server.core.sessionCreate({
44
+ workspace: { repoRoot: "/repo", branch: "main" },
45
+ artifact: {
46
+ type: "plan",
47
+ content: PLAN,
48
+ meta: { title: "Migration Plan", planPath: "plan.md" },
49
+ },
50
+ });
51
+ });
52
+ afterEach(() => {
53
+ restoreUserConfig();
54
+ server.stop();
55
+ rmSync(home, { recursive: true, force: true });
56
+ });
57
+
58
+ async function renderApp(options: { readOnly?: boolean } = {}) {
59
+ const setup = await testRender(
60
+ <App home={home} sessionId={session.id} readOnly={options.readOnly ?? false} />,
61
+ {
62
+ width: 120,
63
+ height: 32,
64
+ },
65
+ );
66
+
67
+ await waitForText(setup, "cueloop");
68
+
69
+ return setup;
70
+ }
71
+
72
+ /** Seed annotations directly through the daemon core (all on one block). */
73
+ function seedAnnotations(count: number): void {
74
+ const blocks = parseBlocks(PLAN);
75
+
76
+ for (let index = 1; index <= count; index++) {
77
+ server.core.sessionAnnotate(session.id, {
78
+ id: `a_confirm_${index}`,
79
+ kind: "comment",
80
+ anchor: makeAnchor(blocks, 2, 0, 10),
81
+ body: `note ${String(index).padStart(2, "0")}`,
82
+ });
83
+ }
84
+ }
85
+
86
+ describe("send message confirm", () => {
87
+ test("cmd+enter opens the send-message confirm overlay", async () => {
88
+ // Arrange
89
+ const setup = await renderApp();
90
+
91
+ // Assert - the footer carries the send control before the overlay opens
92
+ expect(setup.captureCharFrame()).toContain("send message");
93
+
94
+ // Act
95
+ await pressKey(setup, "RETURN", { meta: true });
96
+
97
+ // Assert - the overlay: verdict selector and word-buttons
98
+ await waitForText(setup, "[Approve]"); // nothing pending: approve default
99
+ const frame = setup.captureCharFrame();
100
+
101
+ expect(frame).toContain("[Approve]");
102
+ expect(frame).toContain(" send message ");
103
+ expect(frame).toContain(" Cancel ");
104
+ });
105
+
106
+ test("left/right cycles the verdict selector in the overlay", async () => {
107
+ // Arrange
108
+ const setup = await renderApp();
109
+
110
+ await pressKey(setup, "RETURN", { meta: true });
111
+
112
+ // Assert
113
+ await waitForText(setup, "[Approve]");
114
+
115
+ // Act
116
+ await press(setup, "right");
117
+
118
+ // Assert
119
+ await waitForText(setup, "[Changes]");
120
+
121
+ // Act
122
+ await press(setup, "right");
123
+
124
+ // Assert
125
+ await waitForText(setup, "[Comment]");
126
+
127
+ // Act
128
+ await press(setup, "left");
129
+
130
+ // Assert
131
+ await waitForText(setup, "[Changes]");
132
+ });
133
+
134
+ test("esc closes the overlay", async () => {
135
+ // Arrange
136
+ const setup = await renderApp();
137
+
138
+ await pressKey(setup, "RETURN", { meta: true });
139
+
140
+ // Assert
141
+ await waitForText(setup, "[Approve]");
142
+
143
+ // Act
144
+ await press(setup, "escape");
145
+
146
+ // Assert - a bare ESC settles after the parser's escape-sequence window
147
+ const frame = await waitForTextGone(setup, "[Approve]");
148
+
149
+ expect(frame).not.toContain(" Cancel ");
150
+ });
151
+
152
+ test("enter in the overlay resolves the session through the controller", async () => {
153
+ // Arrange
154
+ seedAnnotations(1);
155
+ const setup = await renderApp();
156
+
157
+ // Act
158
+ await pressKey(setup, "RETURN", { meta: true });
159
+
160
+ // Assert
161
+ await waitForText(setup, "[Changes]"); // pending items: request changes default
162
+
163
+ // Act
164
+ await setup.mockInput.typeText("Tighten the steps.");
165
+ await press(setup, "enter");
166
+
167
+ // Assert - the completion flow after submit is unchanged
168
+ await waitForText(setup, "feedback sent");
169
+ const stored = server.core.sessionGet(session.id);
170
+
171
+ expect(stored.status).toBe("resolved");
172
+ expect(stored.verdict!.kind).toBe("request_changes");
173
+ });
174
+
175
+ test("read-only observers cannot open the confirm overlay", async () => {
176
+ // Arrange
177
+ seedAnnotations(1);
178
+ const setup = await renderApp({ readOnly: true });
179
+
180
+ // Act
181
+ await pressKey(setup, "RETURN", { meta: true });
182
+ await settle(setup);
183
+
184
+ // Assert - no overlay, and the session stays pending
185
+ const frame = setup.captureCharFrame();
186
+
187
+ expect(frame).not.toContain("[Changes]");
188
+ expect(frame).not.toContain(" Cancel ");
189
+ expect(server.core.sessionGet(session.id).status).toBe("pending");
190
+ });
191
+ });