@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
package/package.json
CHANGED
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.20",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
7
7
|
".": "./src/index.ts"
|
|
8
8
|
},
|
|
9
9
|
"dependencies": {
|
|
10
|
-
"@cueloop/daemon": "0.1.0-alpha.
|
|
11
|
-
"@cueloop/extension-api": "0.1.0-alpha.
|
|
12
|
-
"@cueloop/integration-obsidian": "0.1.0-alpha.
|
|
13
|
-
"@cueloop/schema": "0.1.0-alpha.
|
|
14
|
-
"@opentui/core": "^0.
|
|
15
|
-
"@opentui/
|
|
10
|
+
"@cueloop/daemon": "0.1.0-alpha.20",
|
|
11
|
+
"@cueloop/extension-api": "0.1.0-alpha.20",
|
|
12
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.20",
|
|
13
|
+
"@cueloop/schema": "0.1.0-alpha.20",
|
|
14
|
+
"@opentui/core": "^0.5.1",
|
|
15
|
+
"@opentui/keymap": "0.5.1",
|
|
16
|
+
"@opentui/react": "^0.5.1",
|
|
17
|
+
"@opentui/ssh": "0.5.1",
|
|
16
18
|
"@pierre/diffs": "^1.3.5",
|
|
17
|
-
"react": "^19.2.0"
|
|
18
|
-
"@opentui/ssh": "0.4.5"
|
|
19
|
+
"react": "^19.2.0"
|
|
19
20
|
},
|
|
20
21
|
"devDependencies": {
|
|
21
22
|
"@types/react": "^19.2.0",
|
|
@@ -36,5 +37,10 @@
|
|
|
36
37
|
"type": "git",
|
|
37
38
|
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
38
39
|
"directory": "packages/client"
|
|
39
|
-
}
|
|
40
|
-
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/mmurakaru/cueloop/issues"
|
|
44
|
+
},
|
|
45
|
+
"description": "The cueloop review UI: projection renderer, keyboard grammar, review rail"
|
|
46
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-submit completion overlay: prompt, opt-in persistence, countdown,
|
|
3
|
+
* dismissal. Countdown seconds tick on an injected ManualClock, so the
|
|
4
|
+
* tests advance time instead of waiting it out.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import React from "react";
|
|
12
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
13
|
+
import { ManualClock } from "@opentui/core/testing";
|
|
14
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
15
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
16
|
+
import { App } from "./App";
|
|
17
|
+
import { press, settle, waitForText } from "./test-support";
|
|
18
|
+
|
|
19
|
+
const PLAN = "# Plan\n\nShip the thing.\n";
|
|
20
|
+
|
|
21
|
+
let home: string;
|
|
22
|
+
let server: DaemonServer;
|
|
23
|
+
let session: ReviewSession;
|
|
24
|
+
let configPath: string;
|
|
25
|
+
let clock: ManualClock;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-close-"));
|
|
29
|
+
configPath = join(home, "config.toml");
|
|
30
|
+
process.env.CUELOOP_CONFIG = configPath;
|
|
31
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
32
|
+
server.start();
|
|
33
|
+
session = server.core.sessionCreate({
|
|
34
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
35
|
+
artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
|
|
36
|
+
});
|
|
37
|
+
clock = new ManualClock();
|
|
38
|
+
});
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
delete process.env.CUELOOP_CONFIG;
|
|
41
|
+
server.stop();
|
|
42
|
+
rmSync(home, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
async function renderApp(onExit?: (code: number) => void) {
|
|
46
|
+
const setup = await testRender(<App home={home} sessionId={session.id} onExit={onExit} clock={clock} />, {
|
|
47
|
+
width: 120,
|
|
48
|
+
height: 30,
|
|
49
|
+
});
|
|
50
|
+
await waitForText(setup, "cueloop");
|
|
51
|
+
return setup;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
55
|
+
|
|
56
|
+
async function submitApprove(setup: Setup): Promise<void> {
|
|
57
|
+
await press(setup, "enter"); // open submit (approve default: no pending items)
|
|
58
|
+
await press(setup, "enter"); // confirm
|
|
59
|
+
await waitForText(setup, "review approved");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe("completion overlay", () => {
|
|
63
|
+
test("submit with auto-close off counts down from 5; ⏎ exits now", async () => {
|
|
64
|
+
let exited = -1;
|
|
65
|
+
const setup = await renderApp((code) => (exited = code));
|
|
66
|
+
await submitApprove(setup);
|
|
67
|
+
const frame = await waitForText(setup, "closing in 5s");
|
|
68
|
+
expect(frame).toContain("review approved");
|
|
69
|
+
expect(frame).toContain("close [return]");
|
|
70
|
+
expect(frame).toContain("return to plan [esc]");
|
|
71
|
+
await press(setup, "enter");
|
|
72
|
+
expect(exited).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a remembers the countdown as the persisted default", async () => {
|
|
76
|
+
const setup = await renderApp();
|
|
77
|
+
await submitApprove(setup);
|
|
78
|
+
await waitForText(setup, "closing in 5s");
|
|
79
|
+
await press(setup, "a");
|
|
80
|
+
expect(existsSync(configPath)).toBe(true);
|
|
81
|
+
expect(readFileSync(configPath, "utf8")).toContain("auto_close = 5");
|
|
82
|
+
// the countdown keeps running; each second is one manual-clock tick
|
|
83
|
+
clock.advance(1000);
|
|
84
|
+
await waitForText(setup, "closing in 4s");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("esc dismisses to the resolved read-only view", async () => {
|
|
88
|
+
const setup = await renderApp();
|
|
89
|
+
await submitApprove(setup);
|
|
90
|
+
await waitForText(setup, "closing in 5s");
|
|
91
|
+
// a bare ESC sits in the input parser's escape-sequence disambiguation
|
|
92
|
+
// window before it is delivered - the frame wait absorbs it
|
|
93
|
+
await press(setup, "escape");
|
|
94
|
+
const frame = await waitForText(setup, "resolved: approve");
|
|
95
|
+
expect(frame).not.toContain("closing in");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("configured auto_close counts down and exits without interaction", async () => {
|
|
99
|
+
writeFileSync(configPath, "[ui]\nauto_close = 1\n");
|
|
100
|
+
let exited = -1;
|
|
101
|
+
const setup = await renderApp((code) => (exited = code));
|
|
102
|
+
await submitApprove(setup);
|
|
103
|
+
await waitForText(setup, "closing in 1s");
|
|
104
|
+
expect(exited).toBe(-1);
|
|
105
|
+
clock.advance(1000);
|
|
106
|
+
await settle(setup);
|
|
107
|
+
expect(exited).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
});
|
package/src/App.diff.test.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { testRender } from "@opentui/react/test-utils";
|
|
|
9
9
|
import { DaemonServer } from "@cueloop/daemon";
|
|
10
10
|
import type { ReviewSession } from "@cueloop/schema";
|
|
11
11
|
import { App } from "./App";
|
|
12
|
+
import { press, waitForText } from "./test-support";
|
|
12
13
|
|
|
13
14
|
const PATCH = `diff --git a/src/store.ts b/src/store.ts
|
|
14
15
|
index 111..222 100644
|
|
@@ -41,22 +42,10 @@ afterEach(() => {
|
|
|
41
42
|
|
|
42
43
|
async function renderApp() {
|
|
43
44
|
const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 30 });
|
|
44
|
-
|
|
45
|
-
await Bun.sleep(25);
|
|
46
|
-
await setup.renderOnce();
|
|
47
|
-
}
|
|
48
|
-
await setup.renderOnce();
|
|
45
|
+
await waitForText(setup, "cueloop");
|
|
49
46
|
return setup;
|
|
50
47
|
}
|
|
51
48
|
|
|
52
|
-
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
53
|
-
async function press(setup: Setup, k: string): Promise<void> {
|
|
54
|
-
if (k === "enter") setup.mockInput.pressKey("RETURN");
|
|
55
|
-
else await setup.mockInput.typeText(k);
|
|
56
|
-
await Bun.sleep(15);
|
|
57
|
-
await setup.renderOnce();
|
|
58
|
-
}
|
|
59
|
-
|
|
60
49
|
describe("diff review", () => {
|
|
61
50
|
test("renders file header, hunks, and signed lines", async () => {
|
|
62
51
|
const setup = await renderApp();
|
|
@@ -75,17 +64,15 @@ describe("diff review", () => {
|
|
|
75
64
|
expect(setup.captureCharFrame()).toContain("COMMENT ON");
|
|
76
65
|
await setup.mockInput.typeText("Map needs an eviction story.");
|
|
77
66
|
await press(setup, "enter");
|
|
78
|
-
|
|
79
|
-
await setup.
|
|
67
|
+
// inline annotation card rendered under the line
|
|
68
|
+
await waitForText(setup, "◆ Map needs an eviction story.");
|
|
80
69
|
const stored = server.core.sessionGet(session.id);
|
|
81
70
|
expect(stored.annotations.length).toBe(1);
|
|
82
71
|
expect(stored.annotations[0]!.anchor.quote).toContain("new Map()");
|
|
83
|
-
// inline annotation card rendered under the line
|
|
84
|
-
expect(setup.captureCharFrame()).toContain("◆ Map needs an eviction story.");
|
|
85
72
|
|
|
86
73
|
await press(setup, "enter"); // submit
|
|
87
74
|
await press(setup, "enter"); // confirm request_changes
|
|
88
|
-
await
|
|
75
|
+
await waitForText(setup, "feedback sent");
|
|
89
76
|
const resolved = server.core.sessionGet(session.id);
|
|
90
77
|
expect(resolved.verdict!.feedback).toContain("new Map()");
|
|
91
78
|
expect(resolved.verdict!.feedback).toContain("Map needs an eviction story.");
|
package/src/App.export.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, waitForState, waitForText } from "./test-support";
|
|
16
17
|
|
|
17
18
|
const PLAN = `# Migration Plan
|
|
18
19
|
|
|
@@ -50,22 +51,16 @@ afterEach(() => {
|
|
|
50
51
|
describe("obsidian export on resolve", () => {
|
|
51
52
|
test("submitting a review writes the plan into the vault and shows the path", async () => {
|
|
52
53
|
const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 32 });
|
|
53
|
-
|
|
54
|
-
await Bun.sleep(25);
|
|
55
|
-
await setup.renderOnce();
|
|
56
|
-
}
|
|
54
|
+
await waitForText(setup, "cueloop");
|
|
57
55
|
// open the submit overlay, keep the default verdict (approve), submit
|
|
58
|
-
setup
|
|
59
|
-
await Bun.sleep(15);
|
|
60
|
-
await setup.renderOnce();
|
|
56
|
+
await press(setup, "enter");
|
|
61
57
|
expect(setup.captureCharFrame()).toContain("verdict");
|
|
62
|
-
setup
|
|
58
|
+
await press(setup, "enter");
|
|
59
|
+
// the export is an async round-trip after resolve; the status line lands
|
|
60
|
+
// on a later render than the file write
|
|
63
61
|
const dir = join(vault, "cueloop");
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
await setup.renderOnce();
|
|
67
|
-
}
|
|
68
|
-
await setup.renderOnce();
|
|
62
|
+
await waitForState(setup, () => existsSync(dir));
|
|
63
|
+
await waitForText(setup, "exported to");
|
|
69
64
|
|
|
70
65
|
const notes = readdirSync(dir);
|
|
71
66
|
expect(notes.length).toBe(1);
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan review surface v2: native selection feeds the annotation
|
|
3
|
+
* quote, the inline compose box keeps its anchor painted, the rail edits what
|
|
4
|
+
* the document selects, and edit-exit reconciliation orphans annotations
|
|
5
|
+
* whose passage was removed. Char-frame + styled-span assertions over the
|
|
6
|
+
* real App and a real in-process daemon.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import React from "react";
|
|
14
|
+
import { testRender } from "@opentui/react/test-utils";
|
|
15
|
+
import { DaemonServer } from "@cueloop/daemon";
|
|
16
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
17
|
+
import { App } from "./App";
|
|
18
|
+
import { DARK as T } from "./theme";
|
|
19
|
+
import { press, settle, typeText as type, waitForState, waitForText, waitForTextGone } from "./test-support";
|
|
20
|
+
|
|
21
|
+
const PLAN = `# Migration Plan
|
|
22
|
+
|
|
23
|
+
## Context
|
|
24
|
+
|
|
25
|
+
The daemon persists sessions to disk atomically.
|
|
26
|
+
|
|
27
|
+
## Steps
|
|
28
|
+
|
|
29
|
+
- move the store
|
|
30
|
+
- add recovery
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
let home: string;
|
|
34
|
+
let server: DaemonServer;
|
|
35
|
+
let session: ReviewSession;
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-plan-review-"));
|
|
39
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
40
|
+
server.start();
|
|
41
|
+
session = server.core.sessionCreate({
|
|
42
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
43
|
+
artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md", agent: "agent/worker-3" } },
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
server.stop();
|
|
48
|
+
rmSync(home, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
async function renderApp() {
|
|
52
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
53
|
+
width: 120,
|
|
54
|
+
height: 32,
|
|
55
|
+
});
|
|
56
|
+
await waitForText(setup, "cueloop");
|
|
57
|
+
return setup;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Background colors (hex) of every styled span containing the needle - the
|
|
64
|
+
* document highlight and the rail's quote excerpt can both match.
|
|
65
|
+
*/
|
|
66
|
+
function backgroundsOf(setup: Setup, needle: string): string[] {
|
|
67
|
+
const backgrounds: string[] = [];
|
|
68
|
+
for (const line of setup.captureSpans().lines) {
|
|
69
|
+
for (const span of line.spans) {
|
|
70
|
+
if (!span.text.includes(needle)) continue;
|
|
71
|
+
const [red, green, blue] = span.bg.toInts();
|
|
72
|
+
backgrounds.push("#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return backgrounds;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Move the cursor to "The daemon persists sessions to disk atomically." */
|
|
79
|
+
async function toContextParagraph(setup: Setup): Promise<void> {
|
|
80
|
+
await press(setup, "j");
|
|
81
|
+
await press(setup, "j");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe("selection feeds the annotation quote", () => {
|
|
85
|
+
test("v anchors, l extends, c composes with the char-precise quote", async () => {
|
|
86
|
+
const setup = await renderApp();
|
|
87
|
+
await toContextParagraph(setup);
|
|
88
|
+
await press(setup, "v");
|
|
89
|
+
await press(setup, "l"); // "The daemon"
|
|
90
|
+
await press(setup, "c");
|
|
91
|
+
expect(setup.captureCharFrame()).toContain('comment on "The daemon"');
|
|
92
|
+
await type(setup, "Which daemon?");
|
|
93
|
+
await press(setup, "enter");
|
|
94
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
95
|
+
const stored = server.core.sessionGet(session.id);
|
|
96
|
+
expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("a mouse drag over the text becomes the compose quote", async () => {
|
|
100
|
+
const setup = await renderApp();
|
|
101
|
+
const lines = setup.captureCharFrame().split("\n");
|
|
102
|
+
const row = lines.findIndex((line) => line.includes("persists sessions"));
|
|
103
|
+
const startColumn = lines[row]!.indexOf("persists");
|
|
104
|
+
expect(row).toBeGreaterThan(-1);
|
|
105
|
+
// the drag's end cell is exclusive: release one past the last character
|
|
106
|
+
await setup.mockMouse.drag(startColumn, row, startColumn + "persists sessions".length, row);
|
|
107
|
+
await settle(setup);
|
|
108
|
+
await press(setup, "c");
|
|
109
|
+
expect(setup.captureCharFrame()).toContain('comment on "persists sessions"');
|
|
110
|
+
await type(setup, "sessions plural?");
|
|
111
|
+
await press(setup, "enter");
|
|
112
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
113
|
+
const stored = server.core.sessionGet(session.id);
|
|
114
|
+
expect(stored.annotations[0]!.anchor.quote).toBe("persists sessions");
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("inline compose keeps the anchor painted", () => {
|
|
119
|
+
test("the selection stays painted while composing; cancel un-paints; save converts to the comment highlight", async () => {
|
|
120
|
+
const setup = await renderApp();
|
|
121
|
+
await toContextParagraph(setup);
|
|
122
|
+
await press(setup, "v");
|
|
123
|
+
await press(setup, "l");
|
|
124
|
+
await press(setup, "c");
|
|
125
|
+
// compose open: the anchor is painted selection-style. The box can render
|
|
126
|
+
// a frame before the anchor repaint settles, so wait on the color itself.
|
|
127
|
+
expect(setup.captureCharFrame()).toContain("Save ⏎");
|
|
128
|
+
expect(setup.captureCharFrame()).toContain("Cancel esc");
|
|
129
|
+
await waitForState(setup, () => backgroundsOf(setup, "The daemon").includes(T.accent));
|
|
130
|
+
expect(backgroundsOf(setup, "The daemon")).toContain(T.accent);
|
|
131
|
+
// cancel un-paints (a bare ESC settles after the parser's escape window)
|
|
132
|
+
await press(setup, "escape");
|
|
133
|
+
await waitForTextGone(setup, 'comment on "');
|
|
134
|
+
expect(backgroundsOf(setup, "The daemon")).not.toContain(T.accent);
|
|
135
|
+
// save converts the paint to the kind-colored annotation highlight
|
|
136
|
+
await press(setup, "c");
|
|
137
|
+
await type(setup, "Which daemon?");
|
|
138
|
+
await press(setup, "enter");
|
|
139
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
140
|
+
await settle(setup);
|
|
141
|
+
// the whole cursor block is the anchor here; check the highlight landed
|
|
142
|
+
const stored = server.core.sessionGet(session.id);
|
|
143
|
+
expect(stored.annotations.length).toBe(1);
|
|
144
|
+
expect(backgroundsOf(setup, stored.annotations[0]!.anchor.quote.slice(0, 20))).toContain(T.markCommentBg);
|
|
145
|
+
// renderApp plus this many frame-waits grazes the 5s default on a loaded CI
|
|
146
|
+
// runner; the sibling walk resume test carries the same budget.
|
|
147
|
+
}, 15_000);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("the document selects, the rail edits", () => {
|
|
151
|
+
test("e edits the selected card in place; enter saves through the controller", async () => {
|
|
152
|
+
const setup = await renderApp();
|
|
153
|
+
await toContextParagraph(setup);
|
|
154
|
+
await press(setup, "c");
|
|
155
|
+
await type(setup, "Needs a citation.");
|
|
156
|
+
await press(setup, "enter");
|
|
157
|
+
await waitForText(setup, "COMMENT · pending");
|
|
158
|
+
// the saved card is selected; e turns its body into an input in place
|
|
159
|
+
await press(setup, "e");
|
|
160
|
+
await type(setup, " And a link.");
|
|
161
|
+
await press(setup, "enter");
|
|
162
|
+
await waitForState(
|
|
163
|
+
setup,
|
|
164
|
+
() => server.core.sessionGet(session.id).annotations[0]!.body === "Needs a citation. And a link.",
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("x deletes the selected card and un-paints the document highlight", async () => {
|
|
169
|
+
const setup = await renderApp();
|
|
170
|
+
await toContextParagraph(setup);
|
|
171
|
+
await press(setup, "c");
|
|
172
|
+
await type(setup, "Delete me.");
|
|
173
|
+
await press(setup, "enter");
|
|
174
|
+
await waitForText(setup, "COMMENT · pending");
|
|
175
|
+
expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
|
|
176
|
+
expect(backgroundsOf(setup, "persists sessions")).toContain(T.markCommentBg);
|
|
177
|
+
await press(setup, "x");
|
|
178
|
+
await waitForTextGone(setup, "COMMENT · pending");
|
|
179
|
+
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
180
|
+
expect(backgroundsOf(setup, "persists sessions")).not.toContain(T.markCommentBg);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("sheet header", () => {
|
|
185
|
+
test("shows the submitting agent, the revision, and the Edit word-button", async () => {
|
|
186
|
+
const setup = await renderApp();
|
|
187
|
+
const frame = setup.captureCharFrame();
|
|
188
|
+
expect(frame).toContain("submitted by agent/worker-3 · revision 1");
|
|
189
|
+
expect(frame).toContain("Edit");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("the Agent tab shows the submitter, status, and revision", async () => {
|
|
193
|
+
const setup = await renderApp();
|
|
194
|
+
const lines = setup.captureCharFrame().split("\n");
|
|
195
|
+
const tabRow = lines.findIndex((line) => line.includes("Review (0)"));
|
|
196
|
+
const tabColumn = lines[tabRow]!.indexOf("Agent");
|
|
197
|
+
await setup.mockMouse.click(tabColumn + 1, tabRow);
|
|
198
|
+
await setup.renderOnce();
|
|
199
|
+
const frame = setup.captureCharFrame();
|
|
200
|
+
expect(frame).toContain("agent/worker-3");
|
|
201
|
+
expect(frame).toContain("status: pending");
|
|
202
|
+
expect(frame).toContain("revision 1");
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe("edit-exit reconciliation", () => {
|
|
207
|
+
test("an edit that removes an anchored passage orphans the annotation and shows the banner", async () => {
|
|
208
|
+
const script = join(home, "fake-editor.sh");
|
|
209
|
+
await Bun.write(script, `#!/bin/sh\nsed -i '' '/^The daemon persists/d' "$1"\n`);
|
|
210
|
+
Bun.spawnSync(["chmod", "+x", script]);
|
|
211
|
+
process.env.CUELOOP_EDITOR = script;
|
|
212
|
+
try {
|
|
213
|
+
const setup = await renderApp();
|
|
214
|
+
await toContextParagraph(setup);
|
|
215
|
+
await press(setup, "c");
|
|
216
|
+
await type(setup, "Anchor me to the doomed passage.");
|
|
217
|
+
await press(setup, "enter");
|
|
218
|
+
await waitForText(setup, "· pending");
|
|
219
|
+
// deselect the card so e reaches the editor hand-off, then edit
|
|
220
|
+
await press(setup, "escape");
|
|
221
|
+
await waitForTextGone(setup, "▸ COMMENT");
|
|
222
|
+
await press(setup, "e");
|
|
223
|
+
await waitForText(setup, "1 annotation no longer match - the passage was removed.");
|
|
224
|
+
// the rail card flips to ORPHANED once the working-copy refresh lands
|
|
225
|
+
await waitForText(setup, "· ORPHANED");
|
|
226
|
+
// the annotation is NOT deleted: the feedback serializer handles orphans
|
|
227
|
+
expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
|
|
228
|
+
} finally {
|
|
229
|
+
delete process.env.CUELOOP_EDITOR;
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
});
|
|
@@ -13,6 +13,7 @@ import { testRender } from "@opentui/react/test-utils";
|
|
|
13
13
|
import { DaemonServer } from "@cueloop/daemon";
|
|
14
14
|
import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
|
|
15
15
|
import { App } from "./App";
|
|
16
|
+
import { press, waitForText } from "./test-support";
|
|
16
17
|
|
|
17
18
|
const PLAN = `# Migration Plan
|
|
18
19
|
|
|
@@ -49,27 +50,14 @@ async function renderObserver() {
|
|
|
49
50
|
width: 120,
|
|
50
51
|
height: 32,
|
|
51
52
|
});
|
|
52
|
-
|
|
53
|
-
await Bun.sleep(25);
|
|
54
|
-
await setup.renderOnce();
|
|
55
|
-
}
|
|
56
|
-
await setup.renderOnce();
|
|
53
|
+
await waitForText(setup, "cueloop");
|
|
57
54
|
return setup;
|
|
58
55
|
}
|
|
59
56
|
|
|
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
57
|
/** Session state that any mutating verb would change. */
|
|
70
58
|
function snapshot() {
|
|
71
|
-
const
|
|
72
|
-
return { annotations:
|
|
59
|
+
const stored = server.core.sessionGet(session.id);
|
|
60
|
+
return { annotations: stored.annotations.length, workingCopy: stored.workingCopy, status: stored.status };
|
|
73
61
|
}
|
|
74
62
|
|
|
75
63
|
describe("observer rendering", () => {
|
|
@@ -96,10 +84,7 @@ describe("observer verbs are blocked", () => {
|
|
|
96
84
|
await press(setup, "j");
|
|
97
85
|
await press(setup, "j");
|
|
98
86
|
await press(setup, key);
|
|
99
|
-
await
|
|
100
|
-
await setup.renderOnce();
|
|
101
|
-
const frame = setup.captureCharFrame();
|
|
102
|
-
expect(frame).toContain("observer - read-only");
|
|
87
|
+
const frame = await waitForText(setup, "observer - read-only");
|
|
103
88
|
// no overlay opened: compose/submit bars never appear
|
|
104
89
|
expect(frame).not.toContain("COMMENT ON");
|
|
105
90
|
expect(frame).not.toContain("SUGGEST REPLACEMENT");
|
|
@@ -129,7 +114,7 @@ describe("observer navigation still works", () => {
|
|
|
129
114
|
await press(setup, "j");
|
|
130
115
|
await setup.renderOnce();
|
|
131
116
|
const lines = setup.captureCharFrame().split("\n");
|
|
132
|
-
const cursorLine = lines.find((
|
|
117
|
+
const cursorLine = lines.find((line) => line.includes("▎"))!;
|
|
133
118
|
expect(cursorLine).toContain("persists sessions");
|
|
134
119
|
});
|
|
135
120
|
|
|
@@ -0,0 +1,100 @@
|
|
|
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 { 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 server: DaemonServer;
|
|
34
|
+
let session: ReviewSession;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-render-"));
|
|
38
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
39
|
+
server.start();
|
|
40
|
+
session = server.core.sessionCreate({
|
|
41
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
42
|
+
artifact: { type: "plan", content: PLAN, meta: { title: "Render Plan" } },
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
server.stop();
|
|
47
|
+
rmSync(home, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
async function frame(): Promise<string> {
|
|
51
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 36 });
|
|
52
|
+
await waitForText(setup, "Render Plan");
|
|
53
|
+
// async highlights settle within the visual-idle wait
|
|
54
|
+
await setup.waitForVisualIdle();
|
|
55
|
+
return setup.captureCharFrame();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("code blocks", () => {
|
|
59
|
+
test("lines render verbatim: indentation preserved, no word-wrap", async () => {
|
|
60
|
+
const rendered = await frame();
|
|
61
|
+
expect(rendered).toContain(" const blocked = full < 0.6;");
|
|
62
|
+
expect(rendered).toContain(' return <text>{blocked ? "blocked" : "clear"}</text>;');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("the block carries its language tag", async () => {
|
|
66
|
+
const rendered = await frame();
|
|
67
|
+
expect(rendered).toContain("tsx");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("block spacing", () => {
|
|
72
|
+
test("a code block never glues to the list item above it", async () => {
|
|
73
|
+
const lines = (await frame()).split("\n").map((line) => line.trimEnd());
|
|
74
|
+
const listItemLineIndex = lines.findIndex((line) => line.includes("wire the duck over MQTT"));
|
|
75
|
+
const codeHeader = lines.findIndex((line) => line.trim().startsWith("tsx"));
|
|
76
|
+
expect(listItemLineIndex).toBeGreaterThan(-1);
|
|
77
|
+
expect(codeHeader).toBeGreaterThan(listItemLineIndex);
|
|
78
|
+
expect(lines.slice(listItemLineIndex + 1, codeHeader).some((line) => line === "")).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("a heading never sits directly on the previous block", async () => {
|
|
82
|
+
const lines = (await frame()).split("\n").map((line) => line.trimEnd());
|
|
83
|
+
const gate = lines.findIndex((line) => line.trim() === "Gate");
|
|
84
|
+
expect(gate).toBeGreaterThan(0);
|
|
85
|
+
expect(lines[gate - 1]).toBe("");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("consecutive list items stay tight", async () => {
|
|
89
|
+
const listPlan = "# T\n\n- one\n- two\n";
|
|
90
|
+
const s2 = server.core.sessionCreate({
|
|
91
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
92
|
+
artifact: { type: "plan", content: listPlan, meta: {} },
|
|
93
|
+
});
|
|
94
|
+
const setup = await testRender(<App home={home} sessionId={s2.id} />, { width: 120, height: 30 });
|
|
95
|
+
await waitForText(setup, "one");
|
|
96
|
+
const lines = setup.captureCharFrame().split("\n").map((line) => line.trimEnd());
|
|
97
|
+
const one = lines.findIndex((line) => line.includes("- one"));
|
|
98
|
+
expect(lines[one + 1]).toContain("- two");
|
|
99
|
+
});
|
|
100
|
+
});
|