@cueloop/client 0.1.0-alpha.6 → 0.1.0-alpha.61

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 (161) 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 +37 -33
  8. package/src/App.appearance.test.tsx +51 -0
  9. package/src/App.autoclose.test.tsx +73 -47
  10. package/src/App.diff.test.tsx +169 -22
  11. package/src/App.export.test.tsx +49 -37
  12. package/src/App.plan-review.test.tsx +714 -0
  13. package/src/App.prototype.test.tsx +194 -0
  14. package/src/App.readonly.test.tsx +69 -35
  15. package/src/App.rendering.test.tsx +59 -31
  16. package/src/App.review-panel.test.tsx +143 -0
  17. package/src/App.submit-confirm.test.tsx +229 -0
  18. package/src/App.test.tsx +139 -68
  19. package/src/App.toast.test.tsx +77 -0
  20. package/src/App.tsx +0 -0
  21. package/src/App.walk.test.tsx +299 -0
  22. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  23. package/src/app-key-state.ts +61 -0
  24. package/src/app-screens.tsx +270 -0
  25. package/src/app-view-model.ts +307 -0
  26. package/src/attribution.test.ts +31 -0
  27. package/src/attribution.ts +42 -0
  28. package/src/clipboard.test.ts +23 -0
  29. package/src/clipboard.ts +33 -0
  30. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  31. package/src/components/AnnotationCard.stories.tsx +94 -0
  32. package/src/components/AnnotationCard.tsx +250 -0
  33. package/src/components/Breadcrumb.stories.tsx +19 -0
  34. package/src/components/Breadcrumb.tsx +45 -0
  35. package/src/components/CodeBlock.stories.tsx +42 -0
  36. package/src/components/CodeBlock.tsx +84 -0
  37. package/src/components/CompletionOverlay.stories.tsx +23 -0
  38. package/src/components/CompletionOverlay.tsx +60 -0
  39. package/src/components/ConfirmCard.stories.tsx +35 -0
  40. package/src/components/ConfirmCard.tsx +129 -0
  41. package/src/components/ConfirmDialog.stories.tsx +21 -0
  42. package/src/components/ConfirmDialog.tsx +66 -0
  43. package/src/components/DiffSheet.scroll.test.tsx +135 -0
  44. package/src/components/DiffSheet.stories.tsx +78 -0
  45. package/src/components/DiffSheet.syntax.test.tsx +65 -0
  46. package/src/components/DiffSheet.tsx +374 -0
  47. package/src/components/InboxList.stories.tsx +31 -0
  48. package/src/components/InboxList.tsx +64 -0
  49. package/src/components/KeybindsDialog.stories.tsx +31 -0
  50. package/src/components/KeybindsDialog.tsx +59 -0
  51. package/src/components/MarkerPopover.stories.tsx +58 -0
  52. package/src/components/MarkerPopover.tsx +109 -0
  53. package/src/components/MenuBar.stories.tsx +37 -0
  54. package/src/components/MenuBar.tsx +75 -0
  55. package/src/components/PlanSheet.stories.tsx +113 -0
  56. package/src/components/PlanSheet.tsx +422 -0
  57. package/src/components/PromptDialog.stories.tsx +36 -0
  58. package/src/components/PromptDialog.tsx +80 -0
  59. package/src/components/PrototypeSheet.stories.tsx +20 -0
  60. package/src/components/PrototypeSheet.tsx +450 -0
  61. package/src/components/ReviewPanel.stories.tsx +86 -0
  62. package/src/components/ReviewPanel.tsx +103 -0
  63. package/src/components/ReviewRail.stories.tsx +218 -0
  64. package/src/components/ReviewRail.tsx +318 -0
  65. package/src/components/SettingsDialog.stories.tsx +111 -0
  66. package/src/components/SettingsDialog.tsx +147 -0
  67. package/src/components/SettingsRows.stories.tsx +18 -0
  68. package/src/components/SettingsRows.tsx +112 -0
  69. package/src/components/Toast.stories.tsx +12 -0
  70. package/src/components/Toast.tsx +45 -0
  71. package/src/components/WalkWizard.stories.tsx +100 -0
  72. package/src/components/WalkWizard.tsx +152 -0
  73. package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
  74. package/src/components/agent-launcher.stories.tsx +26 -0
  75. package/src/components/agent-launcher.test.tsx +50 -0
  76. package/src/components/agent-launcher.tsx +149 -0
  77. package/src/components/diff-sheet-layout.test.ts +128 -0
  78. package/src/components/diff-sheet-layout.ts +184 -0
  79. package/src/components/plan-sheet-run-style.ts +90 -0
  80. package/src/components/primitives/Button.stories.tsx +32 -0
  81. package/src/components/primitives/Button.tsx +52 -0
  82. package/src/components/primitives/Card.stories.tsx +25 -0
  83. package/src/components/primitives/Card.tsx +60 -0
  84. package/src/components/primitives/Dialog.stories.tsx +24 -0
  85. package/src/components/primitives/Dialog.tsx +66 -0
  86. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  87. package/src/components/primitives/StatusBar.tsx +23 -0
  88. package/src/components/primitives/Tabs.stories.tsx +29 -0
  89. package/src/components/primitives/Tabs.tsx +102 -0
  90. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  91. package/src/components/primitives/Toolbar.tsx +14 -0
  92. package/src/components/primitives/frame.test.ts +12 -0
  93. package/src/components/primitives/frame.ts +14 -0
  94. package/src/components/quick-actions-editor.stories.tsx +54 -0
  95. package/src/components/quick-actions-editor.tsx +138 -0
  96. package/src/components/stories-app.tsx +124 -0
  97. package/src/components/stories.test.tsx +66 -0
  98. package/src/components/story-fixtures.ts +98 -0
  99. package/src/components/story.ts +75 -0
  100. package/src/components/syntax-highlight.ts +102 -0
  101. package/src/components/terminal-pane.test.tsx +40 -0
  102. package/src/components/terminal-pane.ts +193 -0
  103. package/src/components/theme-context.stories.tsx +46 -0
  104. package/src/components/theme-context.tsx +37 -0
  105. package/src/components/truncate-text.ts +7 -0
  106. package/src/config.test.ts +448 -31
  107. package/src/config.ts +339 -57
  108. package/src/diff-hunk-curate.test.ts +315 -0
  109. package/src/diff-hunk-curate.ts +253 -0
  110. package/src/diff-intraline.test.ts +165 -0
  111. package/src/diff-intraline.ts +146 -0
  112. package/src/diff-syntax.test.ts +96 -0
  113. package/src/diff-syntax.ts +176 -0
  114. package/src/editor.test.ts +149 -0
  115. package/src/editor.ts +135 -9
  116. package/src/ghostty-terminal.test.ts +84 -0
  117. package/src/ghostty-terminal.ts +176 -0
  118. package/src/herdr.test.ts +9 -34
  119. package/src/herdr.ts +17 -21
  120. package/src/index.ts +10 -0
  121. package/src/integrations.test.ts +41 -0
  122. package/src/integrations.ts +32 -0
  123. package/src/intent-dispatch.test.ts +499 -0
  124. package/src/intent-dispatch.ts +585 -0
  125. package/src/key-bindings.test.ts +156 -0
  126. package/src/key-bindings.ts +452 -0
  127. package/src/keymap.test.ts +635 -0
  128. package/src/keymap.ts +404 -0
  129. package/src/kitty-image.test.ts +66 -0
  130. package/src/kitty-image.ts +138 -0
  131. package/src/prototype-browser.test.ts +56 -0
  132. package/src/prototype-browser.ts +289 -0
  133. package/src/pty.test.ts +81 -0
  134. package/src/pty.ts +237 -0
  135. package/src/review-panel.test.ts +113 -0
  136. package/src/review-panel.ts +83 -0
  137. package/src/run.ts +16 -5
  138. package/src/serve.test.ts +25 -9
  139. package/src/serve.ts +19 -8
  140. package/src/session-controller.curate.test.ts +260 -0
  141. package/src/session-controller.share.test.ts +320 -0
  142. package/src/session-controller.ts +993 -0
  143. package/src/share.ts +120 -0
  144. package/src/test-support.test.ts +60 -0
  145. package/src/test-support.ts +197 -0
  146. package/src/theme-presets.test.ts +51 -0
  147. package/src/theme-presets.ts +187 -0
  148. package/src/theme.ts +64 -17
  149. package/src/use-settings-dialog.tsx +275 -0
  150. package/src/version.ts +5 -0
  151. package/src/view-diff.test.ts +22 -8
  152. package/src/view-diff.ts +39 -23
  153. package/src/view-plan.regression.test.ts +60 -0
  154. package/src/view-plan.test.ts +406 -0
  155. package/src/view-plan.ts +554 -0
  156. package/src/walk.test.ts +121 -0
  157. package/src/walk.ts +74 -0
  158. package/src/syntax.test.ts +0 -41
  159. package/src/syntax.ts +0 -88
  160. package/src/view.test.ts +0 -157
  161. package/src/view.ts +0 -0
