@cueloop/client 0.1.0-alpha.32 → 0.1.0-alpha.34

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 (101) hide show
  1. package/package.json +27 -26
  2. package/src/App.autoclose.test.tsx +7 -4
  3. package/src/App.diff.test.tsx +131 -5
  4. package/src/App.export.test.tsx +23 -5
  5. package/src/App.plan-review.test.tsx +133 -32
  6. package/src/App.readonly.test.tsx +35 -13
  7. package/src/App.rendering.test.tsx +14 -4
  8. package/src/App.review-panel.test.tsx +27 -15
  9. package/src/App.submit-confirm.test.tsx +14 -13
  10. package/src/App.test.tsx +50 -13
  11. package/src/App.toast.test.tsx +75 -0
  12. package/src/App.tsx +505 -101
  13. package/src/App.walk.test.tsx +31 -11
  14. package/src/attribution.ts +8 -2
  15. package/src/clipboard.ts +5 -1
  16. package/src/components/AnnotationCard.stories.tsx +29 -10
  17. package/src/components/AnnotationCard.tsx +57 -37
  18. package/src/components/Breadcrumb.tsx +7 -1
  19. package/src/components/CodeBlock.stories.tsx +19 -2
  20. package/src/components/CodeBlock.tsx +24 -9
  21. package/src/components/CompletionOverlay.tsx +13 -3
  22. package/src/components/ConfirmCard.stories.tsx +22 -3
  23. package/src/components/ConfirmCard.tsx +23 -5
  24. package/src/components/ConfirmDialog.tsx +10 -2
  25. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  26. package/src/components/DiffSheet.stories.tsx +35 -1
  27. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  28. package/src/components/DiffSheet.tsx +213 -101
  29. package/src/components/InboxList.stories.tsx +16 -3
  30. package/src/components/InboxList.tsx +17 -10
  31. package/src/components/KeybindsDialog.stories.tsx +31 -0
  32. package/src/components/KeybindsDialog.tsx +58 -0
  33. package/src/components/MarkerPopover.stories.tsx +58 -0
  34. package/src/components/MarkerPopover.tsx +102 -0
  35. package/src/components/MenuBar.stories.tsx +37 -0
  36. package/src/components/MenuBar.tsx +74 -0
  37. package/src/components/PlanSheet.stories.tsx +35 -3
  38. package/src/components/PlanSheet.tsx +160 -100
  39. package/src/components/PromptDialog.tsx +19 -3
  40. package/src/components/ReviewPanel.stories.tsx +17 -19
  41. package/src/components/ReviewPanel.tsx +17 -32
  42. package/src/components/ReviewRail.stories.tsx +159 -47
  43. package/src/components/ReviewRail.tsx +206 -62
  44. package/src/components/SettingsDialog.stories.tsx +51 -3
  45. package/src/components/SettingsDialog.tsx +11 -5
  46. package/src/components/SettingsRows.stories.tsx +1 -1
  47. package/src/components/SettingsRows.tsx +19 -3
  48. package/src/components/Toast.stories.tsx +12 -0
  49. package/src/components/Toast.tsx +44 -0
  50. package/src/components/WalkWizard.stories.tsx +13 -2
  51. package/src/components/WalkWizard.tsx +9 -3
  52. package/src/components/__snapshots__/stories.test.tsx.snap +853 -623
  53. package/src/components/diff-sheet-layout.test.ts +127 -0
  54. package/src/components/diff-sheet-layout.ts +167 -0
  55. package/src/components/primitives/Button.stories.tsx +2 -2
  56. package/src/components/primitives/Button.tsx +5 -1
  57. package/src/components/primitives/Dialog.tsx +12 -1
  58. package/src/components/primitives/Tabs.tsx +40 -39
  59. package/src/components/stories-app.tsx +34 -6
  60. package/src/components/story-fixtures.ts +11 -3
  61. package/src/components/story.ts +3 -1
  62. package/src/components/syntax-highlight.ts +47 -12
  63. package/src/components/theme-context.stories.tsx +6 -1
  64. package/src/components/theme-context.tsx +7 -1
  65. package/src/config.test.ts +178 -2
  66. package/src/config.ts +121 -23
  67. package/src/diff-hunk-curate.test.ts +315 -0
  68. package/src/diff-hunk-curate.ts +233 -0
  69. package/src/diff-intraline.test.ts +164 -0
  70. package/src/diff-intraline.ts +132 -0
  71. package/src/diff-syntax.test.ts +96 -0
  72. package/src/diff-syntax.ts +154 -0
  73. package/src/editor.test.ts +13 -4
  74. package/src/editor.ts +31 -5
  75. package/src/herdr.ts +8 -2
  76. package/src/index.ts +9 -1
  77. package/src/integrations.ts +3 -1
  78. package/src/intent-dispatch.test.ts +193 -9
  79. package/src/intent-dispatch.ts +151 -18
  80. package/src/key-bindings.test.ts +19 -9
  81. package/src/key-bindings.ts +77 -16
  82. package/src/keymap.test.ts +171 -26
  83. package/src/keymap.ts +83 -16
  84. package/src/review-panel.test.ts +3 -1
  85. package/src/review-panel.ts +9 -2
  86. package/src/serve.test.ts +13 -5
  87. package/src/session-controller.curate.test.ts +253 -0
  88. package/src/session-controller.share.test.ts +77 -16
  89. package/src/session-controller.ts +319 -26
  90. package/src/share.ts +41 -8
  91. package/src/test-support.ts +97 -23
  92. package/src/theme-presets.ts +168 -0
  93. package/src/theme.ts +1 -3
  94. package/src/version.ts +5 -0
  95. package/src/view-diff.ts +15 -4
  96. package/src/view-plan.test.ts +40 -6
  97. package/src/view-plan.ts +71 -28
  98. package/src/walk.test.ts +7 -2
  99. package/src/walk.ts +7 -2
  100. package/src/components/ComposeBar.stories.tsx +0 -18
  101. package/src/components/ComposeBar.tsx +0 -54
