@cueloop/client 0.1.0-alpha.2 → 0.1.0-alpha.20
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 +109 -0
- package/src/App.diff.test.tsx +5 -18
- package/src/App.export.test.tsx +8 -13
- package/src/App.plan-review.test.tsx +232 -0
- package/src/App.readonly.test.tsx +6 -21
- package/src/App.rendering.test.tsx +100 -0
- package/src/App.submit-confirm.test.tsx +165 -0
- package/src/App.test.tsx +20 -58
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +208 -0
- package/src/components/AnnotationCard.stories.tsx +75 -0
- package/src/components/AnnotationCard.tsx +136 -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/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 +42 -0
- package/src/components/PlanSheet.stories.tsx +81 -0
- package/src/components/PlanSheet.tsx +309 -0
- package/src/components/ReviewRail.stories.tsx +103 -0
- package/src/components/ReviewRail.tsx +158 -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 +1319 -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 +58 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +52 -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/stories-app.tsx +94 -0
- package/src/components/stories.test.tsx +51 -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 +54 -29
- package/src/config.ts +53 -13
- package/src/editor.test.ts +110 -0
- package/src/editor.ts +101 -13
- package/src/herdr.test.ts +37 -0
- package/src/herdr.ts +21 -0
- package/src/integrations.test.ts +29 -0
- package/src/integrations.ts +28 -0
- package/src/key-bindings.test.ts +120 -0
- package/src/key-bindings.ts +353 -0
- package/src/keymap.test.ts +307 -0
- package/src/keymap.ts +204 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +6 -6
- package/src/serve.ts +7 -7
- package/src/session-controller.ts +496 -0
- package/src/test-support.ts +87 -0
- package/src/theme.ts +23 -1
- package/src/view-diff.test.ts +8 -8
- package/src/view-diff.ts +22 -22
- package/src/view-plan.test.ts +179 -0
- package/src/view-plan.ts +362 -0
- package/src/walk.test.ts +110 -0
- package/src/walk.ts +65 -0
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rail submit confirm (tier 2): pressing submit expands the rail's
|
|
3
|
+
* Submit button into a bordered confirm card - counts, verdict selector,
|
|
4
|
+
* summary input, word-buttons. Char-frame assertions over the real App
|
|
5
|
+
* and an in-process daemon, like App.test.tsx.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import React from "react";
|
|
13
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
14
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
15
|
+
import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
|
|
16
|
+
import { App } from "./App";
|
|
17
|
+
import { press, settle, waitForText, waitForTextGone } from "./test-support";
|
|
18
|
+
|
|
19
|
+
const PLAN = `# Migration Plan
|
|
20
|
+
|
|
21
|
+
## Context
|
|
22
|
+
|
|
23
|
+
The daemon persists sessions to disk atomically.
|
|
24
|
+
|
|
25
|
+
## Steps
|
|
26
|
+
|
|
27
|
+
- move the store
|
|
28
|
+
- add recovery
|
|
29
|
+
`;
|
|
30
|
+
|
|
31
|
+
let home: string;
|
|
32
|
+
let server: DaemonServer;
|
|
33
|
+
let session: ReviewSession;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-confirm-"));
|
|
37
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
38
|
+
server.start();
|
|
39
|
+
session = server.core.sessionCreate({
|
|
40
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
41
|
+
artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
server.stop();
|
|
46
|
+
rmSync(home, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
async function renderApp(options: { readOnly?: boolean } = {}) {
|
|
50
|
+
const setup = await testRender(<App home={home} sessionId={session.id} readOnly={options.readOnly ?? false} />, {
|
|
51
|
+
width: 120,
|
|
52
|
+
height: 32,
|
|
53
|
+
});
|
|
54
|
+
await waitForText(setup, "cueloop");
|
|
55
|
+
return setup;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
59
|
+
|
|
60
|
+
/** Seed annotations directly through the daemon core (all on one block). */
|
|
61
|
+
function seedAnnotations(count: number): void {
|
|
62
|
+
const blocks = parseBlocks(PLAN);
|
|
63
|
+
for (let index = 1; index <= count; index++) {
|
|
64
|
+
server.core.sessionAnnotate(session.id, {
|
|
65
|
+
id: `a_confirm_${index}`,
|
|
66
|
+
kind: "comment",
|
|
67
|
+
anchor: makeAnchor(blocks, 2, 0, 10),
|
|
68
|
+
body: `note ${String(index).padStart(2, "0")}`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("rail submit confirm", () => {
|
|
74
|
+
test("submit expands the Submit button into the confirm card", async () => {
|
|
75
|
+
const setup = await renderApp();
|
|
76
|
+
expect(setup.captureCharFrame()).toContain("Submit review (0)");
|
|
77
|
+
await press(setup, "enter");
|
|
78
|
+
const frame = setup.captureCharFrame();
|
|
79
|
+
// the card: title, honest counts, verdict selector, word-buttons
|
|
80
|
+
expect(frame).toContain("submit review");
|
|
81
|
+
expect(frame).toContain("0 annotations · 0 blocking");
|
|
82
|
+
expect(frame).toContain("[Approve]"); // nothing pending: approve default
|
|
83
|
+
expect(frame).toContain(" Submit ");
|
|
84
|
+
expect(frame).toContain(" Cancel ");
|
|
85
|
+
// key hints live in the status line, not on the buttons
|
|
86
|
+
expect(frame).toContain("verdict ←/→ · ⏎ submit · esc cancel");
|
|
87
|
+
// the bottom bar stayed a one-line hint: no detached verdict bar
|
|
88
|
+
expect(frame).not.toContain("Submit review (0) on ⏎");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("left/right cycles the verdict selector in the card", async () => {
|
|
92
|
+
const setup = await renderApp();
|
|
93
|
+
await press(setup, "enter");
|
|
94
|
+
expect(setup.captureCharFrame()).toContain("[Approve]");
|
|
95
|
+
await press(setup, "right");
|
|
96
|
+
expect(setup.captureCharFrame()).toContain("[Changes]");
|
|
97
|
+
await press(setup, "right");
|
|
98
|
+
expect(setup.captureCharFrame()).toContain("[Comment]");
|
|
99
|
+
await press(setup, "left");
|
|
100
|
+
expect(setup.captureCharFrame()).toContain("[Changes]");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("esc cancels the card and restores the plain Submit button", async () => {
|
|
104
|
+
const setup = await renderApp();
|
|
105
|
+
await press(setup, "enter");
|
|
106
|
+
expect(setup.captureCharFrame()).toContain("[Approve]");
|
|
107
|
+
await press(setup, "escape");
|
|
108
|
+
// a bare ESC settles after the parser's escape-sequence window
|
|
109
|
+
const frame = await waitForTextGone(setup, "[Approve]");
|
|
110
|
+
expect(frame).not.toContain("0 annotations");
|
|
111
|
+
expect(frame).toContain("Submit review (0)");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("enter in the card resolves the session through the controller", async () => {
|
|
115
|
+
seedAnnotations(1);
|
|
116
|
+
const setup = await renderApp();
|
|
117
|
+
await press(setup, "enter");
|
|
118
|
+
const frame = setup.captureCharFrame();
|
|
119
|
+
expect(frame).toContain("1 annotations · 0 blocking");
|
|
120
|
+
expect(frame).toContain("[Changes]"); // pending items: request changes default
|
|
121
|
+
await setup.mockInput.typeText("Tighten the steps.");
|
|
122
|
+
await press(setup, "enter");
|
|
123
|
+
// the completion flow after submit is unchanged
|
|
124
|
+
await waitForText(setup, "feedback sent");
|
|
125
|
+
const stored = server.core.sessionGet(session.id);
|
|
126
|
+
expect(stored.status).toBe("resolved");
|
|
127
|
+
expect(stored.verdict!.kind).toBe("request_changes");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("the annotation stack stays scrollable while the card is open", async () => {
|
|
131
|
+
seedAnnotations(12);
|
|
132
|
+
const setup = await renderApp();
|
|
133
|
+
// walk annotation focus to the last card: the rail scrolls it into view
|
|
134
|
+
for (let index = 0; index < 12; index++) await press(setup, "n");
|
|
135
|
+
const scrolled = setup.captureCharFrame();
|
|
136
|
+
expect(scrolled).toContain("note 12");
|
|
137
|
+
expect(scrolled).not.toContain("note 01");
|
|
138
|
+
await press(setup, "enter");
|
|
139
|
+
const opened = setup.captureCharFrame();
|
|
140
|
+
// the card is pinned outside the scrollbox; the stack above stays
|
|
141
|
+
// scrolled away from the top when the card takes its space
|
|
142
|
+
expect(opened).toContain("12 annotations · 0 blocking");
|
|
143
|
+
expect(opened).not.toContain("note 01");
|
|
144
|
+
// the stack still scrolls with the card open: wheel over the rail
|
|
145
|
+
// brings the last card back into view while the confirm card stays put
|
|
146
|
+
for (let turn = 0; turn < 12; turn++) await setup.mockMouse.scroll(100, 10, "down");
|
|
147
|
+
await settle(setup);
|
|
148
|
+
const scrolledWithCard = setup.captureCharFrame();
|
|
149
|
+
expect(scrolledWithCard).toContain("note 12");
|
|
150
|
+
expect(scrolledWithCard).toContain("12 annotations · 0 blocking");
|
|
151
|
+
expect(scrolledWithCard).not.toContain("note 01");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("read-only observers never see the confirm card", async () => {
|
|
155
|
+
seedAnnotations(1);
|
|
156
|
+
const setup = await renderApp({ readOnly: true });
|
|
157
|
+
await press(setup, "enter");
|
|
158
|
+
const frame = setup.captureCharFrame();
|
|
159
|
+
expect(frame).toContain("observer - read-only");
|
|
160
|
+
expect(frame).not.toContain("1 annotations");
|
|
161
|
+
expect(frame).not.toContain("[Changes]");
|
|
162
|
+
expect(setup.captureCharFrame()).not.toContain("verdict ←/→");
|
|
163
|
+
expect(server.core.sessionGet(session.id).status).toBe("pending");
|
|
164
|
+
});
|
|
165
|
+
});
|
package/src/App.test.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import { testRender } from "@opentui/react/test-utils";
|
|
|
13
13
|
import { DaemonServer } from "@cueloop/daemon";
|
|
14
14
|
import type { ReviewSession } from "@cueloop/schema";
|
|
15
15
|
import { App } from "./App";
|
|
16
|
+
import { press, typeText as type, waitForState, waitForText } from "./test-support";
|
|
16
17
|
|
|
17
18
|
const PLAN = `# Migration Plan
|
|
18
19
|
|
|
@@ -49,35 +50,11 @@ async function renderApp(sessionId?: string) {
|
|
|
49
50
|
width: 120,
|
|
50
51
|
height: 32,
|
|
51
52
|
});
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
await Bun.sleep(25);
|
|
55
|
-
await setup.renderOnce();
|
|
56
|
-
}
|
|
57
|
-
await setup.renderOnce();
|
|
53
|
+
// the async daemon connect + first fetch land within the frame wait
|
|
54
|
+
await waitForText(setup, "cueloop");
|
|
58
55
|
return setup;
|
|
59
56
|
}
|
|
60
57
|
|
|
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
58
|
describe("plan rendering", () => {
|
|
82
59
|
test("renders the plan with headings, list markers, and the rail", async () => {
|
|
83
60
|
const setup = await renderApp();
|
|
@@ -86,7 +63,7 @@ describe("plan rendering", () => {
|
|
|
86
63
|
expect(frame).toContain("Context");
|
|
87
64
|
expect(frame).toContain("persists sessions to disk atomically");
|
|
88
65
|
expect(frame).toContain("- move the store");
|
|
89
|
-
expect(frame).toContain("
|
|
66
|
+
expect(frame).toContain("Review (0)");
|
|
90
67
|
expect(frame).toContain("Submit review (0)");
|
|
91
68
|
});
|
|
92
69
|
});
|
|
@@ -98,7 +75,7 @@ describe("keyboard grammar", () => {
|
|
|
98
75
|
await press(setup, "j");
|
|
99
76
|
await setup.renderOnce();
|
|
100
77
|
const lines = setup.captureCharFrame().split("\n");
|
|
101
|
-
const cursorLine = lines.find((
|
|
78
|
+
const cursorLine = lines.find((line) => line.includes("▎"))!;
|
|
102
79
|
expect(cursorLine).toContain("persists sessions");
|
|
103
80
|
});
|
|
104
81
|
|
|
@@ -108,15 +85,13 @@ describe("keyboard grammar", () => {
|
|
|
108
85
|
await press(setup, "j");
|
|
109
86
|
await press(setup, "c");
|
|
110
87
|
await setup.renderOnce();
|
|
111
|
-
expect(setup.captureCharFrame()).toContain("
|
|
88
|
+
expect(setup.captureCharFrame()).toContain('comment on "The daemon');
|
|
112
89
|
await type(setup, "Define atomically.");
|
|
113
90
|
await press(setup, "enter");
|
|
114
|
-
await
|
|
115
|
-
await setup.renderOnce();
|
|
91
|
+
await waitForText(setup, "COMMENT");
|
|
116
92
|
const stored = server.core.sessionGet(session.id);
|
|
117
93
|
expect(stored.annotations.length).toBe(1);
|
|
118
94
|
expect(stored.annotations[0]!.body).toBe("Define atomically.");
|
|
119
|
-
expect(setup.captureCharFrame()).toContain("COMMENT");
|
|
120
95
|
});
|
|
121
96
|
|
|
122
97
|
test("span mode: v + l selects words, c anchors the exact span", async () => {
|
|
@@ -128,7 +103,7 @@ describe("keyboard grammar", () => {
|
|
|
128
103
|
await press(setup, "c");
|
|
129
104
|
await type(setup, "Which daemon?");
|
|
130
105
|
await press(setup, "enter");
|
|
131
|
-
await
|
|
106
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
132
107
|
const stored = server.core.sessionGet(session.id);
|
|
133
108
|
expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
|
|
134
109
|
});
|
|
@@ -138,14 +113,10 @@ describe("keyboard grammar", () => {
|
|
|
138
113
|
// move to "- move the store" (h1, h2, p, h2 = 4 steps in)
|
|
139
114
|
for (let i = 0; i < 4; i++) await press(setup, "j");
|
|
140
115
|
await press(setup, "x");
|
|
141
|
-
await
|
|
142
|
-
await setup.renderOnce();
|
|
116
|
+
await waitForText(setup, "[cut]");
|
|
143
117
|
expect(server.core.sessionGet(session.id).workingCopy).not.toContain("move the store");
|
|
144
|
-
expect(setup.captureCharFrame()).toContain("[cut]");
|
|
145
118
|
await press(setup, "x");
|
|
146
|
-
await
|
|
147
|
-
await setup.renderOnce();
|
|
148
|
-
expect(server.core.sessionGet(session.id).workingCopy).toBeUndefined();
|
|
119
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
|
|
149
120
|
});
|
|
150
121
|
|
|
151
122
|
test("e runs $EDITOR on the working copy and tracks the diff", async () => {
|
|
@@ -156,10 +127,8 @@ describe("keyboard grammar", () => {
|
|
|
156
127
|
try {
|
|
157
128
|
const setup = await renderApp();
|
|
158
129
|
await press(setup, "e");
|
|
159
|
-
await
|
|
160
|
-
await setup.renderOnce();
|
|
130
|
+
await waitForText(setup, "[edited]");
|
|
161
131
|
expect(server.core.sessionGet(session.id).workingCopy).toContain("very atomically");
|
|
162
|
-
expect(setup.captureCharFrame()).toContain("[edited]");
|
|
163
132
|
} finally {
|
|
164
133
|
delete process.env.CUELOOP_EDITOR;
|
|
165
134
|
}
|
|
@@ -167,35 +136,33 @@ describe("keyboard grammar", () => {
|
|
|
167
136
|
});
|
|
168
137
|
|
|
169
138
|
describe("submit", () => {
|
|
170
|
-
test("⏎ opens the
|
|
139
|
+
test("⏎ opens the rail confirm card; verdict + summary resolve the session", async () => {
|
|
171
140
|
const setup = await renderApp();
|
|
172
141
|
await press(setup, "j");
|
|
173
142
|
await press(setup, "j");
|
|
174
143
|
await press(setup, "c");
|
|
175
144
|
await type(setup, "Needs a phase list.");
|
|
176
145
|
await press(setup, "enter");
|
|
177
|
-
await
|
|
146
|
+
await waitForText(setup, "Review (1)");
|
|
178
147
|
await press(setup, "enter"); // open submit
|
|
179
|
-
|
|
180
|
-
expect(setup.captureCharFrame()).toContain("[Request changes]");
|
|
148
|
+
expect(setup.captureCharFrame()).toContain("[Changes]");
|
|
181
149
|
await type(setup, "Expand the steps.");
|
|
182
150
|
await press(setup, "enter");
|
|
183
|
-
await
|
|
184
|
-
await setup.renderOnce();
|
|
151
|
+
await waitForText(setup, "feedback sent");
|
|
185
152
|
const stored = server.core.sessionGet(session.id);
|
|
186
153
|
expect(stored.status).toBe("resolved");
|
|
187
154
|
expect(stored.verdict!.kind).toBe("request_changes");
|
|
188
155
|
expect(stored.verdict!.feedback).toContain("Needs a phase list.");
|
|
189
|
-
|
|
156
|
+
// submit hands the reviewer back to the agent via the completion overlay
|
|
157
|
+
expect(setup.captureCharFrame()).toContain("feedback sent");
|
|
190
158
|
});
|
|
191
159
|
|
|
192
160
|
test("approve via ←/→ verdict cycling", async () => {
|
|
193
161
|
const setup = await renderApp();
|
|
194
162
|
await press(setup, "enter");
|
|
195
|
-
await setup.renderOnce();
|
|
196
163
|
expect(setup.captureCharFrame()).toContain("[Approve]"); // no pending items → approve default
|
|
197
164
|
await press(setup, "enter");
|
|
198
|
-
await
|
|
165
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).verdict !== undefined);
|
|
199
166
|
expect(server.core.sessionGet(session.id).verdict!.kind).toBe("approve");
|
|
200
167
|
});
|
|
201
168
|
});
|
|
@@ -203,15 +170,10 @@ describe("submit", () => {
|
|
|
203
170
|
describe("inbox", () => {
|
|
204
171
|
test("inbox mode renders and opens a session", async () => {
|
|
205
172
|
const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
|
|
206
|
-
|
|
207
|
-
await Bun.sleep(25);
|
|
208
|
-
await setup.renderOnce();
|
|
209
|
-
}
|
|
173
|
+
await waitForText(setup, "inbox");
|
|
210
174
|
expect(setup.captureCharFrame()).toContain("inbox (1 pending)");
|
|
211
175
|
expect(setup.captureCharFrame()).toContain("Migration Plan");
|
|
212
176
|
await press(setup, "enter");
|
|
213
|
-
await
|
|
214
|
-
await setup.renderOnce();
|
|
215
|
-
expect(setup.captureCharFrame()).toContain("Submit review (0)");
|
|
177
|
+
await waitForText(setup, "Submit review (0)");
|
|
216
178
|
});
|
|
217
179
|
});
|
package/src/App.tsx
CHANGED
|
Binary file
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guided walk in the virtual terminal: the wizard steps every
|
|
3
|
+
* changed file with a plain progress title, ] marks viewed and persists with
|
|
4
|
+
* the session, esc keeps progress, w resumes at the first unviewed file
|
|
5
|
+
* (across App instances - the daemon round-trip), the agent-note block
|
|
6
|
+
* renders only for files carrying a note, and the end card hands over to the
|
|
7
|
+
* submit confirm with the honest viewed count.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import React from "react";
|
|
15
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
16
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
17
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
18
|
+
import { App } from "./App";
|
|
19
|
+
import { DARK } from "./theme";
|
|
20
|
+
import { press, waitForState, waitForText, waitForTextGone } from "./test-support";
|
|
21
|
+
|
|
22
|
+
const PATCH = `diff --git a/src/a.ts b/src/a.ts
|
|
23
|
+
index 111..222 100644
|
|
24
|
+
--- a/src/a.ts
|
|
25
|
+
+++ b/src/a.ts
|
|
26
|
+
@@ -1,2 +1,2 @@
|
|
27
|
+
export const a = 1;
|
|
28
|
+
-export const b = 2;
|
|
29
|
+
+export const b = 3;
|
|
30
|
+
diff --git a/src/b.ts b/src/b.ts
|
|
31
|
+
index 333..444 100644
|
|
32
|
+
--- a/src/b.ts
|
|
33
|
+
+++ b/src/b.ts
|
|
34
|
+
@@ -1,2 +1,2 @@
|
|
35
|
+
// header
|
|
36
|
+
-old line
|
|
37
|
+
+new line
|
|
38
|
+
diff --git a/src/c.ts b/src/c.ts
|
|
39
|
+
index 555..666 100644
|
|
40
|
+
--- a/src/c.ts
|
|
41
|
+
+++ b/src/c.ts
|
|
42
|
+
@@ -1,1 +1,2 @@
|
|
43
|
+
keep
|
|
44
|
+
+added tail
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
let home: string;
|
|
48
|
+
let server: DaemonServer;
|
|
49
|
+
let session: ReviewSession;
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-walk-"));
|
|
53
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
54
|
+
server.start();
|
|
55
|
+
session = server.core.sessionCreate({
|
|
56
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
57
|
+
artifact: { type: "diff", content: PATCH, meta: { title: "working tree" } },
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
server.stop();
|
|
62
|
+
rmSync(home, { recursive: true, force: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
async function renderApp() {
|
|
66
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 34 });
|
|
67
|
+
// Wait for session content, not just the chrome: pressing w before the diff
|
|
68
|
+
// loads drops the walk intent (no session, no diff view) and hangs the test.
|
|
69
|
+
await waitForText(setup, "src/a.ts");
|
|
70
|
+
return setup;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
74
|
+
|
|
75
|
+
/** Foreground colors (hex) of every styled span containing the needle. */
|
|
76
|
+
function foregroundsOf(setup: Setup, needle: string): string[] {
|
|
77
|
+
const foregrounds: 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.fg.toInts();
|
|
82
|
+
foregrounds.push("#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return foregrounds;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
describe("the guided walk", () => {
|
|
89
|
+
test("w walks all three files: progress title, persisted marks, end card, submit hand-over", async () => {
|
|
90
|
+
const setup = await renderApp();
|
|
91
|
+
await press(setup, "w");
|
|
92
|
+
await waitForText(setup, "file 1 of 3 · 0 viewed");
|
|
93
|
+
expect(setup.captureCharFrame()).toContain("src/a.ts");
|
|
94
|
+
expect(setup.captureCharFrame()).toContain("+1 -1");
|
|
95
|
+
|
|
96
|
+
await press(setup, "]");
|
|
97
|
+
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
98
|
+
// the viewed mark rides the session record, not the client
|
|
99
|
+
await waitForState(setup, () => (server.core.sessionGet(session.id).viewedPaths ?? []).includes("src/a.ts"));
|
|
100
|
+
|
|
101
|
+
// [ steps back; the revisited card shows its viewed marker
|
|
102
|
+
await press(setup, "[");
|
|
103
|
+
await waitForText(setup, "file 1 of 3 · 1 viewed");
|
|
104
|
+
expect(setup.captureCharFrame()).toContain("· viewed");
|
|
105
|
+
await press(setup, "]");
|
|
106
|
+
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
107
|
+
|
|
108
|
+
await press(setup, "]");
|
|
109
|
+
await waitForText(setup, "file 3 of 3 · 2 viewed");
|
|
110
|
+
await press(setup, "]");
|
|
111
|
+
await waitForText(setup, "walk complete");
|
|
112
|
+
expect(setup.captureCharFrame()).toContain("every file viewed (3/3)");
|
|
113
|
+
expect(server.core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
|
|
114
|
+
|
|
115
|
+
// the end card's return leaves the walk and opens the rail confirm
|
|
116
|
+
await press(setup, "enter");
|
|
117
|
+
await waitForText(setup, "submit review");
|
|
118
|
+
expect(setup.captureCharFrame()).toContain("3/3 files viewed");
|
|
119
|
+
await press(setup, "enter");
|
|
120
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("esc keeps progress and w resumes at the first unviewed file", async () => {
|
|
124
|
+
const setup = await renderApp();
|
|
125
|
+
await press(setup, "w");
|
|
126
|
+
await waitForText(setup, "file 1 of 3 · 0 viewed");
|
|
127
|
+
await press(setup, "]");
|
|
128
|
+
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
129
|
+
|
|
130
|
+
await press(setup, "escape");
|
|
131
|
+
await waitForTextGone(setup, "file 2 of 3");
|
|
132
|
+
// the sheet is back at full strength: no wizard card on screen
|
|
133
|
+
expect(setup.captureCharFrame()).not.toContain("· 1 viewed");
|
|
134
|
+
|
|
135
|
+
await press(setup, "w");
|
|
136
|
+
await waitForText(setup, "file 2 of 3 · 1 viewed");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("a half-walked review resumes across App instances via the daemon", async () => {
|
|
140
|
+
const first = await renderApp();
|
|
141
|
+
await press(first, "w");
|
|
142
|
+
await waitForText(first, "file 1 of 3 · 0 viewed");
|
|
143
|
+
await press(first, "]");
|
|
144
|
+
await press(first, "]");
|
|
145
|
+
await waitForText(first, "file 3 of 3 · 2 viewed");
|
|
146
|
+
await waitForState(first, () => (server.core.sessionGet(session.id).viewedPaths ?? []).length === 2);
|
|
147
|
+
first.renderer.destroy();
|
|
148
|
+
|
|
149
|
+
// a fresh client reads the viewed set back from the session record
|
|
150
|
+
const second = await renderApp();
|
|
151
|
+
await press(second, "w");
|
|
152
|
+
await waitForText(second, "file 3 of 3 · 2 viewed");
|
|
153
|
+
expect(second.captureCharFrame()).toContain("src/c.ts");
|
|
154
|
+
// two full App boots with daemon round-trips need more than the default budget
|
|
155
|
+
}, 15_000);
|
|
156
|
+
|
|
157
|
+
test("the agent-note block renders only for files carrying a note", async () => {
|
|
158
|
+
server.core.sessionAnnotate(session.id, {
|
|
159
|
+
id: "note-b",
|
|
160
|
+
kind: "note",
|
|
161
|
+
anchor: { quote: "src/b.ts", prefix: "", suffix: "" },
|
|
162
|
+
body: "Swaps the stale line for the new constant.",
|
|
163
|
+
});
|
|
164
|
+
const setup = await renderApp();
|
|
165
|
+
await press(setup, "w");
|
|
166
|
+
await waitForText(setup, "file 1 of 3");
|
|
167
|
+
expect(setup.captureCharFrame()).not.toContain("agent note");
|
|
168
|
+
|
|
169
|
+
await press(setup, "]");
|
|
170
|
+
await waitForText(setup, "agent note");
|
|
171
|
+
expect(setup.captureCharFrame()).toContain("Swaps the stale line for the new constant.");
|
|
172
|
+
|
|
173
|
+
// a note is agent context, not reviewer feedback: it neither counts nor
|
|
174
|
+
// flips the default verdict, and never comes back in the feedback doc
|
|
175
|
+
await press(setup, "escape");
|
|
176
|
+
await waitForTextGone(setup, "agent note");
|
|
177
|
+
await press(setup, "enter");
|
|
178
|
+
await waitForText(setup, "0 annotations");
|
|
179
|
+
await press(setup, "enter");
|
|
180
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
|
|
181
|
+
const resolved = server.core.sessionGet(session.id);
|
|
182
|
+
expect(resolved.verdict!.kind).toBe("approve");
|
|
183
|
+
expect(resolved.verdict!.feedback).not.toContain("Swaps the stale line");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("the sheet dims behind the wizard; the preview keeps the diff colors", async () => {
|
|
187
|
+
const setup = await renderApp();
|
|
188
|
+
// before the walk the sheet's added line wears the insertion color
|
|
189
|
+
await waitForText(setup, "+new line");
|
|
190
|
+
expect(foregroundsOf(setup, "+new line")).toContain(DARK.insFg);
|
|
191
|
+
|
|
192
|
+
await press(setup, "w");
|
|
193
|
+
await waitForText(setup, "file 1 of 3 · 0 viewed");
|
|
194
|
+
// dimmed: the sheet line drops to the dim token...
|
|
195
|
+
expect(foregroundsOf(setup, "+new line")).toEqual([DARK.textDim]);
|
|
196
|
+
// ...while the wizard preview keeps the insertion color
|
|
197
|
+
expect(foregroundsOf(setup, "+export const b = 3;")).toContain(DARK.insFg);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("walking a resolved review answers read-only", async () => {
|
|
201
|
+
server.core.sessionResolve(session.id, "approve", "");
|
|
202
|
+
const setup = await renderApp();
|
|
203
|
+
await waitForText(setup, "resolved");
|
|
204
|
+
await press(setup, "w");
|
|
205
|
+
await waitForText(setup, "review submitted - read-only");
|
|
206
|
+
expect(setup.captureCharFrame()).not.toContain("file 1 of 3");
|
|
207
|
+
});
|
|
208
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { DARK } from "../theme";
|
|
3
|
+
import type { Story, StoryMeta } from "./story";
|
|
4
|
+
import { AnnotationCard } from "./AnnotationCard";
|
|
5
|
+
|
|
6
|
+
export const meta: StoryMeta = { title: "AnnotationCard" };
|
|
7
|
+
|
|
8
|
+
const draftCallbacks = { onInput: () => {}, onSave: () => {}, onCancel: () => {} };
|
|
9
|
+
|
|
10
|
+
export const CommentDraft: Story = {
|
|
11
|
+
render: () => (
|
|
12
|
+
<AnnotationCard kind="comment" quote="persists sessions" draft={{ text: "Which daemon?", ...draftCallbacks }} />
|
|
13
|
+
),
|
|
14
|
+
expectedColors: [DARK.accent, DARK.elevated],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const SuggestionDraft: Story = {
|
|
18
|
+
render: () => (
|
|
19
|
+
<AnnotationCard kind="suggestion" quote="move the store" draft={{ text: "", ...draftCallbacks }} />
|
|
20
|
+
),
|
|
21
|
+
expectedColors: [DARK.green],
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const SavedSelected: Story = {
|
|
25
|
+
render: () => (
|
|
26
|
+
<AnnotationCard
|
|
27
|
+
kind="comment"
|
|
28
|
+
quote="persists sessions"
|
|
29
|
+
saved={{
|
|
30
|
+
body: "Which daemon owns this?",
|
|
31
|
+
isSelected: true,
|
|
32
|
+
isOrphan: false,
|
|
33
|
+
isBlocking: false,
|
|
34
|
+
editing: null,
|
|
35
|
+
onPress: () => {},
|
|
36
|
+
}}
|
|
37
|
+
/>
|
|
38
|
+
),
|
|
39
|
+
expectedColors: [DARK.elevated],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const SavedOrphanBlocking: Story = {
|
|
43
|
+
render: () => (
|
|
44
|
+
<AnnotationCard
|
|
45
|
+
kind="comment"
|
|
46
|
+
quote="a removed passage"
|
|
47
|
+
saved={{
|
|
48
|
+
body: "The passage is gone.",
|
|
49
|
+
isSelected: false,
|
|
50
|
+
isOrphan: true,
|
|
51
|
+
isBlocking: true,
|
|
52
|
+
editing: null,
|
|
53
|
+
onPress: () => {},
|
|
54
|
+
}}
|
|
55
|
+
/>
|
|
56
|
+
),
|
|
57
|
+
expectedColors: [DARK.red],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const SavedEditing: Story = {
|
|
61
|
+
render: () => (
|
|
62
|
+
<AnnotationCard
|
|
63
|
+
kind="suggestion"
|
|
64
|
+
quote="move the store"
|
|
65
|
+
saved={{
|
|
66
|
+
body: "old body",
|
|
67
|
+
isSelected: true,
|
|
68
|
+
isOrphan: false,
|
|
69
|
+
isBlocking: false,
|
|
70
|
+
editing: { text: "new body", ...draftCallbacks },
|
|
71
|
+
onPress: () => {},
|
|
72
|
+
}}
|
|
73
|
+
/>
|
|
74
|
+
),
|
|
75
|
+
};
|