@@ -0,0 +1,714 @@
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. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import React from "react";
8
+ import { testRender } from "@opentui/react/test-utils";
9
+ import { DaemonServer } from "@cueloop/daemon";
10
+ import type { ReviewSession } from "@cueloop/schema";
11
+ import { App } from "./App";
12
+ import { DEFAULT_QUICK_ACTIONS, quickActionBody } from "./config";
13
+ import { DARK } from "./theme";
14
+ import {
15
+ isolateUserConfig,
16
+ press,
17
+ settle,
18
+ typeText as type,
19
+ waitForState,
20
+ waitForText,
21
+ waitForTextGone,
22
+ } from "./test-support";
23
+
24
+ const PLAN = `# Migration Plan
25
+
26
+ ## Context
27
+
28
+ The daemon persists sessions to disk atomically.
29
+
30
+ ## Steps
31
+
32
+ - move the store
33
+ - add recovery
34
+ `;
35
+
36
+ let home: string;
37
+ let server: DaemonServer;
38
+ let session: ReviewSession;
39
+ let restoreUserConfig: () => void;
40
+
41
+ beforeEach(() => {
42
+ home = mkdtempSync(join(tmpdir(), "cueloop-plan-review-"));
43
+ restoreUserConfig = isolateUserConfig(home);
44
+ server = new DaemonServer({ home, idleExitMs: 0 });
45
+ server.start();
46
+ session = server.core.sessionCreate({
47
+ workspace: { repoRoot: "/repo", branch: "main" },
48
+ artifact: {
49
+ type: "plan",
50
+ content: PLAN,
51
+ meta: { title: "Migration Plan", planPath: "plan.md", agent: "agent/worker-3" },
52
+ },
53
+ });
54
+ });
55
+ afterEach(() => {
56
+ restoreUserConfig();
57
+ server.stop();
58
+ rmSync(home, { recursive: true, force: true });
59
+ });
60
+
61
+ async function renderApp() {
62
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
63
+ width: 120,
64
+ height: 32,
65
+ });
66
+
67
+ await waitForText(setup, "cueloop");
68
+
69
+ return setup;
70
+ }
71
+
72
+ type Setup = Awaited<ReturnType<typeof renderApp>>;
73
+
74
+ /**
75
+ * Background colors (hex) of every styled span containing the needle - the
76
+ * document highlight and the rail's quote excerpt can both match.
77
+ */
78
+ function backgroundsOf(setup: Setup, needle: string): string[] {
79
+ const backgrounds: string[] = [];
80
+
81
+ for (const line of setup.captureSpans().lines) {
82
+ for (const span of line.spans) {
83
+ if (!span.text.includes(needle)) continue;
84
+ const [red, green, blue] = span.bg.toInts();
85
+
86
+ backgrounds.push(
87
+ "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""),
88
+ );
89
+ }
90
+ }
91
+
92
+ return backgrounds;
93
+ }
94
+
95
+ function foregroundsOf(setup: Setup, needle: string): string[] {
96
+ const foregrounds: string[] = [];
97
+
98
+ for (const line of setup.captureSpans().lines) {
99
+ for (const span of line.spans) {
100
+ if (!span.text.includes(needle)) continue;
101
+ const [red, green, blue] = span.fg.toInts();
102
+
103
+ foregrounds.push(
104
+ "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""),
105
+ );
106
+ }
107
+ }
108
+
109
+ return foregrounds;
110
+ }
111
+
112
+ /** Move the cursor to "The daemon persists sessions to disk atomically." */
113
+ async function toContextParagraph(setup: Setup): Promise<void> {
114
+ await press(setup, "j");
115
+ await press(setup, "j");
116
+ }
117
+
118
+ /** Click the first on-screen occurrence of a string, by its char-frame position. */
119
+ async function clickText(setup: Setup, needle: string): Promise<void> {
120
+ const lines = setup.captureCharFrame().split("\n");
121
+ const row = lines.findIndex((line) => line.includes(needle));
122
+
123
+ await setup.mockMouse.click(lines[row]!.indexOf(needle) + 1, row);
124
+ await setup.renderOnce();
125
+ }
126
+
127
+ describe("share button", () => {
128
+ test("the owner's plan header shows the Share button next to Edit", async () => {
129
+ // Arrange / Act
130
+ const setup = await renderApp();
131
+
132
+ // Assert
133
+ // both word-buttons ride the same header row, so Share sits next to Edit
134
+ const headerLine = setup
135
+ .captureCharFrame()
136
+ .split("\n")
137
+ .find((line) => line.includes("submitted by"));
138
+
139
+ expect(headerLine).toContain("Edit");
140
+ expect(headerLine).toContain("Share");
141
+ });
142
+
143
+ test("a read-only viewer (a plan shared over ssh) never sees the Share button", async () => {
144
+ // Arrange / Act
145
+ const viewer = await testRender(<App home={home} sessionId={session.id} readOnly />, {
146
+ width: 120,
147
+ height: 32,
148
+ });
149
+
150
+ await waitForText(viewer, "cueloop");
151
+
152
+ // Assert
153
+ expect(viewer.captureCharFrame()).not.toContain("Share");
154
+ });
155
+
156
+ test("a resolved plan hides Edit and Share (no re-sharing a finished review)", async () => {
157
+ // Arrange - resolve the session before opening it
158
+ server.core.sessionResolve(session.id, "approve", "");
159
+
160
+ // Act
161
+ const setup = await renderApp();
162
+
163
+ await waitForText(setup, "submitted by");
164
+
165
+ // Assert - the owner toolbar is gone once the review is resolved
166
+ const headerLine = setup
167
+ .captureCharFrame()
168
+ .split("\n")
169
+ .find((line) => line.includes("submitted by"));
170
+
171
+ expect(headerLine).not.toContain("Edit");
172
+ expect(headerLine).not.toContain("Share");
173
+ });
174
+ });
175
+
176
+ describe("edit affordance", () => {
177
+ test("the owner sees the Edit button", async () => {
178
+ // Arrange / Act
179
+ const owner = await renderApp();
180
+
181
+ // Assert
182
+ expect(owner.captureCharFrame()).toContain("Edit");
183
+ });
184
+
185
+ test("a read-only viewer (a plan shared over ssh) never sees the Edit button", async () => {
186
+ // Arrange / Act
187
+ const viewer = await testRender(<App home={home} sessionId={session.id} readOnly />, {
188
+ width: 120,
189
+ height: 32,
190
+ });
191
+
192
+ await waitForText(viewer, "cueloop");
193
+
194
+ // Assert
195
+ expect(viewer.captureCharFrame()).not.toContain("Edit");
196
+ });
197
+ });
198
+
199
+ describe("attribution", () => {
200
+ test("a collaborator's note shows their name in the card border", async () => {
201
+ // Arrange - a pulled collaborator note carries an author fingerprint
202
+ const { makeAnchor, parseBlocks } = await import("@cueloop/schema");
203
+ const blocks = parseBlocks(PLAN);
204
+ const contextBlockIndex = blocks.findIndex((block) =>
205
+ block.text.startsWith("The daemon persists"),
206
+ );
207
+
208
+ server.core.sessionAnnotate(session.id, {
209
+ id: "collab-1",
210
+ kind: "comment",
211
+ anchor: makeAnchor(blocks, contextBlockIndex, 0, 10),
212
+ body: "who owns retries?",
213
+ author: "SHA256:1a2b3c4d5e6f",
214
+ });
215
+
216
+ // Act
217
+ const setup = await renderApp();
218
+
219
+ // Assert - the short author handle rides the card border
220
+ await waitForText(setup, "1a2b3c4d");
221
+ });
222
+ });
223
+
224
+ describe("selection feeds the annotation quote", () => {
225
+ test("v anchors, l extends, c composes with the char-precise quote", async () => {
226
+ // Arrange
227
+ const setup = await renderApp();
228
+
229
+ await toContextParagraph(setup);
230
+
231
+ // Act
232
+ await press(setup, "v");
233
+ await press(setup, "l"); // "The daemon"
234
+ await press(setup, "c");
235
+
236
+ // Assert
237
+ await waitForText(setup, 'comment on "The daemon"');
238
+
239
+ // Act
240
+ await type(setup, "Which daemon?");
241
+ await press(setup, "enter");
242
+
243
+ // Assert
244
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
245
+ const stored = server.core.sessionGet(session.id);
246
+
247
+ expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
248
+ });
249
+
250
+ test("a mouse drag over the text becomes the compose quote", async () => {
251
+ // Arrange
252
+ const setup = await renderApp();
253
+ const lines = setup.captureCharFrame().split("\n");
254
+ const row = lines.findIndex((line) => line.includes("persists sessions"));
255
+ const startColumn = lines[row]!.indexOf("persists");
256
+
257
+ expect(row).toBeGreaterThan(-1);
258
+
259
+ // Act
260
+ // the drag's end cell is exclusive: release one past the last character
261
+ await setup.mockMouse.drag(startColumn, row, startColumn + "persists sessions".length, row);
262
+ await settle(setup);
263
+ await press(setup, "c");
264
+
265
+ // Assert
266
+ await waitForText(setup, 'comment on "persists sessions"');
267
+
268
+ // Act
269
+ await type(setup, "sessions plural?");
270
+ await press(setup, "enter");
271
+
272
+ // Assert
273
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
274
+ const stored = server.core.sessionGet(session.id);
275
+
276
+ expect(stored.annotations[0]!.anchor.quote).toBe("persists sessions");
277
+ });
278
+ });
279
+
280
+ describe("inline compose keeps the anchor painted", () => {
281
+ test("the selection stays painted while composing; cancel un-paints; save converts to the comment highlight", async () => {
282
+ // Arrange
283
+ const setup = await renderApp();
284
+
285
+ await toContextParagraph(setup);
286
+
287
+ // Act
288
+ await press(setup, "v");
289
+ await press(setup, "l");
290
+ await press(setup, "c");
291
+
292
+ // Assert
293
+ // compose open: the anchor is painted selection-style. The box can render
294
+ // a frame before the anchor repaint settles, so wait on the color itself.
295
+ await waitForText(setup, "Save");
296
+ expect(setup.captureCharFrame()).toContain("Cancel");
297
+ await waitForState(setup, () => backgroundsOf(setup, "The daemon").includes(DARK.accent));
298
+ expect(backgroundsOf(setup, "The daemon")).toContain(DARK.accent);
299
+
300
+ // Act
301
+ // cancel un-paints (a bare ESC settles after the parser's escape window)
302
+ await press(setup, "escape");
303
+
304
+ // Assert
305
+ await waitForTextGone(setup, 'comment on "');
306
+ expect(backgroundsOf(setup, "The daemon")).not.toContain(DARK.accent);
307
+
308
+ // Act
309
+ // save converts the paint to the kind-colored annotation highlight
310
+ await press(setup, "c");
311
+ await type(setup, "Which daemon?");
312
+ await press(setup, "enter");
313
+
314
+ // Assert
315
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
316
+ await settle(setup);
317
+ // the whole cursor block is the anchor here; check the highlight landed
318
+ const stored = server.core.sessionGet(session.id);
319
+
320
+ expect(stored.annotations.length).toBe(1);
321
+ expect(backgroundsOf(setup, stored.annotations[0]!.anchor.quote.slice(0, 20))).toContain(
322
+ DARK.markCommentBackground,
323
+ );
324
+ // renderApp plus this many frame-waits grazes the 5s default on a loaded CI
325
+ // runner, and the whole-suite publish lane has timed even 15s out; give the
326
+ // heaviest frame-wait chain generous headroom so runner load cannot flake it.
327
+ }, 60_000);
328
+
329
+ test("clicking the Cancel word-button closes the compose box", async () => {
330
+ // Arrange
331
+ const setup = await renderApp();
332
+
333
+ await toContextParagraph(setup);
334
+ await press(setup, "v");
335
+ await press(setup, "l");
336
+ await press(setup, "c");
337
+ await waitForText(setup, "Cancel");
338
+
339
+ // Act
340
+ await clickText(setup, "Cancel");
341
+
342
+ // Assert
343
+ await waitForTextGone(setup, 'comment on "');
344
+ });
345
+
346
+ test("clicking Cancel in a saved card's edit composer closes it", async () => {
347
+ // Arrange
348
+ const setup = await renderApp();
349
+
350
+ await toContextParagraph(setup);
351
+ await press(setup, "v");
352
+ await press(setup, "l");
353
+ await press(setup, "c");
354
+ await waitForText(setup, "Cancel");
355
+ await type(setup, "note body");
356
+ await press(setup, "enter");
357
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
358
+ // select the saved card, then activate it to open the in-place editor
359
+ await clickText(setup, "note body");
360
+ await clickText(setup, "note body");
361
+ await waitForText(setup, "Cancel");
362
+
363
+ // Act
364
+ await clickText(setup, "Cancel");
365
+
366
+ // Assert - the editor closes back to the plain saved card
367
+ await waitForTextGone(setup, "Cancel");
368
+ });
369
+ });
370
+
371
+ describe("compose newline convention", () => {
372
+ // Option/Alt and Shift reach the App as `meta`/`shift` on the return key.
373
+ // modifyOtherKeys encoding preserves those modifiers so the grammar and the
374
+ // focused textarea can tell a bare submit from a newline.
375
+ async function renderModifierAwareApp() {
376
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
377
+ width: 120,
378
+ height: 32,
379
+ otherModifiersMode: true,
380
+ });
381
+
382
+ await waitForText(setup, "cueloop");
383
+
384
+ return setup;
385
+ }
386
+
387
+ async function pressReturnWith(
388
+ setup: Setup,
389
+ modifiers: { shift?: boolean; meta?: boolean },
390
+ ): Promise<void> {
391
+ setup.mockInput.pressKey("RETURN", modifiers);
392
+ await settle(setup);
393
+ }
394
+
395
+ test("option+return inserts a newline; a bare return submits the multiline body", async () => {
396
+ // Arrange
397
+ const setup = await renderModifierAwareApp();
398
+
399
+ await toContextParagraph(setup);
400
+ await press(setup, "v");
401
+ await press(setup, "l");
402
+ await press(setup, "c");
403
+ await waitForText(setup, 'comment on "The daemon"');
404
+
405
+ // Act
406
+ await type(setup, "first line");
407
+ await pressReturnWith(setup, { meta: true }); // option/alt+return -> newline
408
+ await type(setup, "second line");
409
+
410
+ // Assert
411
+ // still composing: the newline did not submit
412
+ expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
413
+
414
+ // Act
415
+ await press(setup, "enter"); // bare return -> save
416
+
417
+ // Assert
418
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
419
+ expect(server.core.sessionGet(session.id).annotations[0]!.body).toBe("first line\nsecond line");
420
+ });
421
+
422
+ test("shift+return still inserts a newline (existing muscle memory)", async () => {
423
+ // Arrange
424
+ const setup = await renderModifierAwareApp();
425
+
426
+ await toContextParagraph(setup);
427
+ await press(setup, "c");
428
+
429
+ // Act
430
+ await type(setup, "alpha");
431
+ await pressReturnWith(setup, { shift: true }); // shift+return -> newline
432
+ await type(setup, "beta");
433
+
434
+ // Assert
435
+ expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
436
+
437
+ // Act
438
+ await press(setup, "enter");
439
+
440
+ // Assert
441
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
442
+ expect(server.core.sessionGet(session.id).annotations[0]!.body).toBe("alpha\nbeta");
443
+ });
444
+
445
+ test("escape cancels the composer without saving", async () => {
446
+ // Arrange
447
+ const setup = await renderModifierAwareApp();
448
+
449
+ await toContextParagraph(setup);
450
+ await press(setup, "c");
451
+ await waitForText(setup, 'comment on "');
452
+ await type(setup, "never saved");
453
+
454
+ // Act
455
+ await press(setup, "escape");
456
+
457
+ // Assert
458
+ await waitForTextGone(setup, 'comment on "');
459
+ expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
460
+ });
461
+ });
462
+
463
+ describe("the document selects, the rail edits", () => {
464
+ test("e edits the selected card in place; enter saves through the controller", async () => {
465
+ // Arrange
466
+ const setup = await renderApp();
467
+
468
+ await toContextParagraph(setup);
469
+ await press(setup, "c");
470
+ await type(setup, "Needs a citation.");
471
+ await press(setup, "enter");
472
+ await waitForText(setup, "COMMENT · me");
473
+
474
+ // Act
475
+ // the saved card is selected; e turns its body into an input in place
476
+ await press(setup, "e");
477
+ await type(setup, " And a link.");
478
+ await press(setup, "enter");
479
+
480
+ // Assert
481
+ await waitForState(
482
+ setup,
483
+ () =>
484
+ server.core.sessionGet(session.id).annotations[0]!.body === "Needs a citation. And a link.",
485
+ );
486
+ });
487
+
488
+ test("activating a collaborator's card opens rename, not a body edit", async () => {
489
+ // Arrange - a pulled collaborator note carries an author fingerprint
490
+ const { makeAnchor, parseBlocks } = await import("@cueloop/schema");
491
+ const blocks = parseBlocks(PLAN);
492
+ const contextBlockIndex = blocks.findIndex((block) =>
493
+ block.text.startsWith("The daemon persists"),
494
+ );
495
+
496
+ server.core.sessionAnnotate(session.id, {
497
+ id: "collab-1",
498
+ kind: "comment",
499
+ anchor: makeAnchor(blocks, contextBlockIndex, 0, 10),
500
+ body: "who owns retries?",
501
+ author: "SHA256:1a2b3c4d5e6f",
502
+ });
503
+ const setup = await renderApp();
504
+
505
+ await waitForText(setup, "1a2b3c4d");
506
+
507
+ // Act - focus the collaborator card, then activate it
508
+ await press(setup, "n");
509
+ await press(setup, "e");
510
+
511
+ // Assert - the rename prompt opens; the body never becomes an editor
512
+ await waitForText(setup, "Rename author");
513
+ });
514
+
515
+ test("x deletes the selected card and un-paints the document highlight", async () => {
516
+ // Arrange
517
+ const setup = await renderApp();
518
+
519
+ await toContextParagraph(setup);
520
+ await press(setup, "c");
521
+ await type(setup, "Delete me.");
522
+ await press(setup, "enter");
523
+ await waitForText(setup, "COMMENT · me");
524
+ expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
525
+ expect(backgroundsOf(setup, "persists sessions")).toContain(DARK.markCommentBackground);
526
+
527
+ // Act
528
+ await press(setup, "x");
529
+
530
+ // Assert
531
+ await waitForTextGone(setup, "COMMENT · me");
532
+ expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
533
+ expect(backgroundsOf(setup, "persists sessions")).not.toContain(DARK.markCommentBackground);
534
+ });
535
+ });
536
+
537
+ describe("plan cut removals", () => {
538
+ test("a cut block becomes a rail removal card and u restores it", async () => {
539
+ // Arrange
540
+ const setup = await renderApp();
541
+
542
+ await toContextParagraph(setup);
543
+
544
+ // Act - no card is selected, so x cuts the block under the cursor
545
+ await press(setup, "x");
546
+
547
+ // Assert - the rail shows a removal card titled CUT · me for the cut block
548
+ await waitForText(setup, "CUT · me");
549
+ await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy !== undefined);
550
+
551
+ // Act - undo restores it (no selection, so the last removal)
552
+ await press(setup, "u");
553
+
554
+ // Assert - the block returns and the working copy is pristine again
555
+ await waitForText(setup, "removal restored");
556
+ await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
557
+ });
558
+ });
559
+
560
+ describe("addressed annotations leave the open list", () => {
561
+ test("a revision that addresses one card hides it, un-paints it, and shows the count", async () => {
562
+ // Arrange: two annotations through the daemon, then a revision addressing one
563
+ const { makeAnchor, parseBlocks } = await import("@cueloop/schema");
564
+ const blocks = parseBlocks(PLAN);
565
+ const contextBlockIndex = blocks.findIndex((block) =>
566
+ block.text.startsWith("The daemon persists"),
567
+ );
568
+
569
+ server.core.sessionAnnotate(session.id, {
570
+ id: "a_settled",
571
+ kind: "comment",
572
+ anchor: makeAnchor(blocks, contextBlockIndex, 0, 10), // "The daemon"
573
+ body: "settled note",
574
+ });
575
+ server.core.sessionAnnotate(session.id, {
576
+ id: "a_open",
577
+ kind: "comment",
578
+ anchor: makeAnchor(blocks, contextBlockIndex, 11, 19), // "persists"
579
+ body: "still open note",
580
+ });
581
+ server.core.sessionSubmitRevision(session.id, PLAN, ["a_settled"]);
582
+
583
+ // Act
584
+ const setup = await renderApp();
585
+
586
+ await waitForText(setup, "still open note");
587
+
588
+ // Assert: the open card renders, the addressed one is gone behind the count
589
+ const frame = setup.captureCharFrame();
590
+
591
+ expect(frame).toContain("✓ 1 addressed by revision");
592
+ expect(frame).not.toContain("settled note");
593
+ expect(frame).toContain("still open note"); // the open card survives; the addressed one does not
594
+ expect(backgroundsOf(setup, "The daemon")).not.toContain(DARK.markCommentBackground); // no highlight paint
595
+ });
596
+ });
597
+
598
+ describe("sheet header", () => {
599
+ test("shows the submitting agent, the revision, and the Edit and Share word-buttons", async () => {
600
+ // Arrange
601
+ const setup = await renderApp();
602
+
603
+ // Assert
604
+ const frame = setup.captureCharFrame();
605
+
606
+ expect(frame).toContain("submitted by agent/worker-3");
607
+ expect(frame).toContain("rev 1");
608
+ expect(frame).toContain("Edit");
609
+ expect(frame).toContain("Share");
610
+ });
611
+
612
+ test("the Agent tab shows the text-only harness launcher buttons", async () => {
613
+ // Arrange
614
+ const setup = await renderApp();
615
+ const lines = setup.captureCharFrame().split("\n");
616
+ const tabRow = lines.findIndex((line) => line.includes("Review") && line.includes("Agent"));
617
+ const tabColumn = lines[tabRow]!.indexOf("Agent");
618
+
619
+ // Act
620
+ await setup.mockMouse.click(tabColumn + 1, tabRow);
621
+ await setup.renderOnce();
622
+
623
+ // Assert - the three launcher buttons, in order
624
+ await waitForText(setup, "Claude Code");
625
+ const frame = setup.captureCharFrame();
626
+
627
+ expect(frame).toContain("Pi");
628
+ expect(frame).toContain("OpenAI Codex");
629
+ });
630
+ });
631
+
632
+ describe("quick-actions settings editor", () => {
633
+ test("expanding an action edits its system prompt inline and persists it", async () => {
634
+ // Arrange
635
+ const setup = await renderApp();
636
+
637
+ // Act - open Settings, enter Actions, expand the first action, type into its input
638
+ await clickText(setup, "menu");
639
+ await clickText(setup, "Settings");
640
+ await clickText(setup, "Actions");
641
+ await clickText(setup, "Zoom out, research in depth");
642
+ await type(setup, "CUSTOM");
643
+ await setup.renderOnce();
644
+
645
+ // Assert - the keystrokes reached the focused input (not swallowed by nav) and persisted
646
+ expect(setup.captureCharFrame()).toContain("CUSTOM");
647
+ await waitForState(setup, () =>
648
+ readFileSync(join(home, "no-config.toml"), "utf8").includes("CUSTOM"),
649
+ );
650
+ });
651
+ });
652
+
653
+ describe("edit-exit reconciliation", () => {
654
+ test("an edit that removes an anchored passage orphans the annotation and shows the banner", async () => {
655
+ const script = join(home, "fake-editor.sh");
656
+
657
+ await Bun.write(script, `#!/bin/sh\nsed -i '' '/^The daemon persists/d' "$1"\n`);
658
+ Bun.spawnSync(["chmod", "+x", script]);
659
+ process.env.CUELOOP_EDITOR = script;
660
+ try {
661
+ // Arrange
662
+ const setup = await renderApp();
663
+
664
+ await toContextParagraph(setup);
665
+ await press(setup, "c");
666
+ await type(setup, "Anchor me to the doomed passage.");
667
+ await press(setup, "enter");
668
+ await waitForText(setup, "COMMENT · me");
669
+
670
+ // Act
671
+ // deselect the card so e reaches the editor hand-off, then edit; deselection
672
+ // shows as the card's quote line dropping the selection tone (blue)
673
+ await press(setup, "escape");
674
+ await waitForState(setup, () => !foregroundsOf(setup, "The daemon").includes(DARK.blue));
675
+ await press(setup, "e");
676
+
677
+ // Assert
678
+ await waitForText(setup, "1 annotation no longer match - the passage was removed.");
679
+ // the rail card flips to ORPHANED once the working-copy refresh lands
680
+ await waitForText(setup, "· ORPHANED");
681
+ // the annotation is NOT deleted: the feedback serializer handles orphans
682
+ expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
683
+ } finally {
684
+ delete process.env.CUELOOP_EDITOR;
685
+ }
686
+ });
687
+ });
688
+
689
+ describe("marker-actions popover", () => {
690
+ test("v shows the toolbar, a opens quick-actions, enter inserts the preset comment", async () => {
691
+ // Arrange
692
+ const setup = await renderApp();
693
+
694
+ await toContextParagraph(setup);
695
+
696
+ // Act - marking a span reveals the inline toolbar
697
+ await press(setup, "v");
698
+ await waitForText(setup, "actions");
699
+
700
+ // open the quick-actions list; step to the second default and pick it. The
701
+ // list renders as a floating overlay the virtual terminal cannot composite,
702
+ // so drive the grammar directly rather than waiting on its painted text.
703
+ await press(setup, "a");
704
+ await press(setup, "j");
705
+ await press(setup, "enter");
706
+
707
+ // Assert - a comment annotation was created with the second default's body
708
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
709
+ const stored = server.core.sessionGet(session.id);
710
+
711
+ expect(stored.annotations[0]!.kind).toBe("comment");
712
+ expect(stored.annotations[0]!.body).toBe(quickActionBody(DEFAULT_QUICK_ACTIONS[1]!));
713
+ });
714
+ });