@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.
- package/native/build.sh +56 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/shim.c +107 -0
- package/package.json +34 -24
- package/src/App.autoclose.test.tsx +137 -0
- package/src/App.diff.test.tsx +158 -22
- package/src/App.export.test.tsx +44 -37
- package/src/App.plan-review.test.tsx +632 -0
- package/src/App.readonly.test.tsx +60 -36
- package/src/App.rendering.test.tsx +132 -0
- package/src/App.review-panel.test.tsx +136 -0
- package/src/App.submit-confirm.test.tsx +220 -0
- package/src/App.test.tsx +128 -69
- package/src/App.toast.test.tsx +75 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +288 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +38 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +30 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +228 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +44 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +83 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +59 -0
- package/src/components/ConfirmCard.stories.tsx +56 -0
- package/src/components/ConfirmCard.tsx +135 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +355 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +62 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +58 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +102 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +74 -0
- package/src/components/PlanSheet.stories.tsx +113 -0
- package/src/components/PlanSheet.tsx +389 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +78 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +101 -0
- package/src/components/ReviewRail.stories.tsx +220 -0
- package/src/components/ReviewRail.tsx +324 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +141 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +108 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +44 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1829 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +21 -0
- package/src/components/agent-launcher.tsx +180 -0
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +48 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +64 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +98 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.tsx +136 -0
- package/src/components/stories-app.tsx +123 -0
- package/src/components/stories.test.tsx +62 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +69 -0
- package/src/components/syntax-highlight.ts +92 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +174 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +36 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +430 -31
- package/src/config.ts +304 -26
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +233 -0
- package/src/diff-intraline.test.ts +164 -0
- package/src/diff-intraline.ts +132 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +154 -0
- package/src/editor.test.ts +143 -0
- package/src/editor.ts +123 -9
- package/src/ghostty-terminal.test.ts +45 -0
- package/src/ghostty-terminal.ts +168 -0
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +27 -0
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +30 -0
- package/src/intent-dispatch.test.ts +492 -0
- package/src/intent-dispatch.ts +428 -0
- package/src/key-bindings.test.ts +152 -0
- package/src/key-bindings.ts +424 -0
- package/src/keymap.test.ts +622 -0
- package/src/keymap.ts +339 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +24 -9
- package/src/serve.ts +7 -7
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +309 -0
- package/src/session-controller.ts +900 -0
- package/src/share.ts +113 -0
- package/src/test-support.test.ts +55 -0
- package/src/test-support.ts +187 -0
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +36 -15
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +34 -23
- package/src/view-plan.test.ts +294 -0
- package/src/view-plan.ts +408 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +70 -0
- package/src/view.test.ts +0 -157
- 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
|
+
});
|