package/package.json CHANGED
@@ -1,46 +1,47 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.32",
3
+ "version": "0.1.0-alpha.34",
4
+ "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
+ "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
+ "bugs": {
7
+ "url": "https://github.com/mmurakaru/cueloop/issues"
8
+ },
4
9
  "license": "Apache-2.0",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/mmurakaru/cueloop.git",
13
+ "directory": "packages/client"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "README.md"
18
+ ],
5
19
  "type": "module",
6
20
  "exports": {
7
21
  ".": "./src/index.ts"
8
22
  },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
9
26
  "dependencies": {
10
- "@cueloop/daemon": "0.1.0-alpha.32",
11
- "@cueloop/extension-api": "0.1.0-alpha.32",
12
- "@cueloop/integration-obsidian": "0.1.0-alpha.32",
13
- "@cueloop/schema": "0.1.0-alpha.32",
27
+ "@cueloop/daemon": "0.1.0-alpha.34",
28
+ "@cueloop/extension-api": "0.1.0-alpha.34",
29
+ "@cueloop/integration-obsidian": "0.1.0-alpha.34",
30
+ "@cueloop/schema": "0.1.0-alpha.34",
14
31
  "@opentui/core": "^0.5.2",
15
32
  "@opentui/keymap": "0.5.2",
16
33
  "@opentui/react": "^0.5.2",
17
34
  "@opentui/ssh": "0.5.2",
18
35
  "@pierre/diffs": "^1.3.5",
36
+ "diff": "^8",
19
37
  "react": "^19.2.0"
20
38
  },
21
39
  "devDependencies": {
22
40
  "@types/react": "^19.2.0",
23
- "ssh2": "^1.17.0",
24
- "@types/ssh2": "^1.15.5"
41
+ "@types/ssh2": "^1.15.5",
42
+ "ssh2": "^1.17.0"
25
43
  },
26
- "publishConfig": {
27
- "access": "public"
28
- },
29
- "files": [
30
- "src",
31
- "README.md"
32
- ],
33
44
  "engines": {
34
45
  "bun": ">=1.3.0"
35
- },
36
- "repository": {
37
- "type": "git",
38
- "url": "git+https://github.com/mmurakaru/cueloop.git",
39
- "directory": "packages/client"
40
- },
41
- "homepage": "https://github.com/mmurakaru/cueloop#readme",
42
- "bugs": {
43
- "url": "https://github.com/mmurakaru/cueloop/issues"
44
- },
45
- "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail"
46
- }
46
+ }
47
+ }
@@ -40,10 +40,13 @@ afterEach(() => {
40
40
  });
