@cueloop/client 0.1.0-alpha.3 → 0.1.0-alpha.30
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/package.json +17 -11
- package/src/App.autoclose.test.tsx +133 -0
- package/src/App.diff.test.tsx +27 -20
- package/src/App.export.test.tsx +19 -28
- package/src/App.plan-review.test.tsx +507 -0
- package/src/App.readonly.test.tsx +26 -27
- package/src/App.rendering.test.tsx +119 -0
- package/src/App.review-panel.test.tsx +123 -0
- package/src/App.submit-confirm.test.tsx +216 -0
- package/src/App.test.tsx +83 -64
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +265 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +32 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +26 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +75 -0
- package/src/components/AnnotationCard.tsx +208 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +38 -0
- package/src/components/CodeBlock.stories.tsx +25 -0
- package/src/components/CodeBlock.tsx +68 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +49 -0
- package/src/components/ComposeBar.stories.tsx +18 -0
- package/src/components/ComposeBar.tsx +54 -0
- package/src/components/ConfirmCard.stories.tsx +37 -0
- package/src/components/ConfirmCard.tsx +117 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +56 -0
- package/src/components/DiffSheet.stories.tsx +44 -0
- package/src/components/DiffSheet.tsx +243 -0
- package/src/components/InboxList.stories.tsx +18 -0
- package/src/components/InboxList.tsx +55 -0
- package/src/components/PlanSheet.stories.tsx +81 -0
- package/src/components/PlanSheet.tsx +329 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +62 -0
- package/src/components/ReviewPanel.stories.tsx +87 -0
- package/src/components/ReviewPanel.tsx +116 -0
- package/src/components/ReviewRail.stories.tsx +107 -0
- package/src/components/ReviewRail.tsx +180 -0
- package/src/components/SettingsDialog.stories.tsx +63 -0
- package/src/components/SettingsDialog.tsx +132 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +92 -0
- package/src/components/WalkWizard.stories.tsx +89 -0
- package/src/components/WalkWizard.tsx +146 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1519 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +44 -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 +53 -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 +97 -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/stories-app.tsx +95 -0
- package/src/components/stories.test.tsx +56 -0
- package/src/components/story-fixtures.ts +90 -0
- package/src/components/story.ts +66 -0
- package/src/components/syntax-highlight.ts +57 -0
- package/src/components/theme-context.stories.tsx +41 -0
- package/src/components/theme-context.tsx +30 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +196 -30
- package/src/config.ts +128 -16
- package/src/editor.test.ts +134 -0
- package/src/editor.ts +101 -13
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +21 -0
- package/src/index.ts +2 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +28 -0
- package/src/intent-dispatch.test.ts +308 -0
- package/src/intent-dispatch.ts +295 -0
- package/src/key-bindings.test.ts +142 -0
- package/src/key-bindings.ts +363 -0
- package/src/keymap.test.ts +477 -0
- package/src/keymap.ts +272 -0
- package/src/review-panel.test.ts +111 -0
- package/src/review-panel.ts +76 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +13 -6
- package/src/serve.ts +7 -7
- package/src/session-controller.share.test.ts +248 -0
- package/src/session-controller.ts +607 -0
- package/src/share.ts +80 -0
- package/src/test-support.ts +87 -0
- package/src/theme.ts +23 -1
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +22 -22
- package/src/view-plan.test.ts +260 -0
- package/src/view-plan.ts +365 -0
- package/src/walk.test.ts +116 -0
- package/src/walk.ts +65 -0
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
package/src/App.test.tsx
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Virtual-terminal component tests (tier 2): the real App over a real
|
|
3
|
-
* in-process daemon in a temp home. Char-frame assertions + mock keys -
|
|
4
|
-
* the whole review loop drivable without a terminal.
|
|
5
|
-
*/
|
|
1
|
+
/** Virtual-terminal component tests (tier 2): the real App over a real in-process daemon in a temp home. Char-frame assertions + mock keys - the whole review loop drivable without a terminal. */
|
|
6
2
|
|
|
7
3
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
4
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
@@ -13,6 +9,7 @@ import { testRender } from "@opentui/react/test-utils";
|
|
|
13
9
|
import { DaemonServer } from "@cueloop/daemon";
|
|
14
10
|
import type { ReviewSession } from "@cueloop/schema";
|
|
15
11
|
import { App } from "./App";
|
|
12
|
+
import { press, typeText as type, waitForState, waitForText } from "./test-support";
|
|
16
13
|
|
|
17
14
|
const PLAN = `# Migration Plan
|
|
18
15
|
|
|
@@ -49,78 +46,72 @@ async function renderApp(sessionId?: string) {
|
|
|
49
46
|
width: 120,
|
|
50
47
|
height: 32,
|
|
51
48
|
});
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
await Bun.sleep(25);
|
|
55
|
-
await setup.renderOnce();
|
|
56
|
-
}
|
|
57
|
-
await setup.renderOnce();
|
|
49
|
+
// the async daemon connect + first fetch land within the frame wait
|
|
50
|
+
await waitForText(setup, "cueloop");
|
|
58
51
|
return setup;
|
|
59
52
|
}
|
|
60
53
|
|
|
61
|
-
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
62
|
-
|
|
63
|
-
/** Drive one key press: letters type as text; named keys use KeyCodes ids. */
|
|
64
|
-
async function press(setup: Setup, k: string): Promise<void> {
|
|
65
|
-
if (k === "enter") setup.mockInput.pressKey("RETURN");
|
|
66
|
-
else if (k === "escape") setup.mockInput.pressKey("ESCAPE");
|
|
67
|
-
else if (k === "backspace") setup.mockInput.pressKey("BACKSPACE");
|
|
68
|
-
else if (k === "left") setup.mockInput.pressKey("ARROW_LEFT");
|
|
69
|
-
else if (k === "right") setup.mockInput.pressKey("ARROW_RIGHT");
|
|
70
|
-
else await type(setup, k);
|
|
71
|
-
await Bun.sleep(15);
|
|
72
|
-
await setup.renderOnce();
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function type(setup: Setup, text: string): Promise<void> {
|
|
76
|
-
await setup.mockInput.typeText(text);
|
|
77
|
-
await Bun.sleep(15);
|
|
78
|
-
await setup.renderOnce();
|
|
79
|
-
}
|
|
80
|
-
|
|
81
54
|
describe("plan rendering", () => {
|
|
82
55
|
test("renders the plan with headings, list markers, and the rail", async () => {
|
|
56
|
+
// Arrange
|
|
83
57
|
const setup = await renderApp();
|
|
58
|
+
|
|
59
|
+
// Assert
|
|
84
60
|
const frame = setup.captureCharFrame();
|
|
85
61
|
expect(frame).toContain("Migration Plan");
|
|
86
62
|
expect(frame).toContain("Context");
|
|
87
63
|
expect(frame).toContain("persists sessions to disk atomically");
|
|
88
64
|
expect(frame).toContain("- move the store");
|
|
89
|
-
expect(frame).toContain("
|
|
65
|
+
expect(frame).toContain("Review (0)");
|
|
90
66
|
expect(frame).toContain("Submit review (0)");
|
|
91
67
|
});
|
|
92
68
|
});
|
|
93
69
|
|
|
94
70
|
describe("keyboard grammar", () => {
|
|
95
71
|
test("j/k moves the cursor glyph between blocks", async () => {
|
|
72
|
+
// Arrange
|
|
96
73
|
const setup = await renderApp();
|
|
74
|
+
|
|
75
|
+
// Act
|
|
97
76
|
await press(setup, "j");
|
|
98
77
|
await press(setup, "j");
|
|
99
78
|
await setup.renderOnce();
|
|
79
|
+
|
|
80
|
+
// Assert
|
|
100
81
|
const lines = setup.captureCharFrame().split("\n");
|
|
101
|
-
const cursorLine = lines.find((
|
|
82
|
+
const cursorLine = lines.find((line) => line.includes("▎"))!;
|
|
102
83
|
expect(cursorLine).toContain("persists sessions");
|
|
103
84
|
});
|
|
104
85
|
|
|
105
86
|
test("comment flow: c types a body, ⏎ saves to the daemon", async () => {
|
|
87
|
+
// Arrange
|
|
106
88
|
const setup = await renderApp();
|
|
89
|
+
|
|
90
|
+
// Act
|
|
107
91
|
await press(setup, "j");
|
|
108
92
|
await press(setup, "j");
|
|
109
93
|
await press(setup, "c");
|
|
110
94
|
await setup.renderOnce();
|
|
111
|
-
|
|
95
|
+
|
|
96
|
+
// Assert
|
|
97
|
+
await waitForText(setup, 'comment on "The daemon');
|
|
98
|
+
|
|
99
|
+
// Act
|
|
112
100
|
await type(setup, "Define atomically.");
|
|
113
101
|
await press(setup, "enter");
|
|
114
|
-
|
|
115
|
-
|
|
102
|
+
|
|
103
|
+
// Assert
|
|
104
|
+
await waitForText(setup, "COMMENT");
|
|
116
105
|
const stored = server.core.sessionGet(session.id);
|
|
117
106
|
expect(stored.annotations.length).toBe(1);
|
|
118
107
|
expect(stored.annotations[0]!.body).toBe("Define atomically.");
|
|
119
|
-
expect(setup.captureCharFrame()).toContain("COMMENT");
|
|
120
108
|
});
|
|
121
109
|
|
|
122
110
|
test("span mode: v + l selects words, c anchors the exact span", async () => {
|
|
111
|
+
// Arrange
|
|
123
112
|
const setup = await renderApp();
|
|
113
|
+
|
|
114
|
+
// Act
|
|
124
115
|
await press(setup, "j");
|
|
125
116
|
await press(setup, "j");
|
|
126
117
|
await press(setup, "v");
|
|
@@ -128,24 +119,31 @@ describe("keyboard grammar", () => {
|
|
|
128
119
|
await press(setup, "c");
|
|
129
120
|
await type(setup, "Which daemon?");
|
|
130
121
|
await press(setup, "enter");
|
|
131
|
-
|
|
122
|
+
|
|
123
|
+
// Assert
|
|
124
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
132
125
|
const stored = server.core.sessionGet(session.id);
|
|
133
126
|
expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
|
|
134
127
|
});
|
|
135
128
|
|
|
136
129
|
test("x cuts a block into the working copy; x restores it", async () => {
|
|
130
|
+
// Arrange
|
|
137
131
|
const setup = await renderApp();
|
|
138
132
|
// move to "- move the store" (h1, h2, p, h2 = 4 steps in)
|
|
139
133
|
for (let i = 0; i < 4; i++) await press(setup, "j");
|
|
134
|
+
|
|
135
|
+
// Act
|
|
140
136
|
await press(setup, "x");
|
|
141
|
-
|
|
142
|
-
|
|
137
|
+
|
|
138
|
+
// Assert
|
|
139
|
+
await waitForText(setup, "[cut]");
|
|
143
140
|
expect(server.core.sessionGet(session.id).workingCopy).not.toContain("move the store");
|
|
144
|
-
|
|
141
|
+
|
|
142
|
+
// Act
|
|
145
143
|
await press(setup, "x");
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
144
|
+
|
|
145
|
+
// Assert
|
|
146
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
|
|
149
147
|
});
|
|
150
148
|
|
|
151
149
|
test("e runs $EDITOR on the working copy and tracks the diff", async () => {
|
|
@@ -154,12 +152,15 @@ describe("keyboard grammar", () => {
|
|
|
154
152
|
Bun.spawnSync(["chmod", "+x", script]);
|
|
155
153
|
process.env.CUELOOP_EDITOR = script;
|
|
156
154
|
try {
|
|
155
|
+
// Arrange
|
|
157
156
|
const setup = await renderApp();
|
|
157
|
+
|
|
158
|
+
// Act
|
|
158
159
|
await press(setup, "e");
|
|
159
|
-
|
|
160
|
-
|
|
160
|
+
|
|
161
|
+
// Assert
|
|
162
|
+
await waitForText(setup, "[edited]");
|
|
161
163
|
expect(server.core.sessionGet(session.id).workingCopy).toContain("very atomically");
|
|
162
|
-
expect(setup.captureCharFrame()).toContain("[edited]");
|
|
163
164
|
} finally {
|
|
164
165
|
delete process.env.CUELOOP_EDITOR;
|
|
165
166
|
}
|
|
@@ -167,51 +168,69 @@ describe("keyboard grammar", () => {
|
|
|
167
168
|
});
|
|
168
169
|
|
|
169
170
|
describe("submit", () => {
|
|
170
|
-
test("⏎ opens the
|
|
171
|
+
test("⏎ opens the rail confirm card; verdict + summary resolve the session", async () => {
|
|
172
|
+
// Arrange
|
|
171
173
|
const setup = await renderApp();
|
|
172
174
|
await press(setup, "j");
|
|
173
175
|
await press(setup, "j");
|
|
174
176
|
await press(setup, "c");
|
|
175
177
|
await type(setup, "Needs a phase list.");
|
|
176
178
|
await press(setup, "enter");
|
|
177
|
-
await
|
|
179
|
+
await waitForText(setup, "Review (1)");
|
|
180
|
+
|
|
181
|
+
// Act
|
|
178
182
|
await press(setup, "enter"); // open submit
|
|
179
|
-
|
|
180
|
-
|
|
183
|
+
|
|
184
|
+
// Assert
|
|
185
|
+
await waitForText(setup, "[Changes]");
|
|
186
|
+
|
|
187
|
+
// Act
|
|
181
188
|
await type(setup, "Expand the steps.");
|
|
182
189
|
await press(setup, "enter");
|
|
183
|
-
|
|
184
|
-
|
|
190
|
+
|
|
191
|
+
// Assert
|
|
192
|
+
await waitForText(setup, "feedback sent");
|
|
185
193
|
const stored = server.core.sessionGet(session.id);
|
|
186
194
|
expect(stored.status).toBe("resolved");
|
|
187
195
|
expect(stored.verdict!.kind).toBe("request_changes");
|
|
188
196
|
expect(stored.verdict!.feedback).toContain("Needs a phase list.");
|
|
189
|
-
|
|
197
|
+
// submit hands the reviewer back to the agent via the completion overlay
|
|
198
|
+
expect(setup.captureCharFrame()).toContain("feedback sent");
|
|
190
199
|
});
|
|
191
200
|
|
|
192
201
|
test("approve via ←/→ verdict cycling", async () => {
|
|
202
|
+
// Arrange
|
|
193
203
|
const setup = await renderApp();
|
|
204
|
+
|
|
205
|
+
// Act
|
|
194
206
|
await press(setup, "enter");
|
|
195
|
-
|
|
196
|
-
|
|
207
|
+
|
|
208
|
+
// Assert
|
|
209
|
+
await waitForText(setup, "[Approve]"); // no pending items → approve default
|
|
210
|
+
|
|
211
|
+
// Act
|
|
197
212
|
await press(setup, "enter");
|
|
198
|
-
|
|
213
|
+
|
|
214
|
+
// Assert
|
|
215
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).verdict !== undefined);
|
|
199
216
|
expect(server.core.sessionGet(session.id).verdict!.kind).toBe("approve");
|
|
200
217
|
});
|
|
201
218
|
});
|
|
202
219
|
|
|
203
220
|
describe("inbox", () => {
|
|
204
221
|
test("inbox mode renders and opens a session", async () => {
|
|
222
|
+
// Arrange
|
|
205
223
|
const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
224
|
+
await waitForText(setup, "inbox");
|
|
225
|
+
|
|
226
|
+
// Assert
|
|
210
227
|
expect(setup.captureCharFrame()).toContain("inbox (1 pending)");
|
|
211
228
|
expect(setup.captureCharFrame()).toContain("Migration Plan");
|
|
229
|
+
|
|
230
|
+
// Act
|
|
212
231
|
await press(setup, "enter");
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
232
|
+
|
|
233
|
+
// Assert
|
|
234
|
+
await waitForText(setup, "Submit review (0)");
|
|
216
235
|
});
|
|
217
236
|
});
|
package/src/App.tsx
CHANGED
|
Binary file
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/** The guided walk in the virtual terminal: the wizard steps every changed file with a plain progress title, ] marks viewed and persists with the session, esc keeps progress, w resumes at the first unviewed file (across App instances via the daemon round-trip), the agent-note block renders only for files carrying a note, and the end card hands over to the submit confirm with the honest viewed count. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
+
import { mkdtempSync, 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 { DARK } from "./theme";
|
|
13
|
+
import { press, waitForState, waitForText, waitForTextGone } from "./test-support";
|
|
14
|
+
|
|
15
|
+
const PATCH = `diff --git a/src/a.ts b/src/a.ts
|
|
16
|
+
index 111..222 100644
|
|
17
|
+
--- a/src/a.ts
|
|
18
|
+
+++ b/src/a.ts
|
|
19
|
+
@@ -1,2 +1,2 @@
|
|
20
|
+
export const a = 1;
|
|
21
|
+
-export const b = 2;
|
|
22
|
+
+export const b = 3;
|
|
23
|
+
diff --git a/src/b.ts b/src/b.ts
|
|
24
|
+
index 333..444 100644
|
|
25
|
+
--- a/src/b.ts
|
|
26
|
+
+++ b/src/b.ts
|
|
27
|
+
@@ -1,2 +1,2 @@
|
|
28
|
+
// header
|
|
29
|
+
-old line
|
|
30
|
+
+new line
|
|
31
|
+
diff --git a/src/c.ts b/src/c.ts
|
|
32
|
+
index 555..666 100644
|
|
33
|
+
--- a/src/c.ts
|
|
34
|
+
+++ b/src/c.ts
|
|
35
|
+
@@ -1,1 +1,2 @@
|
|
36
|
+
keep
|
|
37
|
+
+added tail
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
let home: string;
|
|
41
|
+
let server: DaemonServer;
|
|
42
|
+
let session: ReviewSession;
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-walk-"));
|
|
46
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
47
|
+
server.start();
|
|
48
|
+
session = server.core.sessionCreate({
|
|
49
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
50
|
+
artifact: { type: "diff", content: PATCH, meta: { title: "working tree" } },
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
server.stop();
|
|
55
|
+
rmSync(home, { recursive: true, force: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
async function renderApp() {
|
|
59
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 34 });
|
|
60
|
+
// Wait for session content, not just the chrome: pressing w before the diff
|
|
61
|
+
// loads drops the walk intent (no session, no diff view) and hangs the test.
|
|
62
|
+
await waitForText(setup, "src/a.ts");
|
|
63
|
+
return setup;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
67
|
+
|
|
68
|
+
/** Foreground colors (hex) of every styled span containing the needle. */
|
|
69
|
+
function foregroundsOf(setup: Setup, needle: string): string[] {
|
|
70
|
+
const foregrounds: string[] = [];
|
|
71
|
+
for (const line of setup.captureSpans().lines) {
|
|
72
|
+
for (const span of line.spans) {
|
|
73
|
+
if (!span.text.includes(needle)) continue;
|
|
74
|
+
const [red, green, blue] = span.fg.toInts();
|
|
75
|
+
foregrounds.push("#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return foregrounds;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("the guided walk", () => {
|
|
82
|
+
test("w walks all three files: progress title, persisted marks, end card, submit hand-over", async () => {
|
|
83
|
+
// Arrange
|
|
84
|
+
const setup = await renderApp();
|
|
85
|
+
|
|
86
|
+
// Act
|
|
87
|
+
await press(setup, "w");
|
|
88
|
+
|
|
89
|
+
// Assert
|
|
90
|
+
await waitForText(setup, "file 1 of 3 · 0 viewed");
|
|
91
|
+
expect(setup.captureCharFrame()).toContain("src/a.ts");
|
|
92
|
+
expect(setup.captureCharFrame()).toContain("+1 -1");
|
|
93
|
+
|
|
94
|
+
// Act
|
|
95
|
+
await press(setup, "]");
|
|
96
|
+
|
|
97
|
+
// Assert
|
|
98
|
+
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
99
|
+
// the viewed mark rides the session record, not the client
|
|
100
|
+
await waitForState(setup, () => (server.core.sessionGet(session.id).viewedPaths ?? []).includes("src/a.ts"));
|
|
101
|
+
|
|
102
|
+
// Act
|
|
103
|
+
// [ steps back; the revisited card shows its viewed marker
|
|
104
|
+
await press(setup, "[");
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
107
|
+
await waitForText(setup, "file 1 of 3 · 1 viewed");
|
|
108
|
+
expect(setup.captureCharFrame()).toContain("· viewed");
|
|
109
|
+
|
|
110
|
+
// Act
|
|
111
|
+
await press(setup, "]");
|
|
112
|
+
|
|
113
|
+
// Assert
|
|
114
|
+
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
115
|
+
|
|
116
|
+
// Act
|
|
117
|
+
await press(setup, "]");
|
|
118
|
+
|
|
119
|
+
// Assert
|
|
120
|
+
await waitForText(setup, "file 3 of 3 · 2 viewed");
|
|
121
|
+
|
|
122
|
+
// Act
|
|
123
|
+
await press(setup, "]");
|
|
124
|
+
|
|
125
|
+
// Assert
|
|
126
|
+
await waitForText(setup, "walk complete");
|
|
127
|
+
expect(setup.captureCharFrame()).toContain("every file viewed (3/3)");
|
|
128
|
+
expect(server.core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
|
|
129
|
+
|
|
130
|
+
// Act
|
|
131
|
+
// the end card's return leaves the walk and opens the rail confirm
|
|
132
|
+
await press(setup, "enter");
|
|
133
|
+
|
|
134
|
+
// Assert
|
|
135
|
+
await waitForText(setup, "submit review");
|
|
136
|
+
expect(setup.captureCharFrame()).toContain("3/3 files viewed");
|
|
137
|
+
|
|
138
|
+
// Act
|
|
139
|
+
await press(setup, "enter");
|
|
140
|
+
|
|
141
|
+
// Assert
|
|
142
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("esc keeps progress and w resumes at the first unviewed file", async () => {
|
|
146
|
+
// Arrange
|
|
147
|
+
const setup = await renderApp();
|
|
148
|
+
await press(setup, "w");
|
|
149
|
+
await waitForText(setup, "file 1 of 3 · 0 viewed");
|
|
150
|
+
await press(setup, "]");
|
|
151
|
+
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
152
|
+
|
|
153
|
+
// Act
|
|
154
|
+
await press(setup, "escape");
|
|
155
|
+
|
|
156
|
+
// Assert
|
|
157
|
+
await waitForTextGone(setup, "file 2 of 3");
|
|
158
|
+
// the sheet is back at full strength: no wizard card on screen
|
|
159
|
+
expect(setup.captureCharFrame()).not.toContain("· 1 viewed");
|
|
160
|
+
|
|
161
|
+
// Act
|
|
162
|
+
await press(setup, "w");
|
|
163
|
+
|
|
164
|
+
// Assert
|
|
165
|
+
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("a half-walked review resumes across App instances via the daemon", async () => {
|
|
169
|
+
// Arrange
|
|
170
|
+
const first = await renderApp();
|
|
171
|
+
await press(first, "w");
|
|
172
|
+
await waitForText(first, "file 1 of 3 · 0 viewed");
|
|
173
|
+
await press(first, "]");
|
|
174
|
+
await press(first, "]");
|
|
175
|
+
await waitForText(first, "file 3 of 3 · 2 viewed");
|
|
176
|
+
await waitForState(first, () => (server.core.sessionGet(session.id).viewedPaths ?? []).length === 2);
|
|
177
|
+
first.renderer.destroy();
|
|
178
|
+
|
|
179
|
+
// Act
|
|
180
|
+
// a fresh client reads the viewed set back from the session record
|
|
181
|
+
const second = await renderApp();
|
|
182
|
+
await press(second, "w");
|
|
183
|
+
|
|
184
|
+
// Assert
|
|
185
|
+
await waitForText(second, "file 3 of 3 · 2 viewed");
|
|
186
|
+
expect(second.captureCharFrame()).toContain("src/c.ts");
|
|
187
|
+
// two full App boots with daemon round-trips need more than the default
|
|
188
|
+
// budget, and the whole-suite publish lane has timed even 15s out; give the
|
|
189
|
+
// double-boot chain generous headroom so runner load cannot flake it.
|
|
190
|
+
}, 30_000);
|
|
191
|
+
|
|
192
|
+
test("the agent-note block renders only for files carrying a note", async () => {
|
|
193
|
+
// Arrange
|
|
194
|
+
server.core.sessionAnnotate(session.id, {
|
|
195
|
+
id: "note-b",
|
|
196
|
+
kind: "note",
|
|
197
|
+
anchor: { quote: "src/b.ts", prefix: "", suffix: "" },
|
|
198
|
+
body: "Swaps the stale line for the new constant.",
|
|
199
|
+
});
|
|
200
|
+
const setup = await renderApp();
|
|
201
|
+
|
|
202
|
+
// Act
|
|
203
|
+
await press(setup, "w");
|
|
204
|
+
|
|
205
|
+
// Assert
|
|
206
|
+
await waitForText(setup, "file 1 of 3");
|
|
207
|
+
expect(setup.captureCharFrame()).not.toContain("agent note");
|
|
208
|
+
|
|
209
|
+
// Act
|
|
210
|
+
await press(setup, "]");
|
|
211
|
+
|
|
212
|
+
// Assert
|
|
213
|
+
await waitForText(setup, "agent note");
|
|
214
|
+
expect(setup.captureCharFrame()).toContain("Swaps the stale line for the new constant.");
|
|
215
|
+
|
|
216
|
+
// Act
|
|
217
|
+
// a note is agent context, not reviewer feedback: it neither counts nor
|
|
218
|
+
// flips the default verdict, and never comes back in the feedback doc
|
|
219
|
+
await press(setup, "escape");
|
|
220
|
+
await waitForTextGone(setup, "agent note");
|
|
221
|
+
await press(setup, "enter");
|
|
222
|
+
await waitForText(setup, "0 annotations");
|
|
223
|
+
await press(setup, "enter");
|
|
224
|
+
|
|
225
|
+
// Assert
|
|
226
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
|
|
227
|
+
const resolved = server.core.sessionGet(session.id);
|
|
228
|
+
expect(resolved.verdict!.kind).toBe("approve");
|
|
229
|
+
expect(resolved.verdict!.feedback).not.toContain("Swaps the stale line");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("the sheet dims behind the wizard; the preview keeps the diff colors", async () => {
|
|
233
|
+
// Arrange
|
|
234
|
+
const setup = await renderApp();
|
|
235
|
+
|
|
236
|
+
// Assert
|
|
237
|
+
// before the walk the sheet's added line wears the insertion color
|
|
238
|
+
await waitForText(setup, "+new line");
|
|
239
|
+
expect(foregroundsOf(setup, "+new line")).toContain(DARK.insFg);
|
|
240
|
+
|
|
241
|
+
// Act
|
|
242
|
+
await press(setup, "w");
|
|
243
|
+
|
|
244
|
+
// Assert
|
|
245
|
+
await waitForText(setup, "file 1 of 3 · 0 viewed");
|
|
246
|
+
// dimmed: the sheet line drops to the dim token...
|
|
247
|
+
expect(foregroundsOf(setup, "+new line")).toEqual([DARK.textDim]);
|
|
248
|
+
// ...while the wizard preview keeps the insertion color
|
|
249
|
+
expect(foregroundsOf(setup, "+export const b = 3;")).toContain(DARK.insFg);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("walking a resolved review answers read-only", async () => {
|
|
253
|
+
// Arrange
|
|
254
|
+
server.core.sessionResolve(session.id, "approve", "");
|
|
255
|
+
const setup = await renderApp();
|
|
256
|
+
await waitForText(setup, "resolved");
|
|
257
|
+
|
|
258
|
+
// Act
|
|
259
|
+
await press(setup, "w");
|
|
260
|
+
|
|
261
|
+
// Assert
|
|
262
|
+
await waitForText(setup, "review submitted - read-only");
|
|
263
|
+
expect(setup.captureCharFrame()).not.toContain("file 1 of 3");
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { Identity } from "@cueloop/schema";
|
|
3
|
+
import { ANONYMOUS_LABEL, resolveDisplayName, shortHandle } from "./attribution";
|
|
4
|
+
|
|
5
|
+
describe(shortHandle, () => {
|
|
6
|
+
test("shortens an ssh fingerprint to a stable handle", () => {
|
|
7
|
+
// Arrange / Act / Assert
|
|
8
|
+
expect(shortHandle("SHA256:1a2b3c4d5e6f")).toBe("1a2b3c4d");
|
|
9
|
+
expect(shortHandle("SHA256:1a2b3c4d5e6f")).toBe(shortHandle("SHA256:1a2b3c4d5e6f"));
|
|
10
|
+
});
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe(resolveDisplayName, () => {
|
|
14
|
+
const named: Identity[] = [{ id: "SHA256:abc", provider: "ssh", name: "Al" }];
|
|
15
|
+
const unnamed: Identity[] = [{ id: "SHA256:abc", provider: "ssh" }];
|
|
16
|
+
|
|
17
|
+
test("a local rename override wins over everything", () => {
|
|
18
|
+
// Arrange / Act
|
|
19
|
+
const label = resolveDisplayName("SHA256:abc", named, { "SHA256:abc": "Alex" });
|
|
20
|
+
|
|
21
|
+
// Assert
|
|
22
|
+
expect(label).toBe("Alex");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("falls back to name, then anonymous, then the short handle", () => {
|
|
26
|
+
// Arrange / Act / Assert
|
|
27
|
+
expect(resolveDisplayName("SHA256:abc", named, {})).toBe("Al");
|
|
28
|
+
expect(resolveDisplayName("SHA256:abc", unnamed, {})).toBe(ANONYMOUS_LABEL);
|
|
29
|
+
expect(resolveDisplayName("SHA256:1a2b3c4d5e", [], {})).toBe("1a2b3c4d");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attribution labels for annotations. An annotation's `author` is an identity
|
|
3
|
+
* id (an SSH fingerprint today). The rail resolves it against the share's
|
|
4
|
+
* participant registry and the planner's local rename overrides; ADR 0006.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Identity } from "@cueloop/schema";
|
|
8
|
+
|
|
9
|
+
const FINGERPRINT_PREFIX = "SHA256:";
|
|
10
|
+
|
|
11
|
+
/** Shown for a known participant who chose not to give a name. */
|
|
12
|
+
export const ANONYMOUS_LABEL = "-- anonymous --";
|
|
13
|
+
|
|
14
|
+
/** A short, stable handle from a raw author id when no name is known. */
|
|
15
|
+
export function shortHandle(authorId: string): string {
|
|
16
|
+
const base = authorId.startsWith(FINGERPRINT_PREFIX) ? authorId.slice(FINGERPRINT_PREFIX.length) : authorId;
|
|
17
|
+
return base.slice(0, 8) || authorId;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The rail label for an author id: a local rename wins, then the collaborator's
|
|
22
|
+
* own name, then anonymous for a known-but-unnamed participant, then a short
|
|
23
|
+
* handle for an id we hold no identity for.
|
|
24
|
+
*/
|
|
25
|
+
export function resolveDisplayName(authorId: string, participants: Identity[] | undefined, overrides: Record<string, string>): string {
|
|
26
|
+
const override = overrides[authorId];
|
|
27
|
+
if (override) return override;
|
|
28
|
+
const identity = participants?.find((participant) => participant.id === authorId);
|
|
29
|
+
if (identity?.name) return identity.name;
|
|
30
|
+
if (identity) return ANONYMOUS_LABEL;
|
|
31
|
+
return shortHandle(authorId);
|
|
32
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { clipboardCommands } from "./clipboard";
|
|
3
|
+
|
|
4
|
+
describe(clipboardCommands, () => {
|
|
5
|
+
test("uses pbcopy on macOS", () => {
|
|
6
|
+
// Assert
|
|
7
|
+
expect(clipboardCommands("darwin")).toEqual([["pbcopy"]]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("uses clip.exe on Windows", () => {
|
|
11
|
+
// Assert
|
|
12
|
+
expect(clipboardCommands("win32")).toEqual([["clip.exe"]]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test("prefers wayland, then X11 tools on Linux", () => {
|
|
16
|
+
// Act
|
|
17
|
+
const commands = clipboardCommands("linux");
|
|
18
|
+
|
|
19
|
+
// Assert
|
|
20
|
+
expect(commands[0]).toEqual(["wl-copy"]);
|
|
21
|
+
expect(commands.map((command) => command[0])).toEqual(["wl-copy", "xclip", "xsel"]);
|
|
22
|
+
});
|
|
23
|
+
});
|
package/src/clipboard.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort system clipboard. Each platform has its own tool; we try them in
|
|
3
|
+
* order and report whether one worked, so the caller can fall back to printing
|
|
4
|
+
* the line (the over-SSH case, where no clipboard tool exists). Mirrors how
|
|
5
|
+
* herdr confirms a copy.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The clipboard-write commands to try, most-preferred first, for this platform. */
|
|
9
|
+
export function clipboardCommands(platform: NodeJS.Platform = process.platform): string[][] {
|
|
10
|
+
if (platform === "darwin") return [["pbcopy"]];
|
|
11
|
+
if (platform === "win32") return [["clip.exe"]];
|
|
12
|
+
return [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Copy `text` to the clipboard; false when no tool is available or all fail. */
|
|
16
|
+
export async function copyToClipboard(text: string): Promise<boolean> {
|
|
17
|
+
for (const command of clipboardCommands()) {
|
|
18
|
+
try {
|
|
19
|
+
const proc = Bun.spawn(command, { stdin: Buffer.from(text), stdout: "ignore", stderr: "ignore" });
|
|
20
|
+
if ((await proc.exited) === 0) return true;
|
|
21
|
+
} catch {
|
|
22
|
+
// tool not installed - try the next one
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return false;
|
|
26
|
+
}
|