@cueloop/client 0.1.0-alpha.4 → 0.1.0-alpha.41

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