@cueloop/client 0.1.0-alpha.33 → 0.1.0-alpha.35

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 (94) hide show
  1. package/package.json +27 -26
  2. package/src/App.autoclose.test.tsx +7 -4
  3. package/src/App.diff.test.tsx +130 -4
  4. package/src/App.export.test.tsx +22 -4
  5. package/src/App.plan-review.test.tsx +127 -27
  6. package/src/App.readonly.test.tsx +31 -8
  7. package/src/App.rendering.test.tsx +12 -3
  8. package/src/App.review-panel.test.tsx +11 -3
  9. package/src/App.submit-confirm.test.tsx +12 -7
  10. package/src/App.test.tsx +46 -9
  11. package/src/App.toast.test.tsx +10 -3
  12. package/src/App.tsx +400 -100
  13. package/src/App.walk.test.tsx +30 -10
  14. package/src/attribution.ts +8 -2
  15. package/src/clipboard.ts +5 -1
  16. package/src/components/AnnotationCard.stories.tsx +28 -9
  17. package/src/components/AnnotationCard.tsx +53 -38
  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 +22 -5
  24. package/src/components/ConfirmDialog.tsx +9 -1
  25. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  26. package/src/components/DiffSheet.stories.tsx +18 -2
  27. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  28. package/src/components/DiffSheet.tsx +184 -98
  29. package/src/components/InboxList.stories.tsx +16 -3
  30. package/src/components/InboxList.tsx +17 -10
  31. package/src/components/KeybindsDialog.tsx +19 -3
  32. package/src/components/MarkerPopover.stories.tsx +58 -0
  33. package/src/components/MarkerPopover.tsx +102 -0
  34. package/src/components/MenuBar.stories.tsx +5 -1
  35. package/src/components/MenuBar.tsx +18 -2
  36. package/src/components/PlanSheet.stories.tsx +34 -2
  37. package/src/components/PlanSheet.tsx +141 -32
  38. package/src/components/PromptDialog.tsx +18 -2
  39. package/src/components/ReviewPanel.stories.tsx +13 -3
  40. package/src/components/ReviewPanel.tsx +8 -4
  41. package/src/components/ReviewRail.stories.tsx +145 -72
  42. package/src/components/ReviewRail.tsx +177 -38
  43. package/src/components/SettingsDialog.stories.tsx +51 -3
  44. package/src/components/SettingsDialog.tsx +8 -3
  45. package/src/components/SettingsRows.tsx +19 -3
  46. package/src/components/Toast.tsx +17 -2
  47. package/src/components/WalkWizard.stories.tsx +13 -2
  48. package/src/components/WalkWizard.tsx +9 -3
  49. package/src/components/__snapshots__/stories.test.tsx.snap +281 -83
  50. package/src/components/diff-sheet-layout.test.ts +127 -0
  51. package/src/components/diff-sheet-layout.ts +167 -0
  52. package/src/components/primitives/Button.tsx +5 -1
  53. package/src/components/primitives/Dialog.tsx +9 -1
  54. package/src/components/primitives/Tabs.tsx +24 -4
  55. package/src/components/stories-app.tsx +34 -6
  56. package/src/components/story-fixtures.ts +11 -3
  57. package/src/components/story.ts +3 -1
  58. package/src/components/syntax-highlight.ts +47 -12
  59. package/src/components/theme-context.stories.tsx +6 -1
  60. package/src/components/theme-context.tsx +7 -1
  61. package/src/config.test.ts +178 -2
  62. package/src/config.ts +121 -23
  63. package/src/diff-hunk-curate.test.ts +315 -0
  64. package/src/diff-hunk-curate.ts +233 -0
  65. package/src/diff-intraline.test.ts +164 -0
  66. package/src/diff-intraline.ts +132 -0
  67. package/src/diff-syntax.test.ts +96 -0
  68. package/src/diff-syntax.ts +154 -0
  69. package/src/editor.test.ts +13 -4
  70. package/src/editor.ts +31 -5
  71. package/src/herdr.ts +8 -2
  72. package/src/index.ts +9 -1
  73. package/src/integrations.ts +3 -1
  74. package/src/intent-dispatch.test.ts +193 -9
  75. package/src/intent-dispatch.ts +151 -18
  76. package/src/key-bindings.test.ts +19 -9
  77. package/src/key-bindings.ts +36 -14
  78. package/src/keymap.test.ts +171 -26
  79. package/src/keymap.ts +83 -16
  80. package/src/review-panel.test.ts +3 -1
  81. package/src/review-panel.ts +9 -2
  82. package/src/serve.test.ts +11 -3
  83. package/src/session-controller.curate.test.ts +253 -0
  84. package/src/session-controller.share.test.ts +65 -17
  85. package/src/session-controller.ts +301 -26
  86. package/src/share.ts +41 -8
  87. package/src/test-support.ts +16 -6
  88. package/src/theme-presets.ts +168 -0
  89. package/src/theme.ts +1 -3
  90. package/src/view-diff.ts +15 -4
  91. package/src/view-plan.test.ts +40 -6
  92. package/src/view-plan.ts +71 -28
  93. package/src/walk.test.ts +7 -2
  94. package/src/walk.ts +7 -2
package/package.json CHANGED
@@ -1,46 +1,47 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.33",
3
+ "version": "0.1.0-alpha.35",
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.33",
11
- "@cueloop/extension-api": "0.1.0-alpha.33",
12
- "@cueloop/integration-obsidian": "0.1.0-alpha.33",
13
- "@cueloop/schema": "0.1.0-alpha.33",
27
+ "@cueloop/daemon": "0.1.0-alpha.35",
28
+ "@cueloop/extension-api": "0.1.0-alpha.35",
29
+ "@cueloop/integration-obsidian": "0.1.0-alpha.35",
30
+ "@cueloop/schema": "0.1.0-alpha.35",
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
  }
