@cueloop/client 0.1.0-alpha.64 → 0.1.0-alpha.65

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 (49) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +2 -2
  3. package/src/App.export.test.tsx +2 -2
  4. package/src/App.plan-review.test.tsx +76 -247
  5. package/src/App.readonly.test.tsx +80 -35
  6. package/src/App.rendering.test.tsx +5 -6
  7. package/src/App.review-panel.test.tsx +29 -32
  8. package/src/App.submit-confirm.test.tsx +27 -12
  9. package/src/App.test.tsx +73 -49
  10. package/src/App.toast.test.tsx +17 -8
  11. package/src/App.tree.test.tsx +224 -0
  12. package/src/App.tsx +120 -84
  13. package/src/app-screens.tsx +27 -1
  14. package/src/app-view-model.ts +4 -96
  15. package/src/components/AnnotationCard.tsx +2 -2
  16. package/src/components/KeybindsDialog.tsx +61 -17
  17. package/src/components/ReviewPanel.stories.tsx +14 -0
  18. package/src/components/ReviewRail.stories.tsx +14 -0
  19. package/src/components/ReviewRail.tsx +13 -6
  20. package/src/components/ThreadView.selection.test.tsx +812 -0
  21. package/src/components/ThreadView.stories.tsx +26 -0
  22. package/src/components/ThreadView.tsx +1620 -0
  23. package/src/components/TreePane.stories.tsx +106 -0
  24. package/src/components/TreePane.tsx +156 -0
  25. package/src/components/__snapshots__/stories.test.tsx.snap +120 -172
  26. package/src/diff-hunk-curate.ts +4 -104
  27. package/src/index.ts +1 -0
  28. package/src/intent-dispatch.test.ts +136 -3
  29. package/src/intent-dispatch.ts +92 -24
  30. package/src/key-bindings.test.ts +1 -1
  31. package/src/keymap.test.ts +4 -4
  32. package/src/keymap.ts +14 -7
  33. package/src/session-controller.curate.test.ts +89 -2
  34. package/src/session-controller.share.test.ts +195 -55
  35. package/src/session-controller.tree.test.ts +228 -0
  36. package/src/session-controller.ts +322 -68
  37. package/src/share.ts +114 -16
  38. package/src/test-support.test.ts +193 -1
  39. package/src/test-support.ts +64 -0
  40. package/src/thread-chords.test.ts +154 -0
  41. package/src/thread-chords.ts +144 -0
  42. package/src/thread-selection.test.ts +210 -0
  43. package/src/thread-selection.ts +243 -0
  44. package/src/tree-view.test.ts +128 -0
  45. package/src/tree-view.ts +148 -0
  46. package/src/view-plan.ts +29 -12
  47. package/src/components/PlanSheet.stories.tsx +0 -113
  48. package/src/components/PlanSheet.tsx +0 -419
  49. package/src/components/plan-sheet-run-style.ts +0 -88
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.64",
3
+ "version": "0.1.0-alpha.65",
4
4
  "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -26,10 +26,10 @@
26
26
  "access": "public"
27
27
  },
