@cueloop/client 0.1.0-alpha.4 → 0.1.0-alpha.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/native/build.sh +56 -0
- package/native/darwin-arm64/libcueloopvt.dylib +0 -0
- package/native/src/shim.c +107 -0
- package/package.json +34 -24
- package/src/App.autoclose.test.tsx +137 -0
- package/src/App.diff.test.tsx +158 -22
- package/src/App.export.test.tsx +44 -37
- package/src/App.plan-review.test.tsx +632 -0
- package/src/App.readonly.test.tsx +60 -36
- package/src/App.rendering.test.tsx +132 -0
- package/src/App.review-panel.test.tsx +136 -0
- package/src/App.submit-confirm.test.tsx +220 -0
- package/src/App.test.tsx +128 -69
- package/src/App.toast.test.tsx +75 -0
- package/src/App.tsx +0 -0
- package/src/App.walk.test.tsx +288 -0
- package/src/attribution.test.ts +31 -0
- package/src/attribution.ts +38 -0
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +30 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.stories.tsx +94 -0
- package/src/components/AnnotationCard.tsx +228 -0
- package/src/components/Breadcrumb.stories.tsx +19 -0
- package/src/components/Breadcrumb.tsx +44 -0
- package/src/components/CodeBlock.stories.tsx +42 -0
- package/src/components/CodeBlock.tsx +83 -0
- package/src/components/CompletionOverlay.stories.tsx +23 -0
- package/src/components/CompletionOverlay.tsx +59 -0
- package/src/components/ConfirmCard.stories.tsx +56 -0
- package/src/components/ConfirmCard.tsx +135 -0
- package/src/components/ConfirmDialog.stories.tsx +21 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +78 -0
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +355 -0
- package/src/components/InboxList.stories.tsx +31 -0
- package/src/components/InboxList.tsx +62 -0
- package/src/components/KeybindsDialog.stories.tsx +31 -0
- package/src/components/KeybindsDialog.tsx +58 -0
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +102 -0
- package/src/components/MenuBar.stories.tsx +37 -0
- package/src/components/MenuBar.tsx +74 -0
- package/src/components/PlanSheet.stories.tsx +113 -0
- package/src/components/PlanSheet.tsx +389 -0
- package/src/components/PromptDialog.stories.tsx +36 -0
- package/src/components/PromptDialog.tsx +78 -0
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +101 -0
- package/src/components/ReviewRail.stories.tsx +220 -0
- package/src/components/ReviewRail.tsx +324 -0
- package/src/components/SettingsDialog.stories.tsx +111 -0
- package/src/components/SettingsDialog.tsx +141 -0
- package/src/components/SettingsRows.stories.tsx +18 -0
- package/src/components/SettingsRows.tsx +108 -0
- package/src/components/Toast.stories.tsx +12 -0
- package/src/components/Toast.tsx +44 -0
- package/src/components/WalkWizard.stories.tsx +100 -0
- package/src/components/WalkWizard.tsx +152 -0
- package/src/components/__snapshots__/stories.test.tsx.snap +1829 -0
- package/src/components/agent-launcher.stories.tsx +26 -0
- package/src/components/agent-launcher.test.tsx +21 -0
- package/src/components/agent-launcher.tsx +180 -0
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.stories.tsx +32 -0
- package/src/components/primitives/Button.tsx +48 -0
- package/src/components/primitives/Card.stories.tsx +25 -0
- package/src/components/primitives/Card.tsx +59 -0
- package/src/components/primitives/Dialog.stories.tsx +24 -0
- package/src/components/primitives/Dialog.tsx +64 -0
- package/src/components/primitives/StatusBar.stories.tsx +11 -0
- package/src/components/primitives/StatusBar.tsx +22 -0
- package/src/components/primitives/Tabs.stories.tsx +29 -0
- package/src/components/primitives/Tabs.tsx +98 -0
- package/src/components/primitives/Toolbar.stories.tsx +17 -0
- package/src/components/primitives/Toolbar.tsx +14 -0
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/quick-actions-editor.stories.tsx +54 -0
- package/src/components/quick-actions-editor.tsx +136 -0
- package/src/components/stories-app.tsx +123 -0
- package/src/components/stories.test.tsx +62 -0
- package/src/components/story-fixtures.ts +98 -0
- package/src/components/story.ts +69 -0
- package/src/components/syntax-highlight.ts +92 -0
- package/src/components/terminal-pane.test.tsx +40 -0
- package/src/components/terminal-pane.ts +174 -0
- package/src/components/theme-context.stories.tsx +46 -0
- package/src/components/theme-context.tsx +36 -0
- package/src/components/truncate-text.ts +6 -0
- package/src/config.test.ts +430 -31
- package/src/config.ts +304 -26
- package/src/diff-hunk-curate.test.ts +315 -0
- package/src/diff-hunk-curate.ts +233 -0
- package/src/diff-intraline.test.ts +164 -0
- package/src/diff-intraline.ts +132 -0
- package/src/diff-syntax.test.ts +96 -0
- package/src/diff-syntax.ts +154 -0
- package/src/editor.test.ts +143 -0
- package/src/editor.ts +123 -9
- package/src/ghostty-terminal.test.ts +45 -0
- package/src/ghostty-terminal.ts +168 -0
- package/src/herdr.test.ts +41 -0
- package/src/herdr.ts +27 -0
- package/src/index.ts +10 -0
- package/src/integrations.test.ts +41 -0
- package/src/integrations.ts +30 -0
- package/src/intent-dispatch.test.ts +492 -0
- package/src/intent-dispatch.ts +428 -0
- package/src/key-bindings.test.ts +152 -0
- package/src/key-bindings.ts +424 -0
- package/src/keymap.test.ts +622 -0
- package/src/keymap.ts +339 -0
- package/src/review-panel.test.ts +113 -0
- package/src/review-panel.ts +83 -0
- package/src/run.ts +9 -5
- package/src/serve.test.ts +24 -9
- package/src/serve.ts +7 -7
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +309 -0
- package/src/session-controller.ts +900 -0
- package/src/share.ts +113 -0
- package/src/test-support.test.ts +55 -0
- package/src/test-support.ts +187 -0
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +36 -15
- package/src/version.ts +5 -0
- package/src/view-diff.test.ts +19 -8
- package/src/view-diff.ts +34 -23
- package/src/view-plan.test.ts +294 -0
- package/src/view-plan.ts +408 -0
- package/src/walk.test.ts +121 -0
- package/src/walk.ts +70 -0
- package/src/view.test.ts +0 -157
- package/src/view.ts +0 -0
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Observer mode (tier 2): the App with readOnly renders and navigates like the
|
|
3
|
-
* controller's, but every mutating verb answers "observer - read-only" and
|
|
4
|
-
* leaves daemon state untouched.
|
|
5
|
-
*/
|
|
1
|
+
/** Observer mode (tier 2): the App with readOnly renders and navigates like the controller's, but every mutating verb answers "observer - read-only" and leaves daemon state untouched. */
|
|
6
2
|
|
|
7
3
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
4
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
@@ -13,6 +9,8 @@ import { testRender } from "@opentui/react/test-utils";
|
|
|
13
9
|
import { DaemonServer } from "@cueloop/daemon";
|
|
14
10
|
import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
|
|
15
11
|
import { App } from "./App";
|
|
12
|
+
import { DARK } from "./theme";
|
|
13
|
+
import { isolateUserConfig, press, waitForState, waitForText } from "./test-support";
|
|
16
14
|
|
|
17
15
|
const PLAN = `# Migration Plan
|
|
18
16
|
|
|
@@ -27,19 +25,26 @@ The daemon persists sessions to disk atomically.
|
|
|
27
25
|
`;
|
|
28
26
|
|
|
29
27
|
let home: string;
|
|
28
|
+
let restoreUserConfig: () => void;
|
|
30
29
|
let server: DaemonServer;
|
|
31
30
|
let session: ReviewSession;
|
|
32
31
|
|
|
33
32
|
beforeEach(() => {
|
|
34
33
|
home = mkdtempSync(join(tmpdir(), "cueloop-observer-"));
|
|
34
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
35
35
|
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
36
36
|
server.start();
|
|
37
37
|
session = server.core.sessionCreate({
|
|
38
38
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
39
|
-
artifact: {
|
|
39
|
+
artifact: {
|
|
40
|
+
type: "plan",
|
|
41
|
+
content: PLAN,
|
|
42
|
+
meta: { title: "Migration Plan", planPath: "plan.md" },
|
|
43
|
+
},
|
|
40
44
|
});
|
|
41
45
|
});
|
|
42
46
|
afterEach(() => {
|
|
47
|
+
restoreUserConfig();
|
|
43
48
|
server.stop();
|
|
44
49
|
rmSync(home, { recursive: true, force: true });
|
|
45
50
|
});
|
|
@@ -49,43 +54,35 @@ async function renderObserver() {
|
|
|
49
54
|
width: 120,
|
|
50
55
|
height: 32,
|
|
51
56
|
});
|
|
52
|
-
|
|
53
|
-
await Bun.sleep(25);
|
|
54
|
-
await setup.renderOnce();
|
|
55
|
-
}
|
|
56
|
-
await setup.renderOnce();
|
|
57
|
+
await waitForText(setup, "cueloop");
|
|
57
58
|
return setup;
|
|
58
59
|
}
|
|
59
60
|
|
|
60
|
-
type Setup = Awaited<ReturnType<typeof renderObserver>>;
|
|
61
|
-
|
|
62
|
-
async function press(setup: Setup, k: string): Promise<void> {
|
|
63
|
-
if (k === "enter") setup.mockInput.pressKey("RETURN");
|
|
64
|
-
else await setup.mockInput.typeText(k);
|
|
65
|
-
await Bun.sleep(15);
|
|
66
|
-
await setup.renderOnce();
|
|
67
|
-
}
|
|
68
|
-
|
|
69
61
|
/** Session state that any mutating verb would change. */
|
|
70
62
|
function snapshot() {
|
|
71
|
-
const
|
|
72
|
-
return {
|
|
63
|
+
const stored = server.core.sessionGet(session.id);
|
|
64
|
+
return {
|
|
65
|
+
annotations: stored.annotations.length,
|
|
66
|
+
workingCopy: stored.workingCopy,
|
|
67
|
+
status: stored.status,
|
|
68
|
+
};
|
|
73
69
|
}
|
|
74
70
|
|
|
75
71
|
describe("observer rendering", () => {
|
|
76
|
-
test("shows the observer badge and
|
|
72
|
+
test("shows the observer badge and the bottom menu", async () => {
|
|
73
|
+
// Arrange
|
|
77
74
|
const setup = await renderObserver();
|
|
75
|
+
|
|
76
|
+
// Assert
|
|
78
77
|
const frame = setup.captureCharFrame();
|
|
79
78
|
expect(frame).toContain("· observer");
|
|
80
|
-
expect(frame).toContain("
|
|
81
|
-
expect(frame).not.toContain("⏎ submit");
|
|
79
|
+
expect(frame).toContain("menu");
|
|
82
80
|
});
|
|
83
81
|
});
|
|
84
82
|
|
|
85
83
|
describe("observer verbs are blocked", () => {
|
|
86
84
|
for (const [key, verb] of [
|
|
87
85
|
["c", "comment"],
|
|
88
|
-
["s", "suggest"],
|
|
89
86
|
["x", "cut"],
|
|
90
87
|
["e", "edit"],
|
|
91
88
|
["enter", "submit"],
|
|
@@ -96,44 +93,51 @@ describe("observer verbs are blocked", () => {
|
|
|
96
93
|
await press(setup, "j");
|
|
97
94
|
await press(setup, "j");
|
|
98
95
|
await press(setup, key);
|
|
99
|
-
await
|
|
100
|
-
await setup.renderOnce();
|
|
101
|
-
const frame = setup.captureCharFrame();
|
|
102
|
-
expect(frame).toContain("observer - read-only");
|
|
96
|
+
const frame = await waitForText(setup, "observer - read-only");
|
|
103
97
|
// no overlay opened: compose/submit bars never appear
|
|
104
|
-
expect(frame).not.toContain(
|
|
105
|
-
expect(frame).not.toContain("SUGGEST REPLACEMENT");
|
|
98
|
+
expect(frame).not.toContain('comment on "');
|
|
106
99
|
expect(frame).not.toContain("verdict ←/→");
|
|
107
100
|
expect(snapshot()).toEqual(before);
|
|
108
101
|
});
|
|
109
102
|
}
|
|
110
103
|
|
|
111
|
-
test("span mode c
|
|
104
|
+
test("span mode c is blocked too", async () => {
|
|
105
|
+
// Arrange
|
|
112
106
|
const setup = await renderObserver();
|
|
107
|
+
|
|
108
|
+
// Act
|
|
113
109
|
await press(setup, "j");
|
|
114
110
|
await press(setup, "j");
|
|
115
111
|
await press(setup, "v");
|
|
116
112
|
await press(setup, "c");
|
|
117
113
|
await setup.renderOnce();
|
|
114
|
+
|
|
115
|
+
// Assert
|
|
118
116
|
const frame = setup.captureCharFrame();
|
|
119
117
|
expect(frame).toContain("observer - read-only");
|
|
120
|
-
expect(frame).not.toContain(
|
|
118
|
+
expect(frame).not.toContain('comment on "');
|
|
121
119
|
expect(snapshot().annotations).toBe(0);
|
|
122
120
|
});
|
|
123
121
|
});
|
|
124
122
|
|
|
125
123
|
describe("observer navigation still works", () => {
|
|
126
124
|
test("j/k moves the cursor between blocks", async () => {
|
|
125
|
+
// Arrange
|
|
127
126
|
const setup = await renderObserver();
|
|
127
|
+
|
|
128
|
+
// Act
|
|
128
129
|
await press(setup, "j");
|
|
129
130
|
await press(setup, "j");
|
|
130
131
|
await setup.renderOnce();
|
|
132
|
+
|
|
133
|
+
// Assert
|
|
131
134
|
const lines = setup.captureCharFrame().split("\n");
|
|
132
|
-
const cursorLine = lines.find((
|
|
135
|
+
const cursorLine = lines.find((line) => line.includes("▎"))!;
|
|
133
136
|
expect(cursorLine).toContain("persists sessions");
|
|
134
137
|
});
|
|
135
138
|
|
|
136
139
|
test("n focuses annotations made by the controller", async () => {
|
|
140
|
+
// Arrange
|
|
137
141
|
server.core.sessionAnnotate(session.id, {
|
|
138
142
|
id: "a_obs1",
|
|
139
143
|
kind: "comment",
|
|
@@ -141,8 +145,28 @@ describe("observer navigation still works", () => {
|
|
|
141
145
|
body: "From the controller.",
|
|
142
146
|
});
|
|
143
147
|
const setup = await renderObserver();
|
|
148
|
+
|
|
149
|
+
// Act
|
|
144
150
|
await press(setup, "n");
|
|
145
151
|
await setup.renderOnce();
|
|
146
|
-
|
|
152
|
+
|
|
153
|
+
// Assert - focus shows as the card's bright kind-colored border (transparent
|
|
154
|
+
// fill); a selected card is the only accent-colored border in the frame
|
|
155
|
+
await waitForText(setup, "COMMENT · me");
|
|
156
|
+
await waitForState(setup, () => hasBorderColor(setup, DARK.accent));
|
|
147
157
|
});
|
|
148
158
|
});
|
|
159
|
+
|
|
160
|
+
/** Whether any border character (box-drawing) is painted in the given foreground hex. */
|
|
161
|
+
function hasBorderColor(setup: Awaited<ReturnType<typeof renderObserver>>, hex: string): boolean {
|
|
162
|
+
for (const line of setup.captureSpans().lines) {
|
|
163
|
+
for (const span of line.spans) {
|
|
164
|
+
if (!/[╭╮╰╯│─┌┐└┘]/.test(span.text)) continue;
|
|
165
|
+
const [red, green, blue] = span.fg.toInts();
|
|
166
|
+
const rendered =
|
|
167
|
+
"#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
|
|
168
|
+
if (rendered === hex) return true;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/** Plan rendering: code blocks verbatim + contained, spacing never collapses. */
|
|
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 { isolateUserConfig, waitForText } from "./test-support";
|
|
13
|
+
|
|
14
|
+
const PLAN = `# Render Plan
|
|
15
|
+
|
|
16
|
+
## Sensor
|
|
17
|
+
|
|
18
|
+
- wire the duck over MQTT
|
|
19
|
+
|
|
20
|
+
\`\`\`tsx
|
|
21
|
+
export function Gate({ full }: { full: number }) {
|
|
22
|
+
const blocked = full < 0.6;
|
|
23
|
+
return <text>{blocked ? "blocked" : "clear"}</text>;
|
|
24
|
+
}
|
|
25
|
+
\`\`\`
|
|
26
|
+
|
|
27
|
+
## Gate
|
|
28
|
+
|
|
29
|
+
Deploys block below the threshold.
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
let home: string;
|
|
33
|
+
let restoreUserConfig: () => void;
|
|
34
|
+
let server: DaemonServer;
|
|
35
|
+
let session: ReviewSession;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-render-"));
|
|
39
|
+
restoreUserConfig = isolateUserConfig(home);
|
|
40
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
41
|
+
server.start();
|
|
42
|
+
session = server.core.sessionCreate({
|
|
43
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
44
|
+
artifact: { type: "plan", content: PLAN, meta: { title: "Render Plan" } },
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
restoreUserConfig();
|
|
49
|
+
server.stop();
|
|
50
|
+
rmSync(home, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
async function frame(): Promise<string> {
|
|
54
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
55
|
+
width: 120,
|
|
56
|
+
height: 36,
|
|
57
|
+
});
|
|
58
|
+
await waitForText(setup, "Render Plan");
|
|
59
|
+
// async highlights settle within the visual-idle wait
|
|
60
|
+
await setup.waitForVisualIdle();
|
|
61
|
+
return setup.captureCharFrame();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("code blocks", () => {
|
|
65
|
+
test("lines render verbatim: indentation preserved, no word-wrap", async () => {
|
|
66
|
+
// Arrange
|
|
67
|
+
const rendered = await frame();
|
|
68
|
+
|
|
69
|
+
// Assert
|
|
70
|
+
expect(rendered).toContain(" const blocked = full < 0.6;");
|
|
71
|
+
expect(rendered).toContain(' return <text>{blocked ? "blocked" : "clear"}</text>;');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("the block carries its language tag", async () => {
|
|
75
|
+
// Arrange
|
|
76
|
+
const rendered = await frame();
|
|
77
|
+
|
|
78
|
+
// Assert
|
|
79
|
+
expect(rendered).toContain("tsx");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("block spacing", () => {
|
|
84
|
+
// the review panel's full-height divider paints a `│` on every row, so read
|
|
85
|
+
// the plan column (everything left of the divider) before asserting spacing
|
|
86
|
+
// the plan body is framed: content sits between the left border and the next │
|
|
87
|
+
const planColumn = (line: string): string => line.split("│")[1]?.trimEnd() ?? "";
|
|
88
|
+
|
|
89
|
+
test("a code block never glues to the list item above it", async () => {
|
|
90
|
+
// Arrange
|
|
91
|
+
const lines = (await frame()).split("\n").map(planColumn);
|
|
92
|
+
const listItemLineIndex = lines.findIndex((line) => line.includes("wire the duck over MQTT"));
|
|
93
|
+
const codeHeader = lines.findIndex((line) => line.trim().startsWith("tsx"));
|
|
94
|
+
|
|
95
|
+
// Assert
|
|
96
|
+
expect(listItemLineIndex).toBeGreaterThan(-1);
|
|
97
|
+
expect(codeHeader).toBeGreaterThan(listItemLineIndex);
|
|
98
|
+
expect(lines.slice(listItemLineIndex + 1, codeHeader).some((line) => line === "")).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a heading never sits directly on the previous block", async () => {
|
|
102
|
+
// Arrange
|
|
103
|
+
const lines = (await frame()).split("\n").map(planColumn);
|
|
104
|
+
const gate = lines.findIndex((line) => line.trim() === "Gate");
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
107
|
+
expect(gate).toBeGreaterThan(0);
|
|
108
|
+
expect(lines[gate - 1]).toBe("");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("consecutive list items stay tight", async () => {
|
|
112
|
+
// Arrange
|
|
113
|
+
const listPlan = "# T\n\n- one\n- two\n";
|
|
114
|
+
const s2 = server.core.sessionCreate({
|
|
115
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
116
|
+
artifact: { type: "plan", content: listPlan, meta: {} },
|
|
117
|
+
});
|
|
118
|
+
const setup = await testRender(<App home={home} sessionId={s2.id} />, {
|
|
119
|
+
width: 120,
|
|
120
|
+
height: 30,
|
|
121
|
+
});
|
|
122
|
+
await waitForText(setup, "one");
|
|
123
|
+
|
|
124
|
+
// Assert
|
|
125
|
+
const lines = setup
|
|
126
|
+
.captureCharFrame()
|
|
127
|
+
.split("\n")
|
|
128
|
+
.map((line) => line.trimEnd());
|
|
129
|
+
const one = lines.findIndex((line) => line.includes("- one"));
|
|
130
|
+
expect(lines[one + 1]).toContain("- two");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/** The review panel end to end: the b key cycles expanded -> compact -> hidden -> expanded on the live App, and the collapse mode persists to the user config so it survives a restart. Rendering details are locked by the ReviewPanel stories; this suite proves the App wiring and the persistence. */
|
|
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 { loadConfig } from "./config";
|
|
13
|
+
import { isolateUserConfig, press, settle, waitForText, waitForTextGone } from "./test-support";
|
|
14
|
+
|
|
15
|
+
const PLAN = "# Plan\n\nShip the thing.\n";
|
|
16
|
+
|
|
17
|
+
let home: string;
|
|
18
|
+
let server: DaemonServer;
|
|
19
|
+
let session: ReviewSession;
|
|
20
|
+
let configPath: string;
|
|
21
|
+
let restoreUserConfig: () => void;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-panel-"));
|
|
25
|
+
configPath = join(home, "config.toml");
|
|
26
|
+
restoreUserConfig = isolateUserConfig(home, "config.toml");
|
|
27
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
28
|
+
server.start();
|
|
29
|
+
session = server.core.sessionCreate({
|
|
30
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
31
|
+
artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
restoreUserConfig();
|
|
36
|
+
server.stop();
|
|
37
|
+
rmSync(home, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
async function renderApp() {
|
|
41
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
42
|
+
width: 120,
|
|
43
|
+
height: 30,
|
|
44
|
+
});
|
|
45
|
+
await waitForText(setup, "Review");
|
|
46
|
+
return setup;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("review panel", () => {
|
|
50
|
+
test("b cycles expanded -> compact -> hidden -> expanded", async () => {
|
|
51
|
+
// Arrange
|
|
52
|
+
const setup = await renderApp();
|
|
53
|
+
// the plan frame widens as the rail shrinks: expanded < compact < hidden.
|
|
54
|
+
// its width is the column of its top-right corner (the first ╮ on the row).
|
|
55
|
+
const planWidth = () =>
|
|
56
|
+
setup
|
|
57
|
+
.captureCharFrame()
|
|
58
|
+
.split("\n")
|
|
59
|
+
.find((line) => line.includes("╮"))
|
|
60
|
+
?.indexOf("╮") ?? 0;
|
|
61
|
+
|
|
62
|
+
// Assert: expanded shows the submit button and leaves room for the rail
|
|
63
|
+
expect(setup.captureCharFrame()).toContain("Submit review");
|
|
64
|
+
const expandedWidth = planWidth();
|
|
65
|
+
|
|
66
|
+
// Act
|
|
67
|
+
await press(setup, "b");
|
|
68
|
+
|
|
69
|
+
// Assert: compact drops the submit button and widens the plan (rail is a strip)
|
|
70
|
+
await waitForTextGone(setup, "Submit review");
|
|
71
|
+
const compactWidth = planWidth();
|
|
72
|
+
expect(compactWidth).toBeGreaterThan(expandedWidth);
|
|
73
|
+
|
|
74
|
+
// Act
|
|
75
|
+
await press(setup, "b");
|
|
76
|
+
await settle(setup);
|
|
77
|
+
|
|
78
|
+
// Assert: hidden spans the plan full width, no rail column
|
|
79
|
+
const hiddenWidth = planWidth();
|
|
80
|
+
expect(hiddenWidth).toBeGreaterThan(compactWidth);
|
|
81
|
+
expect(hiddenWidth).toBeGreaterThan(115);
|
|
82
|
+
|
|
83
|
+
// Act
|
|
84
|
+
await press(setup, "b");
|
|
85
|
+
|
|
86
|
+
// Assert
|
|
87
|
+
await waitForText(setup, "Submit review"); // back to expanded
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("the collapse mode persists to the user config", async () => {
|
|
91
|
+
// Arrange
|
|
92
|
+
const setup = await renderApp();
|
|
93
|
+
|
|
94
|
+
// Act
|
|
95
|
+
await press(setup, "b"); // -> compact
|
|
96
|
+
|
|
97
|
+
// Assert
|
|
98
|
+
expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("compact");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("] widens the rail and persists the new width", async () => {
|
|
102
|
+
// Arrange
|
|
103
|
+
const setup = await renderApp();
|
|
104
|
+
const startWidth = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
|
|
105
|
+
|
|
106
|
+
// Act
|
|
107
|
+
await press(setup, "]");
|
|
108
|
+
|
|
109
|
+
// Assert
|
|
110
|
+
const widened = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
|
|
111
|
+
expect(widened).toBeGreaterThan(startWidth);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("submitting from a hidden panel force-opens the rail so the confirm card is reachable", async () => {
|
|
115
|
+
// Arrange
|
|
116
|
+
const setup = await renderApp();
|
|
117
|
+
await press(setup, "b"); // -> compact
|
|
118
|
+
await press(setup, "b"); // -> hidden
|
|
119
|
+
|
|
120
|
+
// Assert
|
|
121
|
+
await waitForTextGone(setup, "Submit review"); // rail is gone
|
|
122
|
+
expect(setup.captureCharFrame()).not.toContain("Submit review");
|
|
123
|
+
|
|
124
|
+
// Act
|
|
125
|
+
await press(setup, "enter"); // submit while hidden must not trap in an invisible modal
|
|
126
|
+
|
|
127
|
+
// Assert
|
|
128
|
+
await waitForText(setup, "submit review");
|
|
129
|
+
const frame = setup.captureCharFrame();
|
|
130
|
+
expect(frame).toContain("submit review"); // the confirm card is visible again
|
|
131
|
+
expect(frame).toContain("[Approve]");
|
|
132
|
+
|
|
133
|
+
// the reveal is live-only: the saved panel preference stays as the user left it
|
|
134
|
+
expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("hidden");
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
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
|
+
await waitForText(setup, "cueloop");
|
|
60
|
+
return setup;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Seed annotations directly through the daemon core (all on one block). */
|
|
64
|
+
function seedAnnotations(count: number): void {
|
|
65
|
+
const blocks = parseBlocks(PLAN);
|
|
66
|
+
for (let index = 1; index <= count; index++) {
|
|
67
|
+
server.core.sessionAnnotate(session.id, {
|
|
68
|
+
id: `a_confirm_${index}`,
|
|
69
|
+
kind: "comment",
|
|
70
|
+
anchor: makeAnchor(blocks, 2, 0, 10),
|
|
71
|
+
body: `note ${String(index).padStart(2, "0")}`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("rail submit confirm", () => {
|
|
77
|
+
test("submit expands the Submit button into the confirm card", async () => {
|
|
78
|
+
// Arrange
|
|
79
|
+
const setup = await renderApp();
|
|
80
|
+
|
|
81
|
+
// Assert
|
|
82
|
+
expect(setup.captureCharFrame()).toContain("Submit review");
|
|
83
|
+
|
|
84
|
+
// Act
|
|
85
|
+
await press(setup, "enter");
|
|
86
|
+
|
|
87
|
+
// Assert
|
|
88
|
+
await waitForText(setup, "submit review");
|
|
89
|
+
const frame = setup.captureCharFrame();
|
|
90
|
+
// the card: title, honest counts, verdict selector, word-buttons
|
|
91
|
+
expect(frame).toContain("submit review");
|
|
92
|
+
expect(frame).toContain("0 annotations · 0 blocking");
|
|
93
|
+
expect(frame).toContain("[Approve]"); // nothing pending: approve default
|
|
94
|
+
expect(frame).toContain(" Submit ");
|
|
95
|
+
expect(frame).toContain(" Cancel ");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("left/right cycles the verdict selector in the card", async () => {
|
|
99
|
+
// Arrange
|
|
100
|
+
const setup = await renderApp();
|
|
101
|
+
await press(setup, "enter");
|
|
102
|
+
|
|
103
|
+
// Assert
|
|
104
|
+
await waitForText(setup, "[Approve]");
|
|
105
|
+
|
|
106
|
+
// Act
|
|
107
|
+
await press(setup, "right");
|
|
108
|
+
|
|
109
|
+
// Assert
|
|
110
|
+
await waitForText(setup, "[Changes]");
|
|
111
|
+
|
|
112
|
+
// Act
|
|
113
|
+
await press(setup, "right");
|
|
114
|
+
|
|
115
|
+
// Assert
|
|
116
|
+
await waitForText(setup, "[Comment]");
|
|
117
|
+
|
|
118
|
+
// Act
|
|
119
|
+
await press(setup, "left");
|
|
120
|
+
|
|
121
|
+
// Assert
|
|
122
|
+
await waitForText(setup, "[Changes]");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("esc cancels the card and restores the plain Submit button", async () => {
|
|
126
|
+
// Arrange
|
|
127
|
+
const setup = await renderApp();
|
|
128
|
+
await press(setup, "enter");
|
|
129
|
+
|
|
130
|
+
// Assert
|
|
131
|
+
await waitForText(setup, "[Approve]");
|
|
132
|
+
|
|
133
|
+
// Act
|
|
134
|
+
await press(setup, "escape");
|
|
135
|
+
|
|
136
|
+
// Assert
|
|
137
|
+
// a bare ESC settles after the parser's escape-sequence window
|
|
138
|
+
const frame = await waitForTextGone(setup, "[Approve]");
|
|
139
|
+
expect(frame).not.toContain("0 annotations");
|
|
140
|
+
expect(frame).toContain("Submit review");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("enter in the card resolves the session through the controller", async () => {
|
|
144
|
+
// Arrange
|
|
145
|
+
seedAnnotations(1);
|
|
146
|
+
const setup = await renderApp();
|
|
147
|
+
|
|
148
|
+
// Act
|
|
149
|
+
await press(setup, "enter");
|
|
150
|
+
|
|
151
|
+
// Assert
|
|
152
|
+
await waitForText(setup, "1 annotations · 0 blocking");
|
|
153
|
+
const frame = setup.captureCharFrame();
|
|
154
|
+
expect(frame).toContain("1 annotations · 0 blocking");
|
|
155
|
+
expect(frame).toContain("[Changes]"); // pending items: request changes default
|
|
156
|
+
|
|
157
|
+
// Act
|
|
158
|
+
await setup.mockInput.typeText("Tighten the steps.");
|
|
159
|
+
await press(setup, "enter");
|
|
160
|
+
|
|
161
|
+
// Assert
|
|
162
|
+
// the completion flow after submit is unchanged
|
|
163
|
+
await waitForText(setup, "feedback sent");
|
|
164
|
+
const stored = server.core.sessionGet(session.id);
|
|
165
|
+
expect(stored.status).toBe("resolved");
|
|
166
|
+
expect(stored.verdict!.kind).toBe("request_changes");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("the annotation stack stays scrollable while the card is open", async () => {
|
|
170
|
+
// Arrange
|
|
171
|
+
seedAnnotations(12);
|
|
172
|
+
const setup = await renderApp();
|
|
173
|
+
|
|
174
|
+
// Act
|
|
175
|
+
// walk annotation focus to the last card: the rail scrolls it into view
|
|
176
|
+
for (let index = 0; index < 12; index++) await press(setup, "n");
|
|
177
|
+
|
|
178
|
+
// Assert
|
|
179
|
+
await waitForText(setup, "note 12");
|
|
180
|
+
expect(setup.captureCharFrame()).not.toContain("note 01");
|
|
181
|
+
|
|
182
|
+
// Act
|
|
183
|
+
await press(setup, "enter");
|
|
184
|
+
|
|
185
|
+
// Assert
|
|
186
|
+
await waitForText(setup, "12 annotations · 0 blocking");
|
|
187
|
+
// the card is pinned outside the scrollbox; the stack stays scrolled off the top
|
|
188
|
+
expect(setup.captureCharFrame()).not.toContain("note 01");
|
|
189
|
+
|
|
190
|
+
// Act
|
|
191
|
+
// the stack still scrolls with the card open: wheel over the rail
|
|
192
|
+
// brings the last card back into view while the confirm card stays put
|
|
193
|
+
for (let turn = 0; turn < 12; turn++) await setup.mockMouse.scroll(100, 10, "down");
|
|
194
|
+
await settle(setup);
|
|
195
|
+
|
|
196
|
+
// Assert
|
|
197
|
+
await waitForText(setup, "note 12");
|
|
198
|
+
const scrolledWithCard = setup.captureCharFrame();
|
|
199
|
+
expect(scrolledWithCard).toContain("12 annotations · 0 blocking");
|
|
200
|
+
expect(scrolledWithCard).not.toContain("note 01");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("read-only observers never see the confirm card", async () => {
|
|
204
|
+
// Arrange
|
|
205
|
+
seedAnnotations(1);
|
|
206
|
+
const setup = await renderApp({ readOnly: true });
|
|
207
|
+
|
|
208
|
+
// Act
|
|
209
|
+
await press(setup, "enter");
|
|
210
|
+
|
|
211
|
+
// Assert
|
|
212
|
+
await waitForText(setup, "observer - read-only");
|
|
213
|
+
const frame = setup.captureCharFrame();
|
|
214
|
+
expect(frame).toContain("observer - read-only");
|
|
215
|
+
expect(frame).not.toContain("1 annotations");
|
|
216
|
+
expect(frame).not.toContain("[Changes]");
|
|
217
|
+
expect(setup.captureCharFrame()).not.toContain("verdict ←/→");
|
|
218
|
+
expect(server.core.sessionGet(session.id).status).toBe("pending");
|
|
219
|
+
});
|
|
220
|
+
});
|