@cueloop/client 0.1.0-alpha.5 → 0.1.0-alpha.51

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