@cueloop/client 0.1.0-alpha.0
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 +40 -0
- package/src/App.diff.test.tsx +99 -0
- package/src/App.export.test.tsx +80 -0
- package/src/App.readonly.test.tsx +148 -0
- package/src/App.test.tsx +217 -0
- package/src/App.tsx +0 -0
- package/src/config.test.ts +116 -0
- package/src/config.ts +113 -0
- package/src/editor.ts +34 -0
- package/src/index.ts +2 -0
- package/src/run.ts +28 -0
- package/src/serve.test.ts +96 -0
- package/src/serve.ts +75 -0
- package/src/theme.ts +46 -0
- package/src/view-diff.test.ts +55 -0
- package/src/view-diff.ts +86 -0
- package/src/view.test.ts +157 -0
- package/src/view.ts +0 -0
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cueloop/client",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"license": "Apache-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@cueloop/daemon": "workspace:*",
|
|
11
|
+
"@cueloop/extension-api": "workspace:*",
|
|
12
|
+
"@cueloop/integration-obsidian": "workspace:*",
|
|
13
|
+
"@cueloop/schema": "workspace:*",
|
|
14
|
+
"@opentui/core": "^0.4.3",
|
|
15
|
+
"@opentui/react": "^0.4.3",
|
|
16
|
+
"@pierre/diffs": "^1.3.5",
|
|
17
|
+
"react": "^19.2.0",
|
|
18
|
+
"@opentui/ssh": "0.4.5"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/react": "^19.2.0",
|
|
22
|
+
"ssh2": "^1.17.0",
|
|
23
|
+
"@types/ssh2": "^1.15.5"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"src",
|
|
30
|
+
"README.md"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"bun": ">=1.3.0"
|
|
34
|
+
},
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
38
|
+
"directory": "packages/client"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** Diff-review flow in the virtual terminal (slice 3). */
|
|
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
|
+
|
|
13
|
+
const PATCH = `diff --git a/src/store.ts b/src/store.ts
|
|
14
|
+
index 111..222 100644
|
|
15
|
+
--- a/src/store.ts
|
|
16
|
+
+++ b/src/store.ts
|
|
17
|
+
@@ -1,4 +1,4 @@
|
|
18
|
+
export class Store {
|
|
19
|
+
- private items = [];
|
|
20
|
+
+ private items = new Map();
|
|
21
|
+
}
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
let home: string;
|
|
25
|
+
let server: DaemonServer;
|
|
26
|
+
let session: ReviewSession;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-diff-"));
|
|
30
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
31
|
+
server.start();
|
|
32
|
+
session = server.core.sessionCreate({
|
|
33
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
34
|
+
artifact: { type: "diff", content: PATCH, meta: { title: "working tree" } },
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
server.stop();
|
|
39
|
+
rmSync(home, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
async function renderApp() {
|
|
43
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 30 });
|
|
44
|
+
for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
|
|
45
|
+
await Bun.sleep(25);
|
|
46
|
+
await setup.renderOnce();
|
|
47
|
+
}
|
|
48
|
+
await setup.renderOnce();
|
|
49
|
+
return setup;
|
|
50
|
+
}
|
|
51
|
+
|
|
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
|
+
describe("diff review", () => {
|
|
61
|
+
test("renders file header, hunks, and signed lines", async () => {
|
|
62
|
+
const setup = await renderApp();
|
|
63
|
+
const frame = setup.captureCharFrame();
|
|
64
|
+
expect(frame).toContain("■ src/store.ts");
|
|
65
|
+
expect(frame).toContain("@@ -1,4 +1,4 @@");
|
|
66
|
+
expect(frame).toContain("- private items = [];");
|
|
67
|
+
expect(frame).toContain("+ private items = new Map();");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("line comment: quote-anchored, lands in feedback with the code line", async () => {
|
|
71
|
+
const setup = await renderApp();
|
|
72
|
+
// rows: file(0), hunk(1), ctx(2), del(3), add(4)
|
|
73
|
+
for (let i = 0; i < 4; i++) await press(setup, "j");
|
|
74
|
+
await press(setup, "c");
|
|
75
|
+
expect(setup.captureCharFrame()).toContain("COMMENT ON");
|
|
76
|
+
await setup.mockInput.typeText("Map needs an eviction story.");
|
|
77
|
+
await press(setup, "enter");
|
|
78
|
+
await Bun.sleep(80);
|
|
79
|
+
await setup.renderOnce();
|
|
80
|
+
const stored = server.core.sessionGet(session.id);
|
|
81
|
+
expect(stored.annotations.length).toBe(1);
|
|
82
|
+
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
|
+
|
|
86
|
+
await press(setup, "enter"); // submit
|
|
87
|
+
await press(setup, "enter"); // confirm request_changes
|
|
88
|
+
await Bun.sleep(100);
|
|
89
|
+
const resolved = server.core.sessionGet(session.id);
|
|
90
|
+
expect(resolved.verdict!.feedback).toContain("new Map()");
|
|
91
|
+
expect(resolved.verdict!.feedback).toContain("Map needs an eviction story.");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("plan verbs are guarded in diff sessions", async () => {
|
|
95
|
+
const setup = await renderApp();
|
|
96
|
+
await press(setup, "x");
|
|
97
|
+
expect(setup.captureCharFrame()).toContain("plan-only verb");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier-2 wiring test for the notes-vault export: with [integrations.obsidian]
|
|
3
|
+
* configured to export on resolve, submitting a review writes the note into
|
|
4
|
+
* the vault and surfaces the path in the status line.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, 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 { DaemonServer } from "@cueloop/daemon";
|
|
14
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
15
|
+
import { App } from "./App";
|
|
16
|
+
|
|
17
|
+
const PLAN = `# Migration Plan
|
|
18
|
+
|
|
19
|
+
Move the store atomically.
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
let home: string;
|
|
23
|
+
let vault: string;
|
|
24
|
+
let server: DaemonServer;
|
|
25
|
+
let session: ReviewSession;
|
|
26
|
+
let savedConfigEnv: string | undefined;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-app-export-"));
|
|
30
|
+
vault = join(home, "vault");
|
|
31
|
+
mkdirSync(vault);
|
|
32
|
+
const configPath = join(home, "config.toml");
|
|
33
|
+
writeFileSync(configPath, `[integrations.obsidian]\nvault = ${JSON.stringify(vault)}\nexportOn = "resolve"\n`);
|
|
34
|
+
savedConfigEnv = process.env.CUELOOP_CONFIG;
|
|
35
|
+
process.env.CUELOOP_CONFIG = configPath;
|
|
36
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
37
|
+
server.start();
|
|
38
|
+
session = server.core.sessionCreate({
|
|
39
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
40
|
+
artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
if (savedConfigEnv === undefined) delete process.env.CUELOOP_CONFIG;
|
|
45
|
+
else process.env.CUELOOP_CONFIG = savedConfigEnv;
|
|
46
|
+
server.stop();
|
|
47
|
+
rmSync(home, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("obsidian export on resolve", () => {
|
|
51
|
+
test("submitting a review writes the plan into the vault and shows the path", async () => {
|
|
52
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 32 });
|
|
53
|
+
for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
|
|
54
|
+
await Bun.sleep(25);
|
|
55
|
+
await setup.renderOnce();
|
|
56
|
+
}
|
|
57
|
+
// open the submit overlay, keep the default verdict (approve), submit
|
|
58
|
+
setup.mockInput.pressKey("RETURN");
|
|
59
|
+
await Bun.sleep(15);
|
|
60
|
+
await setup.renderOnce();
|
|
61
|
+
expect(setup.captureCharFrame()).toContain("verdict");
|
|
62
|
+
setup.mockInput.pressKey("RETURN");
|
|
63
|
+
const dir = join(vault, "cueloop");
|
|
64
|
+
for (let i = 0; i < 40 && !existsSync(dir); i++) {
|
|
65
|
+
await Bun.sleep(25);
|
|
66
|
+
await setup.renderOnce();
|
|
67
|
+
}
|
|
68
|
+
await setup.renderOnce();
|
|
69
|
+
|
|
70
|
+
const notes = readdirSync(dir);
|
|
71
|
+
expect(notes.length).toBe(1);
|
|
72
|
+
expect(notes[0]).toMatch(/^\d{4}-\d{2}-\d{2} - Migration Plan\.md$/);
|
|
73
|
+
const written = readFileSync(join(dir, notes[0]!), "utf8");
|
|
74
|
+
expect(written).toContain("source: cueloop");
|
|
75
|
+
expect(written).toContain(`session: ${session.id}`);
|
|
76
|
+
expect(written).toContain("verdict: approve");
|
|
77
|
+
expect(written).toContain("Move the store atomically.");
|
|
78
|
+
expect(setup.captureCharFrame()).toContain("exported to");
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
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
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { mkdtempSync, rmSync } 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 { DaemonServer } from "@cueloop/daemon";
|
|
14
|
+
import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
|
|
15
|
+
import { App } from "./App";
|
|
16
|
+
|
|
17
|
+
const PLAN = `# Migration Plan
|
|
18
|
+
|
|
19
|
+
## Context
|
|
20
|
+
|
|
21
|
+
The daemon persists sessions to disk atomically.
|
|
22
|
+
|
|
23
|
+
## Steps
|
|
24
|
+
|
|
25
|
+
- move the store
|
|
26
|
+
- add recovery
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
let home: string;
|
|
30
|
+
let server: DaemonServer;
|
|
31
|
+
let session: ReviewSession;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-observer-"));
|
|
35
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
36
|
+
server.start();
|
|
37
|
+
session = server.core.sessionCreate({
|
|
38
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
39
|
+
artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
server.stop();
|
|
44
|
+
rmSync(home, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
async function renderObserver() {
|
|
48
|
+
const setup = await testRender(<App home={home} sessionId={session.id} readOnly />, {
|
|
49
|
+
width: 120,
|
|
50
|
+
height: 32,
|
|
51
|
+
});
|
|
52
|
+
for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
|
|
53
|
+
await Bun.sleep(25);
|
|
54
|
+
await setup.renderOnce();
|
|
55
|
+
}
|
|
56
|
+
await setup.renderOnce();
|
|
57
|
+
return setup;
|
|
58
|
+
}
|
|
59
|
+
|
|
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
|
+
/** Session state that any mutating verb would change. */
|
|
70
|
+
function snapshot() {
|
|
71
|
+
const s = server.core.sessionGet(session.id);
|
|
72
|
+
return { annotations: s.annotations.length, workingCopy: s.workingCopy, status: s.status };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe("observer rendering", () => {
|
|
76
|
+
test("shows the observer badge and read-only hint bar", async () => {
|
|
77
|
+
const setup = await renderObserver();
|
|
78
|
+
const frame = setup.captureCharFrame();
|
|
79
|
+
expect(frame).toContain("· observer");
|
|
80
|
+
expect(frame).toContain("observer - read-only · j/k move");
|
|
81
|
+
expect(frame).not.toContain("⏎ submit");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe("observer verbs are blocked", () => {
|
|
86
|
+
for (const [key, verb] of [
|
|
87
|
+
["c", "comment"],
|
|
88
|
+
["s", "suggest"],
|
|
89
|
+
["x", "cut"],
|
|
90
|
+
["e", "edit"],
|
|
91
|
+
["enter", "submit"],
|
|
92
|
+
] as const) {
|
|
93
|
+
test(`${verb} (${key}) answers observer - read-only and mutates nothing`, async () => {
|
|
94
|
+
const setup = await renderObserver();
|
|
95
|
+
const before = snapshot();
|
|
96
|
+
await press(setup, "j");
|
|
97
|
+
await press(setup, "j");
|
|
98
|
+
await press(setup, key);
|
|
99
|
+
await Bun.sleep(60);
|
|
100
|
+
await setup.renderOnce();
|
|
101
|
+
const frame = setup.captureCharFrame();
|
|
102
|
+
expect(frame).toContain("observer - read-only");
|
|
103
|
+
// no overlay opened: compose/submit bars never appear
|
|
104
|
+
expect(frame).not.toContain("COMMENT ON");
|
|
105
|
+
expect(frame).not.toContain("SUGGEST REPLACEMENT");
|
|
106
|
+
expect(frame).not.toContain("verdict ←/→");
|
|
107
|
+
expect(snapshot()).toEqual(before);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
test("span mode c/s is blocked too", async () => {
|
|
112
|
+
const setup = await renderObserver();
|
|
113
|
+
await press(setup, "j");
|
|
114
|
+
await press(setup, "j");
|
|
115
|
+
await press(setup, "v");
|
|
116
|
+
await press(setup, "c");
|
|
117
|
+
await setup.renderOnce();
|
|
118
|
+
const frame = setup.captureCharFrame();
|
|
119
|
+
expect(frame).toContain("observer - read-only");
|
|
120
|
+
expect(frame).not.toContain("COMMENT ON");
|
|
121
|
+
expect(snapshot().annotations).toBe(0);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("observer navigation still works", () => {
|
|
126
|
+
test("j/k moves the cursor between blocks", async () => {
|
|
127
|
+
const setup = await renderObserver();
|
|
128
|
+
await press(setup, "j");
|
|
129
|
+
await press(setup, "j");
|
|
130
|
+
await setup.renderOnce();
|
|
131
|
+
const lines = setup.captureCharFrame().split("\n");
|
|
132
|
+
const cursorLine = lines.find((l) => l.includes("▎"))!;
|
|
133
|
+
expect(cursorLine).toContain("persists sessions");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("n focuses annotations made by the controller", async () => {
|
|
137
|
+
server.core.sessionAnnotate(session.id, {
|
|
138
|
+
id: "a_obs1",
|
|
139
|
+
kind: "comment",
|
|
140
|
+
anchor: makeAnchor(parseBlocks(PLAN), 2, 0, 10),
|
|
141
|
+
body: "From the controller.",
|
|
142
|
+
});
|
|
143
|
+
const setup = await renderObserver();
|
|
144
|
+
await press(setup, "n");
|
|
145
|
+
await setup.renderOnce();
|
|
146
|
+
expect(setup.captureCharFrame()).toContain("▸ COMMENT");
|
|
147
|
+
});
|
|
148
|
+
});
|
package/src/App.test.tsx
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
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
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
+
import { mkdtempSync, rmSync } 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 { DaemonServer } from "@cueloop/daemon";
|
|
14
|
+
import type { ReviewSession } from "@cueloop/schema";
|
|
15
|
+
import { App } from "./App";
|
|
16
|
+
|
|
17
|
+
const PLAN = `# Migration Plan
|
|
18
|
+
|
|
19
|
+
## Context
|
|
20
|
+
|
|
21
|
+
The daemon persists sessions to disk atomically.
|
|
22
|
+
|
|
23
|
+
## Steps
|
|
24
|
+
|
|
25
|
+
- move the store
|
|
26
|
+
- add recovery
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
let home: string;
|
|
30
|
+
let server: DaemonServer;
|
|
31
|
+
let session: ReviewSession;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
home = mkdtempSync(join(tmpdir(), "cueloop-app-"));
|
|
35
|
+
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
36
|
+
server.start();
|
|
37
|
+
session = server.core.sessionCreate({
|
|
38
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
39
|
+
artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
server.stop();
|
|
44
|
+
rmSync(home, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
async function renderApp(sessionId?: string) {
|
|
48
|
+
const setup = await testRender(<App home={home} sessionId={sessionId ?? session.id} />, {
|
|
49
|
+
width: 120,
|
|
50
|
+
height: 32,
|
|
51
|
+
});
|
|
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
|
+
|
|
61
|
+
type Setup = Awaited<ReturnType<typeof renderApp>>;
|
|
62
|
+
|
|
63
|
+
/** Drive one key press: letters type as text; named keys use KeyCodes ids. */
|
|
64
|
+
async function press(setup: Setup, k: string): Promise<void> {
|
|
65
|
+
if (k === "enter") setup.mockInput.pressKey("RETURN");
|
|
66
|
+
else if (k === "escape") setup.mockInput.pressKey("ESCAPE");
|
|
67
|
+
else if (k === "backspace") setup.mockInput.pressKey("BACKSPACE");
|
|
68
|
+
else if (k === "left") setup.mockInput.pressKey("ARROW_LEFT");
|
|
69
|
+
else if (k === "right") setup.mockInput.pressKey("ARROW_RIGHT");
|
|
70
|
+
else await type(setup, k);
|
|
71
|
+
await Bun.sleep(15);
|
|
72
|
+
await setup.renderOnce();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function type(setup: Setup, text: string): Promise<void> {
|
|
76
|
+
await setup.mockInput.typeText(text);
|
|
77
|
+
await Bun.sleep(15);
|
|
78
|
+
await setup.renderOnce();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("plan rendering", () => {
|
|
82
|
+
test("renders the plan with headings, list markers, and the rail", async () => {
|
|
83
|
+
const setup = await renderApp();
|
|
84
|
+
const frame = setup.captureCharFrame();
|
|
85
|
+
expect(frame).toContain("Migration Plan");
|
|
86
|
+
expect(frame).toContain("Context");
|
|
87
|
+
expect(frame).toContain("persists sessions to disk atomically");
|
|
88
|
+
expect(frame).toContain("- move the store");
|
|
89
|
+
expect(frame).toContain("REVIEW (0)");
|
|
90
|
+
expect(frame).toContain("Submit review (0)");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("keyboard grammar", () => {
|
|
95
|
+
test("j/k moves the cursor glyph between blocks", async () => {
|
|
96
|
+
const setup = await renderApp();
|
|
97
|
+
await press(setup, "j");
|
|
98
|
+
await press(setup, "j");
|
|
99
|
+
await setup.renderOnce();
|
|
100
|
+
const lines = setup.captureCharFrame().split("\n");
|
|
101
|
+
const cursorLine = lines.find((l) => l.includes("▎"))!;
|
|
102
|
+
expect(cursorLine).toContain("persists sessions");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("comment flow: c types a body, ⏎ saves to the daemon", async () => {
|
|
106
|
+
const setup = await renderApp();
|
|
107
|
+
await press(setup, "j");
|
|
108
|
+
await press(setup, "j");
|
|
109
|
+
await press(setup, "c");
|
|
110
|
+
await setup.renderOnce();
|
|
111
|
+
expect(setup.captureCharFrame()).toContain("COMMENT ON");
|
|
112
|
+
await type(setup, "Define atomically.");
|
|
113
|
+
await press(setup, "enter");
|
|
114
|
+
await Bun.sleep(80);
|
|
115
|
+
await setup.renderOnce();
|
|
116
|
+
const stored = server.core.sessionGet(session.id);
|
|
117
|
+
expect(stored.annotations.length).toBe(1);
|
|
118
|
+
expect(stored.annotations[0]!.body).toBe("Define atomically.");
|
|
119
|
+
expect(setup.captureCharFrame()).toContain("COMMENT");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("span mode: v + l selects words, c anchors the exact span", async () => {
|
|
123
|
+
const setup = await renderApp();
|
|
124
|
+
await press(setup, "j");
|
|
125
|
+
await press(setup, "j");
|
|
126
|
+
await press(setup, "v");
|
|
127
|
+
await press(setup, "l"); // "The daemon"
|
|
128
|
+
await press(setup, "c");
|
|
129
|
+
await type(setup, "Which daemon?");
|
|
130
|
+
await press(setup, "enter");
|
|
131
|
+
await Bun.sleep(80);
|
|
132
|
+
const stored = server.core.sessionGet(session.id);
|
|
133
|
+
expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("x cuts a block into the working copy; x restores it", async () => {
|
|
137
|
+
const setup = await renderApp();
|
|
138
|
+
// move to "- move the store" (h1, h2, p, h2 = 4 steps in)
|
|
139
|
+
for (let i = 0; i < 4; i++) await press(setup, "j");
|
|
140
|
+
await press(setup, "x");
|
|
141
|
+
await Bun.sleep(80);
|
|
142
|
+
await setup.renderOnce();
|
|
143
|
+
expect(server.core.sessionGet(session.id).workingCopy).not.toContain("move the store");
|
|
144
|
+
expect(setup.captureCharFrame()).toContain("[cut]");
|
|
145
|
+
await press(setup, "x");
|
|
146
|
+
await Bun.sleep(80);
|
|
147
|
+
await setup.renderOnce();
|
|
148
|
+
expect(server.core.sessionGet(session.id).workingCopy).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("e runs $EDITOR on the working copy and tracks the diff", async () => {
|
|
152
|
+
const script = join(home, "fake-editor.sh");
|
|
153
|
+
await Bun.write(script, `#!/bin/sh\nsed -i '' 's/atomically/very atomically/' "$1"\n`);
|
|
154
|
+
Bun.spawnSync(["chmod", "+x", script]);
|
|
155
|
+
process.env.CUELOOP_EDITOR = script;
|
|
156
|
+
try {
|
|
157
|
+
const setup = await renderApp();
|
|
158
|
+
await press(setup, "e");
|
|
159
|
+
await Bun.sleep(150);
|
|
160
|
+
await setup.renderOnce();
|
|
161
|
+
expect(server.core.sessionGet(session.id).workingCopy).toContain("very atomically");
|
|
162
|
+
expect(setup.captureCharFrame()).toContain("[edited]");
|
|
163
|
+
} finally {
|
|
164
|
+
delete process.env.CUELOOP_EDITOR;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("submit", () => {
|
|
170
|
+
test("⏎ opens the verdict bar; verdict + summary resolve the session", async () => {
|
|
171
|
+
const setup = await renderApp();
|
|
172
|
+
await press(setup, "j");
|
|
173
|
+
await press(setup, "j");
|
|
174
|
+
await press(setup, "c");
|
|
175
|
+
await type(setup, "Needs a phase list.");
|
|
176
|
+
await press(setup, "enter");
|
|
177
|
+
await Bun.sleep(80);
|
|
178
|
+
await press(setup, "enter"); // open submit
|
|
179
|
+
await setup.renderOnce();
|
|
180
|
+
expect(setup.captureCharFrame()).toContain("[Request changes]");
|
|
181
|
+
await type(setup, "Expand the steps.");
|
|
182
|
+
await press(setup, "enter");
|
|
183
|
+
await Bun.sleep(120);
|
|
184
|
+
await setup.renderOnce();
|
|
185
|
+
const stored = server.core.sessionGet(session.id);
|
|
186
|
+
expect(stored.status).toBe("resolved");
|
|
187
|
+
expect(stored.verdict!.kind).toBe("request_changes");
|
|
188
|
+
expect(stored.verdict!.feedback).toContain("Needs a phase list.");
|
|
189
|
+
expect(setup.captureCharFrame()).toContain("resolved: request changes");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("approve via ←/→ verdict cycling", async () => {
|
|
193
|
+
const setup = await renderApp();
|
|
194
|
+
await press(setup, "enter");
|
|
195
|
+
await setup.renderOnce();
|
|
196
|
+
expect(setup.captureCharFrame()).toContain("[Approve]"); // no pending items → approve default
|
|
197
|
+
await press(setup, "enter");
|
|
198
|
+
await Bun.sleep(120);
|
|
199
|
+
expect(server.core.sessionGet(session.id).verdict!.kind).toBe("approve");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("inbox", () => {
|
|
204
|
+
test("inbox mode renders and opens a session", async () => {
|
|
205
|
+
const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
|
|
206
|
+
for (let i = 0; i < 40 && !setup.captureCharFrame().includes("inbox"); i++) {
|
|
207
|
+
await Bun.sleep(25);
|
|
208
|
+
await setup.renderOnce();
|
|
209
|
+
}
|
|
210
|
+
expect(setup.captureCharFrame()).toContain("inbox (1 pending)");
|
|
211
|
+
expect(setup.captureCharFrame()).toContain("Migration Plan");
|
|
212
|
+
await press(setup, "enter");
|
|
213
|
+
await Bun.sleep(80);
|
|
214
|
+
await setup.renderOnce();
|
|
215
|
+
expect(setup.captureCharFrame()).toContain("Submit review (0)");
|
|
216
|
+
});
|
|
217
|
+
});
|
package/src/App.tsx
ADDED
|
Binary file
|