41
41
 
42
42
  async function renderApp(onExit?: (code: number) => void) {
43
- const setup = await testRender(<App home={home} sessionId={session.id} onExit={onExit} clock={clock} />, {
44
- width: 120,
45
- height: 30,
46
- });
43
+ const setup = await testRender(
44
+ <App home={home} sessionId={session.id} onExit={onExit} clock={clock} />,
45
+ {
46
+ width: 120,
47
+ height: 30,
48
+ },
49
+ );
47
50
  await waitForText(setup, "cueloop");
48
51
  return setup;
49
52
  }
@@ -44,7 +44,10 @@ afterEach(() => {
44
44
  });
45
45
 
46
46
  async function renderApp() {
47
- const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 30 });
47
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
48
+ width: 120,
49
+ height: 30,
50
+ });
48
51
  await waitForText(setup, "cueloop");
49
52
  return setup;
50
53
  }
@@ -72,7 +75,7 @@ describe("diff review", () => {
72
75
  await press(setup, "c");
73
76
 
74
77
  // Assert
75
- await waitForText(setup, "COMMENT ON");
78
+ await waitForText(setup, 'comment on "');
76
79
 
77
80
  // Act
78
81
  await setup.mockInput.typeText("Map needs an eviction story.");
@@ -96,14 +99,137 @@ describe("diff review", () => {
96
99
  expect(resolved.verdict!.feedback).toContain("Map needs an eviction story.");
97
100
  });
98
101
 
99
- test("plan verbs are guarded in diff sessions", async () => {
100
- // Arrange
102
+ test("curation needs full file contents; a legacy diff answers", async () => {
103
+ // Arrange - the session carries no artifact.files
101
104
  const setup = await renderApp();
102
105
 
103
106
  // Act
104
107
  await press(setup, "x");
105
108
 
106
109
  // Assert
107
- await waitForText(setup, "plan-only verb");
110
+ await waitForText(setup, "hunk curation needs full file contents");
111
+ });
112
+
113
+ test("rejecting the only change empties the curated working copy", async () => {
114
+ // Arrange - a diff session that carries full file contents
115
+ const withFiles = server.core.sessionCreate({
116
+ workspace: { repoRoot: "/repo", branch: "main" },
117
+ artifact: {
118
+ type: "diff",
119
+ content: PATCH,
120
+ meta: { title: "working tree" },
121
+ files: [
122
+ {
123
+ path: "src/store.ts",
124
+ oldContents: "export class Store {\n private items = [];\n}\n",
125
+ newContents: "export class Store {\n private items = new Map();\n}\n",
126
+ status: "modified",
127
+ },
128
+ ],
129
+ },
130
+ });
131
+ const setup = await testRender(<App home={home} sessionId={withFiles.id} />, {
132
+ width: 120,
133
+ height: 30,
134
+ });
135
+ await waitForText(setup, "cueloop");
136
+
137
+ // Act - move to the added line and reject its change
138
+ // rows: file(0), hunk(1), ctx(2), del(3), add(4)
139
+ for (let i = 0; i < 4; i++) await press(setup, "j");
140
+ await press(setup, "x");
141
+
142
+ // Assert - the single change is gone, so the curated working copy is empty
143
+ await waitForText(setup, "change rejected");
144
+ const stored = server.core.sessionGet(withFiles.id);
145
+ expect(stored.workingCopy).toBe("");
146
+ });
147
+
148
+ test("a rejected change lists in the rail and undo (u) restores it", async () => {
149
+ // Arrange - a diff session that carries full file contents
150
+ const withFiles = server.core.sessionCreate({
151
+ workspace: { repoRoot: "/repo", branch: "main" },
152
+ artifact: {
153
+ type: "diff",
154
+ content: PATCH,
155
+ meta: { title: "working tree" },
156
+ files: [
157
+ {
158
+ path: "src/store.ts",
159
+ oldContents: "export class Store {\n private items = [];\n}\n",
160
+ newContents: "export class Store {\n private items = new Map();\n}\n",
161
+ status: "modified",
162
+ },
163
+ ],
164
+ },
165
+ });
166
+ const setup = await testRender(<App home={home} sessionId={withFiles.id} />, {
167
+ width: 120,
168
+ height: 30,
169
+ });
170
+ await waitForText(setup, "cueloop");
171
+
172
+ // Act - reject the change under the added line
173
+ // rows: file(0), hunk(1), ctx(2), del(3), add(4)
174
+ for (let i = 0; i < 4; i++) await press(setup, "j");
175
+ await press(setup, "x");
176
+
177
+ // Assert - the rail shows a removal card titled REJECT · me for the change
178
+ await waitForText(setup, "REJECT · me");
179
+ expect(server.core.sessionGet(withFiles.id).workingCopy).toBe("");
180
+
181
+ // Act - undo restores it (no selection, so the last item)
182
+ await press(setup, "u");
183
+
184
+ // Assert - the change returns to the curated diff (working copy cleared)
185
+ await waitForText(setup, "removal restored");
186
+ expect(server.core.sessionGet(withFiles.id).workingCopy).toBeUndefined();
187
+ });
188
+
189
+ test("a selected removal card shows an undo button that restores it on click", async () => {
190
+ // Arrange - a diff session that carries full file contents
191
+ const withFiles = server.core.sessionCreate({
192
+ workspace: { repoRoot: "/repo", branch: "main" },
193
+ artifact: {
194
+ type: "diff",
195
+ content: PATCH,
196
+ meta: { title: "working tree" },
197
+ files: [
198
+ {
199
+ path: "src/store.ts",
200
+ oldContents: "export class Store {\n private items = [];\n}\n",
201
+ newContents: "export class Store {\n private items = new Map();\n}\n",
202
+ status: "modified",
203
+ },
204
+ ],
205
+ },
206
+ });
207
+ const setup = await testRender(<App home={home} sessionId={withFiles.id} />, {
208
+ width: 120,
209
+ height: 30,
210
+ });
211
+ await waitForText(setup, "cueloop");
212
+
213
+ // Act - reject the change; the removal card lands unselected (no undo button)
214
+ for (let i = 0; i < 4; i++) await press(setup, "j");
215
+ await press(setup, "x");
216
+ await waitForText(setup, "REJECT · me");
217
+ expect(setup.captureCharFrame()).not.toContain("undo");
218
+
219
+ // Act - click the card to select it; the undo button appears
220
+ const clickText = async (needle: string): Promise<void> => {
221
+ const lines = setup.captureCharFrame().split("\n");
222
+ const row = lines.findIndex((line) => line.includes(needle));
223
+ await setup.mockMouse.click(lines[row]!.indexOf(needle) + 1, row);
224
+ };
225
+ await clickText("REJECT · me");
226
+ await waitForText(setup, "undo");
227
+
228
+ // Act - click the undo button
229
+ await clickText("undo");
230
+
231
+ // Assert - the change returns to the curated diff (working copy cleared)
232
+ await waitForText(setup, "removal restored");
233
+ expect(server.core.sessionGet(withFiles.id).workingCopy).toBeUndefined();
108
234
  });
109
235
  });
@@ -1,7 +1,15 @@
1
1
  /** Tier-2 wiring for the notes-vault export: with [integrations.obsidian] set to export on resolve, submitting a review writes the note into the vault and surfaces the path in the status line. */
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ readFileSync,
9
+ readdirSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from "node:fs";
5
13
  import { tmpdir } from "node:os";
6
14
  import { join } from "node:path";
7
15
  import React from "react";
@@ -27,13 +35,20 @@ beforeEach(() => {
27
35
  vault = join(home, "vault");
28
36
  mkdirSync(vault);
29
37
  const configPath = join(home, "config.toml");
30
- writeFileSync(configPath, `[integrations.obsidian]\nvault = ${JSON.stringify(vault)}\nexportOn = "resolve"\n`);
38
+ writeFileSync(
39
+ configPath,
40
+ `[integrations.obsidian]\nvault = ${JSON.stringify(vault)}\nexportOn = "resolve"\n`,
41
+ );
31
42
  restoreUserConfig = isolateUserConfig(home, "config.toml");
32
43
  server = new DaemonServer({ home, idleExitMs: 0 });
33
44
  server.start();
34
45
  session = server.core.sessionCreate({
35
46
  workspace: { repoRoot: "/repo", branch: "main" },
36
- artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
47
+ artifact: {
48
+ type: "plan",
49
+ content: PLAN,
50
+ meta: { title: "Migration Plan", planPath: "plan.md" },
51
+ },
37
52
  });
38
53
  });
39
54
  afterEach(() => {
@@ -45,14 +60,17 @@ afterEach(() => {
45
60
  describe("obsidian export on resolve", () => {
46
61
  test("submitting a review writes the plan into the vault and shows the path", async () => {
47
62
  // Arrange
48
- const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 32 });
63
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
64
+ width: 120,
65
+ height: 32,
66
+ });
49
67
  await waitForText(setup, "cueloop");
50
68
 
51
69
  // Act
52
70
  await press(setup, "enter"); // open the submit overlay, keep the default verdict (approve)
53
71
 
54
72
  // Assert
55
- await waitForText(setup, "verdict");
73
+ await waitForText(setup, "[Approve]");
56
74
 
57
75
  // Act
58
76
  await press(setup, "enter"); // submit
@@ -9,8 +9,16 @@ import { testRender } from "@opentui/react/test-utils";
9
9
  import { DaemonServer } from "@cueloop/daemon";
10
10
  import type { ReviewSession } from "@cueloop/schema";
11
11
  import { App } from "./App";
12
- import { DARK as T } from "./theme";
13
- import { isolateUserConfig, press, settle, typeText as type, waitForState, waitForText, waitForTextGone } from "./test-support";
12
+ import { DARK } from "./theme";
13
+ import {
14
+ isolateUserConfig,
15
+ press,
16
+ settle,
17
+ typeText as type,
18
+ waitForState,
19
+ waitForText,
20
+ waitForTextGone,
21
+ } from "./test-support";
14
22
 
15
23
  const PLAN = `# Migration Plan
16
24
 
@@ -36,7 +44,11 @@ beforeEach(() => {
36
44
  server.start();
37
45
  session = server.core.sessionCreate({
38
46
  workspace: { repoRoot: "/repo", branch: "main" },
39
- artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md", agent: "agent/worker-3" } },
47
+ artifact: {
48
+ type: "plan",
49
+ content: PLAN,
50
+ meta: { title: "Migration Plan", planPath: "plan.md", agent: "agent/worker-3" },
51
+ },
40
52
  });
41
53
  });
42
54
  afterEach(() => {
@@ -66,12 +78,28 @@ function backgroundsOf(setup: Setup, needle: string): string[] {
66
78
  for (const span of line.spans) {
67
79
  if (!span.text.includes(needle)) continue;
68
80
  const [red, green, blue] = span.bg.toInts();
69
- backgrounds.push("#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""));
81
+ backgrounds.push(
82
+ "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""),
83
+ );
70
84
  }
71
85
  }
72
86
  return backgrounds;
73
87
  }
74
88
 
89
+ /** Whether any border character (box-drawing) is painted in the given foreground hex. */
90
+ function hasBorderColor(setup: Setup, hex: string): boolean {
91
+ for (const line of setup.captureSpans().lines) {
92
+ for (const span of line.spans) {
93
+ if (!/[╭╮╰╯│─┌┐└┘]/.test(span.text)) continue;
94
+ const [red, green, blue] = span.fg.toInts();
95
+ const rendered =
96
+ "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
97
+ if (rendered === hex) return true;
98
+ }
99
+ }
100
+ return false;
101
+ }
102
+
75
103
  /** Move the cursor to "The daemon persists sessions to disk atomically." */
76
104
  async function toContextParagraph(setup: Setup): Promise<void> {
77
105
  await press(setup, "j");
@@ -85,14 +113,20 @@ describe("share button", () => {
85
113
 
86
114
  // Assert
87
115
  // both word-buttons ride the same header row, so Share sits next to Edit
88
- const headerLine = setup.captureCharFrame().split("\n").find((line) => line.includes("submitted by"));
116
+ const headerLine = setup
117
+ .captureCharFrame()
118
+ .split("\n")
119
+ .find((line) => line.includes("submitted by"));
89
120
  expect(headerLine).toContain("Edit");
90
121
  expect(headerLine).toContain("Share");
91
122
  });
92
123
 
93
124
  test("a read-only viewer (a plan shared over ssh) never sees the Share button", async () => {
94
125
  // Arrange / Act
95
- const viewer = await testRender(<App home={home} sessionId={session.id} readOnly />, { width: 120, height: 32 });
126
+ const viewer = await testRender(<App home={home} sessionId={session.id} readOnly />, {
127
+ width: 120,
128
+ height: 32,
129
+ });
96
130
  await waitForText(viewer, "cueloop");
97
131
 
98
132
  // Assert
@@ -108,7 +142,10 @@ describe("share button", () => {
108
142
  await waitForText(setup, "submitted by");
109
143
 
110
144
  // Assert - the owner toolbar is gone once the review is resolved
111
- const headerLine = setup.captureCharFrame().split("\n").find((line) => line.includes("submitted by"));
145
+ const headerLine = setup
146
+ .captureCharFrame()
147
+ .split("\n")
148
+ .find((line) => line.includes("submitted by"));
112
149
  expect(headerLine).not.toContain("Edit");
113
150
  expect(headerLine).not.toContain("Share");
114
151
  });
@@ -125,7 +162,10 @@ describe("edit affordance", () => {
125
162
 
126
163
  test("a read-only viewer (a plan shared over ssh) never sees the Edit button", async () => {
127
164
  // Arrange / Act
128
- const viewer = await testRender(<App home={home} sessionId={session.id} readOnly />, { width: 120, height: 32 });
165
+ const viewer = await testRender(<App home={home} sessionId={session.id} readOnly />, {
166
+ width: 120,
167
+ height: 32,
168
+ });
129
169
  await waitForText(viewer, "cueloop");
130
170
 
131
171
  // Assert
@@ -138,7 +178,9 @@ describe("attribution", () => {
138
178
  // Arrange - a pulled collaborator note carries an author fingerprint
139
179
  const { makeAnchor, parseBlocks } = await import("@cueloop/schema");
140
180
  const blocks = parseBlocks(PLAN);
141
- const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("The daemon persists"));
181
+ const contextBlockIndex = blocks.findIndex((block) =>
182
+ block.text.startsWith("The daemon persists"),
183
+ );
142
184
  server.core.sessionAnnotate(session.id, {
143
185
  id: "collab-1",
144
186
  kind: "comment",
@@ -221,10 +263,10 @@ describe("inline compose keeps the anchor painted", () => {
221
263
  // Assert
222
264
  // compose open: the anchor is painted selection-style. The box can render
223
265
  // a frame before the anchor repaint settles, so wait on the color itself.
224
- await waitForText(setup, "Save ⏎");
225
- expect(setup.captureCharFrame()).toContain("Cancel esc");
226
- await waitForState(setup, () => backgroundsOf(setup, "The daemon").includes(T.accent));
227
- expect(backgroundsOf(setup, "The daemon")).toContain(T.accent);
266
+ await waitForText(setup, "Save");
267
+ expect(setup.captureCharFrame()).toContain("Cancel");
268
+ await waitForState(setup, () => backgroundsOf(setup, "The daemon").includes(DARK.accent));
269
+ expect(backgroundsOf(setup, "The daemon")).toContain(DARK.accent);
228
270
 
229
271
  // Act
230
272
  // cancel un-paints (a bare ESC settles after the parser's escape window)
@@ -232,7 +274,7 @@ describe("inline compose keeps the anchor painted", () => {
232
274
 
233
275
  // Assert
234
276
  await waitForTextGone(setup, 'comment on "');
235
- expect(backgroundsOf(setup, "The daemon")).not.toContain(T.accent);
277
+ expect(backgroundsOf(setup, "The daemon")).not.toContain(DARK.accent);
236
278
 
237
279
  // Act
238
280
  // save converts the paint to the kind-colored annotation highlight
@@ -246,11 +288,13 @@ describe("inline compose keeps the anchor painted", () => {
246
288
  // the whole cursor block is the anchor here; check the highlight landed
247
289
  const stored = server.core.sessionGet(session.id);
248
290
  expect(stored.annotations.length).toBe(1);
249
- expect(backgroundsOf(setup, stored.annotations[0]!.anchor.quote.slice(0, 20))).toContain(T.markCommentBackground);
291
+ expect(backgroundsOf(setup, stored.annotations[0]!.anchor.quote.slice(0, 20))).toContain(
292
+ DARK.markCommentBackground,
293
+ );
250
294
  // renderApp plus this many frame-waits grazes the 5s default on a loaded CI
251
295
  // runner, and the whole-suite publish lane has timed even 15s out; give the
252
296
  // heaviest frame-wait chain generous headroom so runner load cannot flake it.
253
- }, 30_000);
297
+ }, 60_000);
254
298
  });
255
299
 
256
300
  describe("compose newline convention", () => {
@@ -267,7 +311,10 @@ describe("compose newline convention", () => {
267
311
  return setup;
268
312
  }
269
313
 
270
- async function pressReturnWith(setup: Setup, modifiers: { shift?: boolean; meta?: boolean }): Promise<void> {
314
+ async function pressReturnWith(
315
+ setup: Setup,
316
+ modifiers: { shift?: boolean; meta?: boolean },
317
+ ): Promise<void> {
271
318
  setup.mockInput.pressKey("RETURN", modifiers);
272
319
  await settle(setup);
273
320
  }
@@ -345,7 +392,7 @@ describe("the document selects, the rail edits", () => {
345
392
  await press(setup, "c");
346
393
  await type(setup, "Needs a citation.");
347
394
  await press(setup, "enter");
348
- await waitForText(setup, "COMMENT · pending");
395
+ await waitForText(setup, "COMMENT · me");
349
396
 
350
397
  // Act
351
398
  // the saved card is selected; e turns its body into an input in place
@@ -356,7 +403,8 @@ describe("the document selects, the rail edits", () => {
356
403
  // Assert
357
404
  await waitForState(
358
405
  setup,
359
- () => server.core.sessionGet(session.id).annotations[0]!.body === "Needs a citation. And a link.",
406
+ () =>
407
+ server.core.sessionGet(session.id).annotations[0]!.body === "Needs a citation. And a link.",
360
408
  );
361
409
  });
362
410
 
@@ -364,7 +412,9 @@ describe("the document selects, the rail edits", () => {
364
412
  // Arrange - a pulled collaborator note carries an author fingerprint
365
413
  const { makeAnchor, parseBlocks } = await import("@cueloop/schema");
366
414
  const blocks = parseBlocks(PLAN);
367
- const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("The daemon persists"));
415
+ const contextBlockIndex = blocks.findIndex((block) =>
416
+ block.text.startsWith("The daemon persists"),
417
+ );
368
418
  server.core.sessionAnnotate(session.id, {
369
419
  id: "collab-1",
370
420
  kind: "comment",
@@ -390,17 +440,39 @@ describe("the document selects, the rail edits", () => {
390
440
  await press(setup, "c");
391
441
  await type(setup, "Delete me.");
392
442
  await press(setup, "enter");
393
- await waitForText(setup, "COMMENT · pending");
443
+ await waitForText(setup, "COMMENT · me");
394
444
  expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
395
- expect(backgroundsOf(setup, "persists sessions")).toContain(T.markCommentBackground);
445
+ expect(backgroundsOf(setup, "persists sessions")).toContain(DARK.markCommentBackground);
396
446
 
397
447
  // Act
398
448
  await press(setup, "x");
399
449
 
400
450
  // Assert
401
- await waitForTextGone(setup, "COMMENT · pending");
451
+ await waitForTextGone(setup, "COMMENT · me");
402
452
  expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
403
- expect(backgroundsOf(setup, "persists sessions")).not.toContain(T.markCommentBackground);
453
+ expect(backgroundsOf(setup, "persists sessions")).not.toContain(DARK.markCommentBackground);
454
+ });
455
+ });
456
+
457
+ describe("plan cut removals", () => {
458
+ test("a cut block becomes a rail removal card and u restores it", async () => {
459
+ // Arrange
460
+ const setup = await renderApp();
461
+ await toContextParagraph(setup);
462
+
463
+ // Act - no card is selected, so x cuts the block under the cursor
464
+ await press(setup, "x");
465
+
466
+ // Assert - the rail shows a removal card titled CUT · me for the cut block
467
+ await waitForText(setup, "CUT · me");
468
+ await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy !== undefined);
469
+
470
+ // Act - undo restores it (no selection, so the last removal)
471
+ await press(setup, "u");
472
+
473
+ // Assert - the block returns and the working copy is pristine again
474
+ await waitForText(setup, "removal restored");
475
+ await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
404
476
  });
405
477
  });
406
478
 
@@ -409,7 +481,9 @@ describe("addressed annotations leave the open list", () => {
409
481
  // Arrange: two annotations through the daemon, then a revision addressing one
410
482
  const { makeAnchor, parseBlocks } = await import("@cueloop/schema");
411
483
  const blocks = parseBlocks(PLAN);
412
- const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("The daemon persists"));
484
+ const contextBlockIndex = blocks.findIndex((block) =>
485
+ block.text.startsWith("The daemon persists"),
486
+ );
413
487
  server.core.sessionAnnotate(session.id, {
414
488
  id: "a_settled",
415
489
  kind: "comment",
@@ -432,8 +506,8 @@ describe("addressed annotations leave the open list", () => {
432
506
  const frame = setup.captureCharFrame();
433
507
  expect(frame).toContain("✓ 1 addressed by revision");
434
508
  expect(frame).not.toContain("settled note");
435
- expect(frame).toContain("Review (1)"); // the addressed card no longer counts as pending
436
- expect(backgroundsOf(setup, "The daemon")).not.toContain(T.markCommentBackground); // no highlight paint
509
+ expect(frame).toContain("still open note"); // the open card survives; the addressed one does not
510
+ expect(backgroundsOf(setup, "The daemon")).not.toContain(DARK.markCommentBackground); // no highlight paint
437
511
  });
438
512
  });
439
513
 
@@ -444,7 +518,8 @@ describe("sheet header", () => {
444
518
 
445
519
  // Assert
446
520
  const frame = setup.captureCharFrame();
447
- expect(frame).toContain("submitted by agent/worker-3 · revision 1");
521
+ expect(frame).toContain("submitted by agent/worker-3");
522
+ expect(frame).toContain("rev 1");
448
523
  expect(frame).toContain("Edit");
449
524
  expect(frame).toContain("Share");
450
525
  });
@@ -453,7 +528,7 @@ describe("sheet header", () => {
453
528
  // Arrange
454
529
  const setup = await renderApp();
455
530
  const lines = setup.captureCharFrame().split("\n");
456
- const tabRow = lines.findIndex((line) => line.includes("Review (0)"));
531
+ const tabRow = lines.findIndex((line) => line.includes("Review") && line.includes("Agent"));
457
532
  const tabColumn = lines[tabRow]!.indexOf("Agent");
458
533
 
459
534
  // Act
@@ -482,12 +557,13 @@ describe("edit-exit reconciliation", () => {
482
557
  await press(setup, "c");
483
558
  await type(setup, "Anchor me to the doomed passage.");
484
559
  await press(setup, "enter");
485
- await waitForText(setup, "· pending");
560
+ await waitForText(setup, "COMMENT · me");
486
561
 
487
562
  // Act
488
- // deselect the card so e reaches the editor hand-off, then edit
563
+ // deselect the card so e reaches the editor hand-off, then edit; deselection
564
+ // shows as the card's accent border fading to a dim border (transparent fill)
489
565
  await press(setup, "escape");
490
- await waitForTextGone(setup, "▸ COMMENT");
566
+ await waitForState(setup, () => !hasBorderColor(setup, DARK.accent));
491
567
  await press(setup, "e");
492
568
 
493
569
  // Assert
@@ -501,3 +577,28 @@ describe("edit-exit reconciliation", () => {
501
577
  }
502
578
  });
503
579
  });
580
+
581
+ describe("marker-actions popover", () => {
582
+ test("v shows the toolbar, a opens quick-actions, enter inserts the preset comment", async () => {
583
+ // Arrange
584
+ const setup = await renderApp();
585
+ await toContextParagraph(setup);
586
+
587
+ // Act - marking a span reveals the inline toolbar
588
+ await press(setup, "v");
589
+ await waitForText(setup, "actions");
590
+
591
+ // open the quick-actions list; step to the second default and pick it. The
592
+ // list renders as a floating overlay the virtual terminal cannot composite,
593
+ // so drive the grammar directly rather than waiting on its painted text.
594
+ await press(setup, "a");
595
+ await press(setup, "j");
596
+ await press(setup, "enter");
597
+
598
+ // Assert - a comment annotation was created with the second default's body
599
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
600
+ const stored = server.core.sessionGet(session.id);
601
+ expect(stored.annotations[0]!.kind).toBe("comment");
602
+ expect(stored.annotations[0]!.body).toBe("Restate simplified");
603
+ });
604
+ });