@cueloop/client 0.1.0-alpha.6 → 0.1.0-alpha.61
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-pty.sh +51 -0
- package/native/build.sh +82 -0
- package/native/darwin-arm64/libcuelooppty.dylib +0 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/pty.zig +193 -0
- package/native/src/shim.zig +114 -0
- package/package.json +37 -33
- package/src/App.appearance.test.tsx +51 -0
- package/src/App.autoclose.test.tsx +73 -47
- package/src/App.diff.test.tsx +169 -22
- package/src/App.export.test.tsx +49 -37
- package/src/App.plan-review.test.tsx +714 -0
- package/src/App.prototype.test.tsx +194 -0
- package/src/App.readonly.test.tsx +69 -35
- package/src/App.rendering.test.tsx +59 -31
- package/src/App.review-panel.test.tsx +143 -0
- package/src/App.submit-confirm.test.tsx +229 -0
- package/src/App.test.tsx +139 -68
- package/src/App.toast.test.tsx +77 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +299 -0
- package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
- package/src/app-key-state.ts +61 -0
- package/src/app-screens.tsx +270 -0
- package/src/app-view-model.ts +307 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +42 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +33 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +250 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +45 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +84 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +60 -0
- package/src/components/ConfirmCard.stories.tsx +35 -0
- package/src/components/ConfirmCard.tsx +129 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +66 -0
- package/src/components/DiffSheet.scroll.test.tsx +135 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +65 -0
- package/src/components/DiffSheet.tsx +374 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +64 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +59 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +109 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +75 -0
- package/src/components/PlanSheet.stories.tsx +113 -0
- package/src/components/PlanSheet.tsx +422 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +80 -0
- package/src/components/PrototypeSheet.stories.tsx +20 -0
- package/src/components/PrototypeSheet.tsx +450 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +103 -0
- package/src/components/ReviewRail.stories.tsx +218 -0
- package/src/components/ReviewRail.tsx +318 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +147 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +112 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +45 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +50 -0
- package/src/components/agent-launcher.tsx +149 -0
- package/src/components/diff-sheet-layout.test.ts +128 -0
- package/src/components/diff-sheet-layout.ts +184 -0
- package/src/components/plan-sheet-run-style.ts +90 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +52 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +60 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +66 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +23 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +102 -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 +138 -0
- package/src/components/stories-app.tsx +124 -0
- package/src/components/stories.test.tsx +66 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +75 -0
- package/src/components/syntax-highlight.ts +102 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +193 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +37 -0
- package/src/components/truncate-text.ts +7 -0
- package/src/config.test.ts +448 -31
- package/src/config.ts +339 -57
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +253 -0
- package/src/diff-intraline.test.ts +165 -0
- package/src/diff-intraline.ts +146 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +176 -0
- package/src/editor.test.ts +149 -0
- package/src/editor.ts +135 -9
- package/src/ghostty-terminal.test.ts +84 -0
- package/src/ghostty-terminal.ts +176 -0
- package/src/herdr.test.ts +9 -34
- package/src/herdr.ts +17 -21
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +32 -0
- package/src/intent-dispatch.test.ts +499 -0
- package/src/intent-dispatch.ts +585 -0
- package/src/key-bindings.test.ts +156 -0
- package/src/key-bindings.ts +452 -0
- package/src/keymap.test.ts +635 -0
- package/src/keymap.ts +404 -0
- package/src/kitty-image.test.ts +66 -0
- package/src/kitty-image.ts +138 -0
- package/src/prototype-browser.test.ts +56 -0
- package/src/prototype-browser.ts +289 -0
- package/src/pty.test.ts +81 -0
- package/src/pty.ts +237 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +16 -5
- package/src/serve.test.ts +25 -9
- package/src/serve.ts +19 -8
- package/src/session-controller.curate.test.ts +260 -0
- package/src/session-controller.share.test.ts +320 -0
- package/src/session-controller.ts +993 -0
- package/src/share.ts +120 -0
- package/src/test-support.test.ts +60 -0
- package/src/test-support.ts +197 -0
- package/src/theme-presets.test.ts +51 -0
- package/src/theme-presets.ts +187 -0
- package/src/theme.ts +64 -17
- package/src/use-settings-dialog.tsx +275 -0
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +22 -8
- package/src/view-diff.ts +39 -23
- package/src/view-plan.regression.test.ts +60 -0
- package/src/view-plan.test.ts +406 -0
- package/src/view-plan.ts +554 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +74 -0
- package/src/syntax.test.ts +0 -41
- package/src/syntax.ts +0 -88
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/** The rail submit confirm (tier 2): pressing submit expands the rail's Submit button into a bordered confirm card - counts, verdict selector, summary input, word-buttons. Char-frame assertions over the real App and an in-process daemon, like App.test.tsx. */
|
|
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 { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
|
|
11
|
+
import { App } from "./App";
|
|
12
|
+
import { isolateUserConfig, press, settle, waitForText, waitForTextGone } from "./test-support";
|
|
13
|
+
|
|
14
|
+
const PLAN = `# Migration Plan
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
The daemon persists sessions to disk atomically.
|
|
19
|
+
|
|
20
|
+
## Steps
|
|
21
|
+
|
|
22
|
+
- move the store
|
|
23
|
+
- add recovery
|
|
24
|
+
`;
|
|
25
|
+
|
|
26
|
+
let home: string;
|
|
27
|
+
let restoreUserConfig: () => void;
|
|
28
|
+
let server: DaemonServer;
|
|
29
|
+
let session: ReviewSession;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-confirm-"));
|
|
33
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
34
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
35
|
+
server.start();
|
|
36
|
+
session = server.core.sessionCreate({
|
|
37
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
38
|
+
artifact: {
|
|
39
|
+
type: "plan",
|
|
40
|
+
content: PLAN,
|
|
41
|
+
meta: { title: "Migration Plan", planPath: "plan.md" },
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
restoreUserConfig();
|
|
47
|
+
server.stop();
|
|
48
|
+
rmSync(home, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
async function renderApp(options: { readOnly?: boolean } = {}) {
|
|
52
|
+
const setup = await testRender(
|
|
53
|
+
<App home={home} sessionId={session.id} readOnly={options.readOnly ?? false} />,
|
|
54
|
+
{
|
|
55
|
+
width: 120,
|
|
56
|
+
height: 32,
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
await waitForText(setup, "cueloop");
|
|
61
|
+
|
|
62
|
+
return setup;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Seed annotations directly through the daemon core (all on one block). */
|
|
66
|
+
function seedAnnotations(count: number): void {
|
|
67
|
+
const blocks = parseBlocks(PLAN);
|
|
68
|
+
|
|
69
|
+
for (let index = 1; index <= count; index++) {
|
|
70
|
+
server.core.sessionAnnotate(session.id, {
|
|
71
|
+
id: `a_confirm_${index}`,
|
|
72
|
+
kind: "comment",
|
|
73
|
+
anchor: makeAnchor(blocks, 2, 0, 10),
|
|
74
|
+
body: `note ${String(index).padStart(2, "0")}`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("rail submit confirm", () => {
|
|
80
|
+
test("submit expands the Submit button into the confirm card", async () => {
|
|
81
|
+
// Arrange
|
|
82
|
+
const setup = await renderApp();
|
|
83
|
+
|
|
84
|
+
// Assert
|
|
85
|
+
expect(setup.captureCharFrame()).toContain("Submit review");
|
|
86
|
+
|
|
87
|
+
// Act
|
|
88
|
+
await press(setup, "enter");
|
|
89
|
+
|
|
90
|
+
// Assert
|
|
91
|
+
await waitForText(setup, "submit review");
|
|
92
|
+
const frame = setup.captureCharFrame();
|
|
93
|
+
|
|
94
|
+
// the card: title, verdict selector, word-buttons
|
|
95
|
+
expect(frame).toContain("submit review");
|
|
96
|
+
expect(frame).toContain("[Approve]"); // nothing pending: approve default
|
|
97
|
+
expect(frame).toContain(" Submit ");
|
|
98
|
+
expect(frame).toContain(" Cancel ");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("left/right cycles the verdict selector in the card", async () => {
|
|
102
|
+
// Arrange
|
|
103
|
+
const setup = await renderApp();
|
|
104
|
+
|
|
105
|
+
await press(setup, "enter");
|
|
106
|
+
|
|
107
|
+
// Assert
|
|
108
|
+
await waitForText(setup, "[Approve]");
|
|
109
|
+
|
|
110
|
+
// Act
|
|
111
|
+
await press(setup, "right");
|
|
112
|
+
|
|
113
|
+
// Assert
|
|
114
|
+
await waitForText(setup, "[Changes]");
|
|
115
|
+
|
|
116
|
+
// Act
|
|
117
|
+
await press(setup, "right");
|
|
118
|
+
|
|
119
|
+
// Assert
|
|
120
|
+
await waitForText(setup, "[Comment]");
|
|
121
|
+
|
|
122
|
+
// Act
|
|
123
|
+
await press(setup, "left");
|
|
124
|
+
|
|
125
|
+
// Assert
|
|
126
|
+
await waitForText(setup, "[Changes]");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("esc cancels the card and restores the plain Submit button", async () => {
|
|
130
|
+
// Arrange
|
|
131
|
+
const setup = await renderApp();
|
|
132
|
+
|
|
133
|
+
await press(setup, "enter");
|
|
134
|
+
|
|
135
|
+
// Assert
|
|
136
|
+
await waitForText(setup, "[Approve]");
|
|
137
|
+
|
|
138
|
+
// Act
|
|
139
|
+
await press(setup, "escape");
|
|
140
|
+
|
|
141
|
+
// Assert
|
|
142
|
+
// a bare ESC settles after the parser's escape-sequence window
|
|
143
|
+
const frame = await waitForTextGone(setup, "[Approve]");
|
|
144
|
+
|
|
145
|
+
expect(frame).not.toContain("submit review");
|
|
146
|
+
expect(frame).toContain("Submit review");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("enter in the card resolves the session through the controller", async () => {
|
|
150
|
+
// Arrange
|
|
151
|
+
seedAnnotations(1);
|
|
152
|
+
const setup = await renderApp();
|
|
153
|
+
|
|
154
|
+
// Act
|
|
155
|
+
await press(setup, "enter");
|
|
156
|
+
|
|
157
|
+
// Assert
|
|
158
|
+
await waitForText(setup, "[Changes]"); // pending items: request changes default
|
|
159
|
+
const frame = setup.captureCharFrame();
|
|
160
|
+
|
|
161
|
+
expect(frame).toContain("submit review");
|
|
162
|
+
|
|
163
|
+
// Act
|
|
164
|
+
await setup.mockInput.typeText("Tighten the steps.");
|
|
165
|
+
await press(setup, "enter");
|
|
166
|
+
|
|
167
|
+
// Assert
|
|
168
|
+
// the completion flow after submit is unchanged
|
|
169
|
+
await waitForText(setup, "feedback sent");
|
|
170
|
+
const stored = server.core.sessionGet(session.id);
|
|
171
|
+
|
|
172
|
+
expect(stored.status).toBe("resolved");
|
|
173
|
+
expect(stored.verdict!.kind).toBe("request_changes");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("the annotation stack stays scrollable while the card is open", async () => {
|
|
177
|
+
// Arrange
|
|
178
|
+
seedAnnotations(12);
|
|
179
|
+
const setup = await renderApp();
|
|
180
|
+
|
|
181
|
+
// Act
|
|
182
|
+
// walk annotation focus to the last card: the rail scrolls it into view
|
|
183
|
+
for (let index = 0; index < 12; index++) await press(setup, "n");
|
|
184
|
+
|
|
185
|
+
// Assert
|
|
186
|
+
await waitForText(setup, "note 12");
|
|
187
|
+
expect(setup.captureCharFrame()).not.toContain("note 01");
|
|
188
|
+
|
|
189
|
+
// Act
|
|
190
|
+
await press(setup, "enter");
|
|
191
|
+
|
|
192
|
+
// Assert
|
|
193
|
+
await waitForText(setup, "[Changes]");
|
|
194
|
+
// the card is pinned outside the scrollbox; the stack stays scrolled off the top
|
|
195
|
+
expect(setup.captureCharFrame()).not.toContain("note 01");
|
|
196
|
+
|
|
197
|
+
// Act
|
|
198
|
+
// the stack still scrolls with the card open: wheel over the rail
|
|
199
|
+
// brings the last card back into view while the confirm card stays put
|
|
200
|
+
for (let turn = 0; turn < 12; turn++) await setup.mockMouse.scroll(100, 10, "down");
|
|
201
|
+
await settle(setup);
|
|
202
|
+
|
|
203
|
+
// Assert
|
|
204
|
+
await waitForText(setup, "note 12");
|
|
205
|
+
const scrolledWithCard = setup.captureCharFrame();
|
|
206
|
+
|
|
207
|
+
expect(scrolledWithCard).toContain("submit review");
|
|
208
|
+
expect(scrolledWithCard).not.toContain("note 01");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("read-only observers never see the confirm card", async () => {
|
|
212
|
+
// Arrange
|
|
213
|
+
seedAnnotations(1);
|
|
214
|
+
const setup = await renderApp({ readOnly: true });
|
|
215
|
+
|
|
216
|
+
// Act
|
|
217
|
+
await press(setup, "enter");
|
|
218
|
+
|
|
219
|
+
// Assert
|
|
220
|
+
await waitForText(setup, "observer - read-only");
|
|
221
|
+
const frame = setup.captureCharFrame();
|
|
222
|
+
|
|
223
|
+
expect(frame).toContain("observer - read-only");
|
|
224
|
+
expect(frame).not.toContain("1 annotations");
|
|
225
|
+
expect(frame).not.toContain("[Changes]");
|
|
226
|
+
expect(setup.captureCharFrame()).not.toContain("verdict ←/→");
|
|
227
|
+
expect(server.core.sessionGet(session.id).status).toBe("pending");
|
|
228
|
+
});
|
|
229
|
+
});
|
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,13 @@ 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 {
|
|
13
|
+
isolateUserConfig,
|
|
14
|
+
press,
|
|
15
|
+
typeText as type,
|
|
16
|
+
waitForState,
|
|
17
|
+
waitForText,
|
|
18
|
+
} from "./test-support";
|
|
16
19
|
|
|
17
20
|
const PLAN = `# Migration Plan
|
|
18
21
|
|
|
@@ -27,19 +30,26 @@ The daemon persists sessions to disk atomically.
|
|
|
27
30
|
`;
|
|
28
31
|
|
|
29
32
|
let home: string;
|
|
33
|
+
let restoreUserConfig: () => void;
|
|
30
34
|
let server: DaemonServer;
|
|
31
35
|
let session: ReviewSession;
|
|
32
36
|
|
|
33
37
|
beforeEach(() => {
|
|
34
38
|
home = mkdtempSync(join(tmpdir(), "cueloop-app-"));
|
|
39
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
35
40
|
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
36
41
|
server.start();
|
|
37
42
|
session = server.core.sessionCreate({
|
|
38
43
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
39
|
-
artifact: {
|
|
44
|
+
artifact: {
|
|
45
|
+
type: "plan",
|
|
46
|
+
content: PLAN,
|
|
47
|
+
meta: { title: "Migration Plan", planPath: "plan.md" },
|
|
48
|
+
},
|
|
40
49
|
});
|
|
41
50
|
});
|
|
42
51
|
afterEach(() => {
|
|
52
|
+
restoreUserConfig();
|
|
43
53
|
server.stop();
|
|
44
54
|
rmSync(home, { recursive: true, force: true });
|
|
45
55
|
});
|
|
@@ -49,78 +59,77 @@ async function renderApp(sessionId?: string) {
|
|
|
49
59
|
width: 120,
|
|
50
60
|
height: 32,
|
|
51
61
|
});
|
|
52
|
-
// let the async daemon connect + first fetch land
|
|
53
|
-
for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
|
|
54
|
-
await Bun.sleep(25);
|
|
55
|
-
await setup.renderOnce();
|
|
56
|
-
}
|
|
57
|
-
await setup.renderOnce();
|
|
58
|
-
return setup;
|
|
59
|
-
}
|
|
60
62
|
|
|
61
|
-
|
|
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
|
-
}
|
|
63
|
+
// the async daemon connect + first fetch land within the frame wait
|
|
64
|
+
await waitForText(setup, "cueloop");
|
|
74
65
|
|
|
75
|
-
|
|
76
|
-
await setup.mockInput.typeText(text);
|
|
77
|
-
await Bun.sleep(15);
|
|
78
|
-
await setup.renderOnce();
|
|
66
|
+
return setup;
|
|
79
67
|
}
|
|
80
68
|
|
|
81
69
|
describe("plan rendering", () => {
|
|
82
70
|
test("renders the plan with headings, list markers, and the rail", async () => {
|
|
71
|
+
// Arrange
|
|
83
72
|
const setup = await renderApp();
|
|
73
|
+
|
|
74
|
+
// Assert
|
|
84
75
|
const frame = setup.captureCharFrame();
|
|
76
|
+
|
|
85
77
|
expect(frame).toContain("Migration Plan");
|
|
86
78
|
expect(frame).toContain("Context");
|
|
87
79
|
expect(frame).toContain("persists sessions to disk atomically");
|
|
88
80
|
expect(frame).toContain("- move the store");
|
|
89
|
-
expect(frame).toContain("
|
|
90
|
-
expect(frame).toContain("Submit review
|
|
81
|
+
expect(frame).toContain("Review");
|
|
82
|
+
expect(frame).toContain("Submit review");
|
|
91
83
|
});
|
|
92
84
|
});
|
|
93
85
|
|
|
94
86
|
describe("keyboard grammar", () => {
|
|
95
87
|
test("j/k moves the cursor glyph between blocks", async () => {
|
|
88
|
+
// Arrange
|
|
96
89
|
const setup = await renderApp();
|
|
90
|
+
|
|
91
|
+
// Act
|
|
97
92
|
await press(setup, "j");
|
|
98
93
|
await press(setup, "j");
|
|
99
94
|
await setup.renderOnce();
|
|
95
|
+
|
|
96
|
+
// Assert
|
|
100
97
|
const lines = setup.captureCharFrame().split("\n");
|
|
101
|
-
const cursorLine = lines.find((
|
|
98
|
+
const cursorLine = lines.find((line) => line.includes("▎"))!;
|
|
99
|
+
|
|
102
100
|
expect(cursorLine).toContain("persists sessions");
|
|
103
101
|
});
|
|
104
102
|
|
|
105
103
|
test("comment flow: c types a body, ⏎ saves to the daemon", async () => {
|
|
104
|
+
// Arrange
|
|
106
105
|
const setup = await renderApp();
|
|
106
|
+
|
|
107
|
+
// Act
|
|
107
108
|
await press(setup, "j");
|
|
108
109
|
await press(setup, "j");
|
|
109
110
|
await press(setup, "c");
|
|
110
111
|
await setup.renderOnce();
|
|
111
|
-
|
|
112
|
+
|
|
113
|
+
// Assert
|
|
114
|
+
await waitForText(setup, 'comment on "The daemon');
|
|
115
|
+
|
|
116
|
+
// Act
|
|
112
117
|
await type(setup, "Define atomically.");
|
|
113
118
|
await press(setup, "enter");
|
|
114
|
-
|
|
115
|
-
|
|
119
|
+
|
|
120
|
+
// Assert
|
|
121
|
+
await waitForText(setup, "COMMENT");
|
|
116
122
|
const stored = server.core.sessionGet(session.id);
|
|
123
|
+
|
|
117
124
|
expect(stored.annotations.length).toBe(1);
|
|
118
125
|
expect(stored.annotations[0]!.body).toBe("Define atomically.");
|
|
119
|
-
expect(setup.captureCharFrame()).toContain("COMMENT");
|
|
120
126
|
});
|
|
121
127
|
|
|
122
128
|
test("span mode: v + l selects words, c anchors the exact span", async () => {
|
|
129
|
+
// Arrange
|
|
123
130
|
const setup = await renderApp();
|
|
131
|
+
|
|
132
|
+
// Act
|
|
124
133
|
await press(setup, "j");
|
|
125
134
|
await press(setup, "j");
|
|
126
135
|
await press(setup, "v");
|
|
@@ -128,38 +137,51 @@ describe("keyboard grammar", () => {
|
|
|
128
137
|
await press(setup, "c");
|
|
129
138
|
await type(setup, "Which daemon?");
|
|
130
139
|
await press(setup, "enter");
|
|
131
|
-
|
|
140
|
+
|
|
141
|
+
// Assert
|
|
142
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
132
143
|
const stored = server.core.sessionGet(session.id);
|
|
144
|
+
|
|
133
145
|
expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
|
|
134
146
|
});
|
|
135
147
|
|
|
136
148
|
test("x cuts a block into the working copy; x restores it", async () => {
|
|
149
|
+
// Arrange
|
|
137
150
|
const setup = await renderApp();
|
|
151
|
+
|
|
138
152
|
// move to "- move the store" (h1, h2, p, h2 = 4 steps in)
|
|
139
153
|
for (let i = 0; i < 4; i++) await press(setup, "j");
|
|
154
|
+
|
|
155
|
+
// Act
|
|
140
156
|
await press(setup, "x");
|
|
141
|
-
|
|
142
|
-
|
|
157
|
+
|
|
158
|
+
// Assert - the rail shows the cut as a removal card titled CUT · me
|
|
159
|
+
await waitForText(setup, "CUT · me");
|
|
143
160
|
expect(server.core.sessionGet(session.id).workingCopy).not.toContain("move the store");
|
|
144
|
-
|
|
161
|
+
|
|
162
|
+
// Act
|
|
145
163
|
await press(setup, "x");
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
164
|
+
|
|
165
|
+
// Assert
|
|
166
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
|
|
149
167
|
});
|
|
150
168
|
|
|
151
169
|
test("e runs $EDITOR on the working copy and tracks the diff", async () => {
|
|
152
170
|
const script = join(home, "fake-editor.sh");
|
|
171
|
+
|
|
153
172
|
await Bun.write(script, `#!/bin/sh\nsed -i '' 's/atomically/very atomically/' "$1"\n`);
|
|
154
173
|
Bun.spawnSync(["chmod", "+x", script]);
|
|
155
174
|
process.env.CUELOOP_EDITOR = script;
|
|
156
175
|
try {
|
|
176
|
+
// Arrange
|
|
157
177
|
const setup = await renderApp();
|
|
178
|
+
|
|
179
|
+
// Act
|
|
158
180
|
await press(setup, "e");
|
|
159
|
-
|
|
160
|
-
|
|
181
|
+
|
|
182
|
+
// Assert
|
|
183
|
+
await waitForText(setup, "[edited]");
|
|
161
184
|
expect(server.core.sessionGet(session.id).workingCopy).toContain("very atomically");
|
|
162
|
-
expect(setup.captureCharFrame()).toContain("[edited]");
|
|
163
185
|
} finally {
|
|
164
186
|
delete process.env.CUELOOP_EDITOR;
|
|
165
187
|
}
|
|
@@ -167,52 +189,101 @@ describe("keyboard grammar", () => {
|
|
|
167
189
|
});
|
|
168
190
|
|
|
169
191
|
describe("submit", () => {
|
|
170
|
-
test("⏎ opens the
|
|
192
|
+
test("⏎ opens the rail confirm card; verdict + summary resolve the session", async () => {
|
|
193
|
+
// Arrange
|
|
171
194
|
const setup = await renderApp();
|
|
195
|
+
|
|
172
196
|
await press(setup, "j");
|
|
173
197
|
await press(setup, "j");
|
|
174
198
|
await press(setup, "c");
|
|
175
199
|
await type(setup, "Needs a phase list.");
|
|
176
200
|
await press(setup, "enter");
|
|
177
|
-
await
|
|
201
|
+
await waitForText(setup, "Needs a phase list");
|
|
202
|
+
|
|
203
|
+
// Act
|
|
178
204
|
await press(setup, "enter"); // open submit
|
|
179
|
-
|
|
180
|
-
|
|
205
|
+
|
|
206
|
+
// Assert
|
|
207
|
+
await waitForText(setup, "[Changes]");
|
|
208
|
+
|
|
209
|
+
// Act
|
|
181
210
|
await type(setup, "Expand the steps.");
|
|
182
211
|
await press(setup, "enter");
|
|
183
|
-
|
|
184
|
-
|
|
212
|
+
|
|
213
|
+
// Assert
|
|
214
|
+
await waitForText(setup, "feedback sent");
|
|
185
215
|
const stored = server.core.sessionGet(session.id);
|
|
216
|
+
|
|
186
217
|
expect(stored.status).toBe("resolved");
|
|
187
218
|
expect(stored.verdict!.kind).toBe("request_changes");
|
|
188
219
|
expect(stored.verdict!.feedback).toContain("Needs a phase list.");
|
|
189
220
|
// submit hands the reviewer back to the agent via the completion overlay
|
|
190
|
-
expect(setup.captureCharFrame()).toContain("
|
|
221
|
+
expect(setup.captureCharFrame()).toContain("feedback sent");
|
|
191
222
|
});
|
|
192
223
|
|
|
193
224
|
test("approve via ←/→ verdict cycling", async () => {
|
|
225
|
+
// Arrange
|
|
194
226
|
const setup = await renderApp();
|
|
227
|
+
|
|
228
|
+
// Act
|
|
195
229
|
await press(setup, "enter");
|
|
196
|
-
|
|
197
|
-
|
|
230
|
+
|
|
231
|
+
// Assert
|
|
232
|
+
await waitForText(setup, "[Approve]"); // no pending items → approve default
|
|
233
|
+
|
|
234
|
+
// Act
|
|
198
235
|
await press(setup, "enter");
|
|
199
|
-
|
|
236
|
+
|
|
237
|
+
// Assert
|
|
238
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).verdict !== undefined);
|
|
200
239
|
expect(server.core.sessionGet(session.id).verdict!.kind).toBe("approve");
|
|
201
240
|
});
|
|
202
241
|
});
|
|
203
242
|
|
|
204
243
|
describe("inbox", () => {
|
|
205
|
-
test("inbox mode
|
|
244
|
+
test("inbox mode wears the review chrome (header + menu bar) and opens a session", async () => {
|
|
245
|
+
// Arrange
|
|
206
246
|
const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
247
|
+
|
|
248
|
+
await waitForText(setup, "cueloop");
|
|
249
|
+
|
|
250
|
+
// Assert - the same header/menu chrome as review, plus the session row
|
|
251
|
+
const frame = setup.captureCharFrame();
|
|
252
|
+
|
|
253
|
+
expect(frame).toContain("cueloop · resume");
|
|
254
|
+
expect(frame).toContain("Migration Plan");
|
|
255
|
+
expect(frame).toContain("menu"); // the shared MenuBar sits at the bottom
|
|
256
|
+
expect(frame).not.toContain("inbox ("); // the old inline header is gone
|
|
257
|
+
|
|
258
|
+
// Act
|
|
213
259
|
await press(setup, "enter");
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
260
|
+
|
|
261
|
+
// Assert
|
|
262
|
+
await waitForText(setup, "Submit review");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("the menu opens from the inbox and escape is not a trap", async () => {
|
|
266
|
+
// Arrange
|
|
267
|
+
const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
|
|
268
|
+
|
|
269
|
+
await waitForText(setup, "cueloop · resume");
|
|
270
|
+
const lines = setup.captureCharFrame().split("\n");
|
|
271
|
+
const menuRow = lines.findIndex((line) => line.includes("menu"));
|
|
272
|
+
const menuColumn = lines[menuRow]!.indexOf("menu");
|
|
273
|
+
|
|
274
|
+
// Act - open the drop-up from the shared menu bar
|
|
275
|
+
await setup.mockMouse.click(menuColumn + 1, menuRow);
|
|
276
|
+
|
|
277
|
+
// Assert - the drop-up options appear
|
|
278
|
+
await waitForText(setup, "Keybinds");
|
|
279
|
+
expect(setup.captureCharFrame()).toContain("Settings");
|
|
280
|
+
|
|
281
|
+
// Act - escape closes the menu (not a trap), and the inbox nav still works
|
|
282
|
+
await press(setup, "escape");
|
|
283
|
+
await waitForState(setup, () => !setup.captureCharFrame().includes("Keybinds"));
|
|
284
|
+
await press(setup, "enter");
|
|
285
|
+
|
|
286
|
+
// Assert
|
|
287
|
+
await waitForText(setup, "Submit review");
|
|
217
288
|
});
|
|
218
289
|
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** The share toast is non-modal: while it is up, escape still cancels an open overlay. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, mock, 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
|
+
|
|
12
|
+
// the controller reaches the gateway through "./share"; stub it so `S` yields a toast
|
|
13
|
+
const publishShare = mock(async () => ({ line: "ssh p_share01@cueloop.dev", copied: true }));
|
|
14
|
+
|
|
15
|
+
mock.module("./share", () => ({
|
|
16
|
+
publishShare,
|
|
17
|
+
pullShare: mock(async () => null),
|
|
18
|
+
pushShare: mock(async () => {}),
|
|
19
|
+
shareIdFromLine: (line: string) => line.match(/^ssh (\S+)@/)?.[1],
|
|
20
|
+
collaboratorAnnotations: () => [],
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
const { App } = await import("./App");
|
|
24
|
+
const { isolateUserConfig, press, waitForText, waitForTextGone } = await import("./test-support");
|
|
25
|
+
|
|
26
|
+
const PLAN = `# Migration Plan\n\n## Context\n\nThe daemon persists sessions to disk atomically.\n`;
|
|
27
|
+
|
|
28
|
+
let home: string;
|
|
29
|
+
let restoreUserConfig: () => void;
|
|
30
|
+
let server: DaemonServer;
|
|
31
|
+
let session: ReviewSession;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-toast-"));
|
|
35
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
36
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
37
|
+
server.start();
|
|
38
|
+
session = server.core.sessionCreate({
|
|
39
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
40
|
+
artifact: {
|
|
41
|
+
type: "plan",
|
|
42
|
+
content: PLAN,
|
|
43
|
+
meta: { title: "Migration Plan", planPath: "plan.md" },
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
restoreUserConfig();
|
|
49
|
+
server.stop();
|
|
50
|
+
rmSync(home, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("share toast", () => {
|
|
54
|
+
test("escape cancels an open composer even while the toast is up", async () => {
|
|
55
|
+
// Arrange
|
|
56
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
57
|
+
width: 120,
|
|
58
|
+
height: 32,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await waitForText(setup, "cueloop");
|
|
62
|
+
|
|
63
|
+
// Act: share raises the toast, then open a composer under it
|
|
64
|
+
await press(setup, "S");
|
|
65
|
+
await waitForText(setup, "share link copied");
|
|
66
|
+
await press(setup, "j");
|
|
67
|
+
await press(setup, "j");
|
|
68
|
+
await press(setup, "c");
|
|
69
|
+
await waitForText(setup, 'comment on "');
|
|
70
|
+
|
|
71
|
+
// Act: escape must reach the composer, not get eaten by the toast
|
|
72
|
+
await press(setup, "escape");
|
|
73
|
+
|
|
74
|
+
// Assert: the composer closed on the first escape
|
|
75
|
+
await waitForTextGone(setup, 'comment on "');
|
|
76
|
+
});
|
|
77
|
+
});
|
package/src/App.tsx
CHANGED
|
Binary file
|