28
28
  "dependencies": {
29
- "@cueloop/daemon": "0.1.0-alpha.64",
30
- "@cueloop/extension-api": "0.1.0-alpha.64",
31
- "@cueloop/integration-obsidian": "0.1.0-alpha.64",
32
- "@cueloop/schema": "0.1.0-alpha.64",
29
+ "@cueloop/daemon": "0.1.0-alpha.65",
30
+ "@cueloop/extension-api": "0.1.0-alpha.65",
31
+ "@cueloop/integration-obsidian": "0.1.0-alpha.65",
32
+ "@cueloop/schema": "0.1.0-alpha.65",
33
33
  "@opentui/core": "^0.5.8",
34
34
  "@opentui/keymap": "0.5.8",
35
35
  "@opentui/react": "^0.5.8",
@@ -10,7 +10,7 @@ import { ManualClock } from "@opentui/core/testing";
10
10
  import { DaemonServer } from "@cueloop/daemon";
11
11
  import type { ReviewSession } from "@cueloop/schema";
12
12
  import { App } from "./App";
13
- import { isolateUserConfig, press, settle, waitForText } from "./test-support";
13
+ import { isolateUserConfig, press, settle, waitForText, pressKey } from "./test-support";
14
14
 
15
15
  const PLAN = "# Plan\n\nShip the thing.\n";
16
16
 
@@ -56,7 +56,7 @@ async function renderApp(onExit?: (code: number) => void) {
56
56
  type Setup = Awaited<ReturnType<typeof renderApp>>;
57
57
 
58
58
  async function submitApprove(setup: Setup): Promise<void> {
59
- await press(setup, "enter"); // open submit (approve default: no pending items)
59
+ await pressKey(setup, "RETURN", { meta: true }); // open submit (approve default: no pending items)
60
60
  await press(setup, "enter"); // confirm
61
61
  await waitForText(setup, "review approved");
62
62
  }
@@ -17,7 +17,7 @@ import { testRender } from "@opentui/react/test-utils";
17
17
  import { DaemonServer } from "@cueloop/daemon";
18
18
  import type { ReviewSession } from "@cueloop/schema";
19
19
  import { App } from "./App";
20
- import { isolateUserConfig, press, waitForState, waitForText } from "./test-support";
20
+ import { isolateUserConfig, press, waitForState, waitForText, pressKey } from "./test-support";
21
21
 
22
22
  const PLAN = `# Migration Plan
23
23
 
@@ -69,7 +69,7 @@ describe("obsidian export on resolve", () => {
69
69
  await waitForText(setup, "cueloop");
70
70
 
71
71
  // Act
72
- await press(setup, "enter"); // open the submit overlay, keep the default verdict (approve)
72
+ await pressKey(setup, "RETURN", { meta: true }); // open the submit overlay, keep the default verdict (approve)
73
73
 
74
74
  // Assert
75
75
  await waitForText(setup, "[Approve]");
@@ -12,9 +12,11 @@ import { App } from "./App";
12
12
  import { DEFAULT_QUICK_ACTIONS, quickActionBody } from "./config";
13
13
  import { DARK } from "./theme";
14
14
  import {
15
+ clickText,
16
+ dragText,
15
17
  isolateUserConfig,
16
18
  press,
17
- settle,
19
+ pressKey,
18
20
  typeText as type,
19
21
  waitForState,
20
22
  waitForText,
@@ -92,38 +94,6 @@ function backgroundsOf(setup: Setup, needle: string): string[] {
92
94
  return backgrounds;
93
95
  }
94
96
 
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
97
  describe("share button", () => {
128
98
  test("the owner's plan header shows the Share button next to Edit", async () => {
129
99
  // Arrange / Act
@@ -221,259 +191,127 @@ describe("attribution", () => {
221
191
  });
222
192
  });
223
193
 
224
- describe("selection feeds the annotation quote", () => {
225
- test("v anchors, l extends, c composes with the char-precise quote", async () => {
194
+ describe("marks feed the comment anchor", () => {
195
+ test("a drag marks a character-precise span and typing anchors the comment to it", async () => {
226
196
  // Arrange
227
197
  const setup = await renderApp();
228
198
 
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
199
  // Act
200
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
240
201
  await type(setup, "Which daemon?");
241
- await press(setup, "enter");
202
+ await pressKey(setup, "RETURN", { meta: true });
242
203
 
243
204
  // Assert
244
205
  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");
206
+ expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe("The daemon");
248
207
  });
249
208
 
250
- test("a mouse drag over the text becomes the compose quote", async () => {
209
+ test("a drag that ends mid-sentence keeps exactly the dragged words", async () => {
251
210
  // Arrange
252
211
  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
212
 
268
- // Act
213
+ // Act: release one past the last character of "sessions"
214
+ await dragText(setup, "persists", "sessions", "sessions".length);
269
215
  await type(setup, "sessions plural?");
270
- await press(setup, "enter");
216
+ await pressKey(setup, "RETURN", { meta: true });
271
217
 
272
218
  // Assert
273
219
  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");
220
+ expect(server.core.sessionGet(session.id).annotations[0]!.anchor.quote).toBe(
221
+ "persists sessions",
222
+ );
277
223
  });
278
224
  });
279
225
 
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 () => {
226
+ /** The thread view's mark backdrop in the dark palette. */
227
+ const THREAD_MARK = "#463852";
228
+
229
+ describe("the mark stays painted while composing", () => {
230
+ test("the mark paints while composing, escape un-paints, save keeps it as the comment's mark", async () => {
282
231
  // Arrange
283
232
  const setup = await renderApp();
284
233
 
285
- await toContextParagraph(setup);
286
-
287
234
  // Act
288
- await press(setup, "v");
289
- await press(setup, "l");
290
- await press(setup, "c");
235
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
236
+ await type(setup, "x");
291
237
 
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);
238
+ // Assert: composing, the marked words carry the mark backdrop
239
+ await waitForText(setup, "● x");
240
+ expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
299
241
 
300
- // Act
301
- // cancel un-paints (a bare ESC settles after the parser's escape window)
242
+ // Act: escape discards the draft; the mark stays for re-typing, a second escape drops it
243
+ await press(setup, "escape");
244
+ await waitForTextGone(setup, "● x");
245
+ expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
302
246
  await press(setup, "escape");
303
247
 
304
248
  // Assert
305
- await waitForTextGone(setup, 'comment on "');
306
- expect(backgroundsOf(setup, "The daemon")).not.toContain(DARK.accent);
249
+ await waitForState(setup, () => !backgroundsOf(setup, "The daemon").includes(THREAD_MARK));
307
250
 
308
- // Act
309
- // save converts the paint to the kind-colored annotation highlight
310
- await press(setup, "c");
251
+ // Act: mark again, save
252
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
311
253
  await type(setup, "Which daemon?");
312
- await press(setup, "enter");
254
+ await pressKey(setup, "RETURN", { meta: true });
313
255
 
314
- // Assert
256
+ // Assert: the saved comment keeps the passage marked
315
257
  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.
258
+ await waitForState(setup, () => backgroundsOf(setup, "The daemon").includes(THREAD_MARK));
327
259
  }, 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
260
  });
370
261
 
371
262
  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 () => {
263
+ test("enter breaks the line; cmd+enter saves the multiline body verbatim", async () => {
396
264
  // Arrange
397
- const setup = await renderModifierAwareApp();
265
+ const setup = await renderApp();
398
266
 
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"');
267
+ await clickText(setup, "daemon");
404
268
 
405
269
  // Act
406
270
  await type(setup, "first line");
407
- await pressReturnWith(setup, { meta: true }); // option/alt+return -> newline
271
+ await press(setup, "enter");
408
272
  await type(setup, "second line");
409
273
 
410
- // Assert
411
- // still composing: the newline did not submit
274
+ // Assert: still composing, the newline did not save
412
275
  expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
413
276
 
414
277
  // Act
415
- await press(setup, "enter"); // bare return -> save
278
+ await pressKey(setup, "RETURN", { meta: true });
416
279
 
417
280
  // Assert
418
281
  await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
419
282
  expect(server.core.sessionGet(session.id).annotations[0]!.body).toBe("first line\nsecond line");
420
283
  });
421
284
 
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
285
  test("escape cancels the composer without saving", async () => {
446
286
  // Arrange
447
- const setup = await renderModifierAwareApp();
287
+ const setup = await renderApp();
448
288
 
449
- await toContextParagraph(setup);
450
- await press(setup, "c");
451
- await waitForText(setup, 'comment on "');
289
+ await clickText(setup, "daemon");
452
290
  await type(setup, "never saved");
291
+ await waitForText(setup, "● never saved");
453
292
 
454
293
  // Act
455
294
  await press(setup, "escape");
456
295
 
457
296
  // Assert
458
- await waitForTextGone(setup, 'comment on "');
297
+ await waitForTextGone(setup, "● never saved");
459
298
  expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
460
299
  });
461
300
  });
462
301
 
463
302
  describe("the document selects, the rail edits", () => {
464
- test("e edits the selected card in place; enter saves through the controller", async () => {
303
+ test("option+e edits the focused card in place; enter saves through the controller", async () => {
465
304
  // Arrange
466
305
  const setup = await renderApp();
467
306
 
468
- await toContextParagraph(setup);
469
- await press(setup, "c");
307
+ await clickText(setup, "daemon");
470
308
  await type(setup, "Needs a citation.");
471
- await press(setup, "enter");
309
+ await pressKey(setup, "RETURN", { meta: true });
472
310
  await waitForText(setup, "COMMENT · me");
473
311
 
474
- // Act
475
- // the saved card is selected; e turns its body into an input in place
476
- await press(setup, "e");
312
+ // Act: focus the card, then turn its body into an input in place
313
+ await pressKey(setup, "n", { meta: true });
314
+ await pressKey(setup, "e", { meta: true });
477
315
  await type(setup, " And a link.");
478
316
  await press(setup, "enter");
479
317
 
@@ -505,51 +343,51 @@ describe("the document selects, the rail edits", () => {
505
343
  await waitForText(setup, "1a2b3c4d");
506
344
 
507
345
  // Act - focus the collaborator card, then activate it
508
- await press(setup, "n");
509
- await press(setup, "e");
346
+ await pressKey(setup, "n", { meta: true });
347
+ await pressKey(setup, "e", { meta: true });
510
348
 
511
349
  // Assert - the rename prompt opens; the body never becomes an editor
512
350
  await waitForText(setup, "Rename author");
513
351
  });
514
352
 
515
- test("x deletes the selected card and un-paints the document highlight", async () => {
353
+ test("option+backspace deletes the focused card and un-paints the document mark", async () => {
516
354
  // Arrange
517
355
  const setup = await renderApp();
518
356
 
519
- await toContextParagraph(setup);
520
- await press(setup, "c");
357
+ await dragText(setup, "daemon", "daemon persists", "daemon".length);
521
358
  await type(setup, "Delete me.");
522
- await press(setup, "enter");
359
+ await pressKey(setup, "RETURN", { meta: true });
523
360
  await waitForText(setup, "COMMENT · me");
524
361
  expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
525
- expect(backgroundsOf(setup, "persists sessions")).toContain(DARK.markCommentBackground);
362
+ await waitForState(setup, () => backgroundsOf(setup, "daemon").includes(THREAD_MARK));
526
363
 
527
364
  // Act
528
- await press(setup, "x");
365
+ await pressKey(setup, "n", { meta: true });
366
+ await pressKey(setup, "BACKSPACE", { meta: true });
529
367
 
530
368
  // Assert
531
369
  await waitForTextGone(setup, "COMMENT · me");
532
370
  expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
533
- expect(backgroundsOf(setup, "persists sessions")).not.toContain(DARK.markCommentBackground);
371
+ expect(backgroundsOf(setup, "daemon")).not.toContain(THREAD_MARK);
534
372
  });
535
373
  });
536
374
 
537
375
  describe("plan cut removals", () => {
538
- test("a cut block becomes a rail removal card and u restores it", async () => {
376
+ test("option+x cuts the caret's block into a rail removal card and option+u restores it", async () => {
539
377
  // Arrange
540
378
  const setup = await renderApp();
541
379
 
542
- await toContextParagraph(setup);
380
+ await clickText(setup, "daemon");
543
381
 
544
- // Act - no card is selected, so x cuts the block under the cursor
545
- await press(setup, "x");
382
+ // Act - no card is focused, so the cut takes the block under the caret
383
+ await pressKey(setup, "x", { meta: true });
546
384
 
547
385
  // Assert - the rail shows a removal card titled CUT · me for the cut block
548
386
  await waitForText(setup, "CUT · me");
549
387
  await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy !== undefined);
550
388
 
551
389
  // Act - undo restores it (no selection, so the last removal)
552
- await press(setup, "u");
390
+ await pressKey(setup, "u", { meta: true });
553
391
 
554
392
  // Assert - the block returns and the working copy is pristine again
555
393
  await waitForText(setup, "removal restored");
@@ -661,18 +499,13 @@ describe("edit-exit reconciliation", () => {
661
499
  // Arrange
662
500
  const setup = await renderApp();
663
501
 
664
- await toContextParagraph(setup);
665
- await press(setup, "c");
502
+ await clickText(setup, "daemon");
666
503
  await type(setup, "Anchor me to the doomed passage.");
667
- await press(setup, "enter");
504
+ await pressKey(setup, "RETURN", { meta: true });
668
505
  await waitForText(setup, "COMMENT · me");
669
506
 
670
507
  // 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");
508
+ await pressKey(setup, "e", { ctrl: true });
676
509
 
677
510
  // Assert
678
511
  await waitForText(setup, "1 annotation no longer match - the passage was removed.");
@@ -686,23 +519,19 @@ describe("edit-exit reconciliation", () => {
686
519
  });
687
520
  });
688
521
 
689
- describe("marker-actions popover", () => {
690
- test("v shows the toolbar, a opens quick-actions, enter inserts the preset comment", async () => {
522
+ describe("the quick-action palette", () => {
523
+ test("/ lists the quick actions and a pick seeds the comment with the preset body", async () => {
691
524
  // Arrange
692
525
  const setup = await renderApp();
693
526
 
694
- await toContextParagraph(setup);
695
-
696
- // Act - marking a span reveals the inline toolbar
697
- await press(setup, "v");
698
- await waitForText(setup, "actions");
527
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
699
528
 
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");
529
+ // Act - a leading slash opens the palette; step to the second default and pick it
530
+ await type(setup, "/");
531
+ await waitForText(setup, "zoom-out-research-in-depth");
532
+ await pressKey(setup, "ARROW_DOWN");
705
533
  await press(setup, "enter");
534
+ await pressKey(setup, "RETURN", { meta: true });
706
535
 
707
536
  // Assert - a comment annotation was created with the second default's body
708
537
  await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);