@@ -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,7 +60,10 @@ 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
@@ -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",
@@ -222,9 +264,9 @@ describe("inline compose keeps the anchor painted", () => {
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
266
  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);
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,7 +288,9 @@ 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.
@@ -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",
@@ -433,7 +507,7 @@ describe("addressed annotations leave the open list", () => {
433
507
  expect(frame).toContain("✓ 1 addressed by revision");
434
508
  expect(frame).not.toContain("settled note");
435
509
  expect(frame).toContain("still open note"); // the open card survives; the addressed one does not
436
- expect(backgroundsOf(setup, "The daemon")).not.toContain(T.markCommentBackground); // no highlight paint
510
+ expect(backgroundsOf(setup, "The daemon")).not.toContain(DARK.markCommentBackground); // no highlight paint
437
511
  });
438
512
  });
439
513
 
@@ -483,12 +557,13 @@ describe("edit-exit reconciliation", () => {
483
557
  await press(setup, "c");
484
558
  await type(setup, "Anchor me to the doomed passage.");
485
559
  await press(setup, "enter");
486
- await waitForText(setup, "· pending");
560
+ await waitForText(setup, "COMMENT · me");
487
561
 
488
562
  // Act
489
- // 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)
490
565
  await press(setup, "escape");
491
- await waitForTextGone(setup, "▸ COMMENT");
566
+ await waitForState(setup, () => !hasBorderColor(setup, DARK.accent));
492
567
  await press(setup, "e");
493
568
 
494
569
  // Assert
@@ -502,3 +577,28 @@ describe("edit-exit reconciliation", () => {
502
577
  }
503
578
  });
504
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
+ });
@@ -9,7 +9,8 @@ import { testRender } from "@opentui/react/test-utils";
9
9
  import { DaemonServer } from "@cueloop/daemon";
10
10
  import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
11
11
  import { App } from "./App";
12
- import { isolateUserConfig, press, waitForText } from "./test-support";
12
+ import { DARK } from "./theme";
13
+ import { isolateUserConfig, press, waitForState, waitForText } from "./test-support";
13
14
 
14
15
  const PLAN = `# Migration Plan
15
16
 
@@ -35,7 +36,11 @@ beforeEach(() => {
35
36
  server.start();
36
37
  session = server.core.sessionCreate({
37
38
  workspace: { repoRoot: "/repo", branch: "main" },
38
- artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
39
+ artifact: {
40
+ type: "plan",
41
+ content: PLAN,
42
+ meta: { title: "Migration Plan", planPath: "plan.md" },
43
+ },
39
44
  });
40
45
  });
41
46
  afterEach(() => {
@@ -56,7 +61,11 @@ async function renderObserver() {
56
61
  /** Session state that any mutating verb would change. */
57
62
  function snapshot() {
58
63
  const stored = server.core.sessionGet(session.id);
59
- return { annotations: stored.annotations.length, workingCopy: stored.workingCopy, status: stored.status };
64
+ return {
65
+ annotations: stored.annotations.length,
66
+ workingCopy: stored.workingCopy,
67
+ status: stored.status,
68
+ };
60
69
  }
61
70
 
62
71
  describe("observer rendering", () => {
@@ -74,7 +83,6 @@ describe("observer rendering", () => {
74
83
  describe("observer verbs are blocked", () => {
75
84
  for (const [key, verb] of [
76
85
  ["c", "comment"],
77
- ["s", "suggest"],
78
86
  ["x", "cut"],
79
87
  ["e", "edit"],
80
88
  ["enter", "submit"],
@@ -88,13 +96,12 @@ describe("observer verbs are blocked", () => {
88
96
  const frame = await waitForText(setup, "observer - read-only");
89
97
  // no overlay opened: compose/submit bars never appear
90
98
  expect(frame).not.toContain('comment on "');
91
- expect(frame).not.toContain('suggest replacement for "');
92
99
  expect(frame).not.toContain("verdict ←/→");
93
100
  expect(snapshot()).toEqual(before);
94
101
  });
95
102
  }
96
103
 
97
- test("span mode c/s is blocked too", async () => {
104
+ test("span mode c is blocked too", async () => {
98
105
  // Arrange
99
106
  const setup = await renderObserver();
100
107
 
@@ -143,7 +150,23 @@ describe("observer navigation still works", () => {
143
150
  await press(setup, "n");
144
151
  await setup.renderOnce();
145
152
 
146
- // Assert
147
- await waitForText(setup, "▸ COMMENT");
153
+ // Assert - focus shows as the card's bright kind-colored border (transparent
154
+ // fill); a selected card is the only accent-colored border in the frame
155
+ await waitForText(setup, "COMMENT · me");
156
+ await waitForState(setup, () => hasBorderColor(setup, DARK.accent));
148
157
  });
149
158
  });
159
+
160
+ /** Whether any border character (box-drawing) is painted in the given foreground hex. */
161
+ function hasBorderColor(setup: Awaited<ReturnType<typeof renderObserver>>, hex: string): boolean {
162
+ for (const line of setup.captureSpans().lines) {
163
+ for (const span of line.spans) {
164
+ if (!/[╭╮╰╯│─┌┐└┘]/.test(span.text)) continue;
165
+ const [red, green, blue] = span.fg.toInts();
166
+ const rendered =
167
+ "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
168
+ if (rendered === hex) return true;
169
+ }
170
+ }
171
+ return false;
172
+ }