@cueloop/client 0.1.0-alpha.33 → 0.1.0-alpha.35
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 +27 -26
- package/src/App.autoclose.test.tsx +7 -4
- package/src/App.diff.test.tsx +130 -4
- package/src/App.export.test.tsx +22 -4
- package/src/App.plan-review.test.tsx +127 -27
- package/src/App.readonly.test.tsx +31 -8
- package/src/App.rendering.test.tsx +12 -3
- package/src/App.review-panel.test.tsx +11 -3
- package/src/App.submit-confirm.test.tsx +12 -7
- package/src/App.test.tsx +46 -9
- package/src/App.toast.test.tsx +10 -3
- package/src/App.tsx +400 -100
- package/src/App.walk.test.tsx +30 -10
- package/src/attribution.ts +8 -2
- package/src/clipboard.ts +5 -1
- package/src/components/AnnotationCard.stories.tsx +28 -9
- package/src/components/AnnotationCard.tsx +53 -38
- package/src/components/Breadcrumb.tsx +7 -1
- package/src/components/CodeBlock.stories.tsx +19 -2
- package/src/components/CodeBlock.tsx +24 -9
- package/src/components/CompletionOverlay.tsx +13 -3
- package/src/components/ConfirmCard.stories.tsx +22 -3
- package/src/components/ConfirmCard.tsx +22 -5
- package/src/components/ConfirmDialog.tsx +9 -1
- package/src/components/DiffSheet.scroll.test.tsx +123 -0
- package/src/components/DiffSheet.stories.tsx +18 -2
- package/src/components/DiffSheet.syntax.test.tsx +62 -0
- package/src/components/DiffSheet.tsx +184 -98
- package/src/components/InboxList.stories.tsx +16 -3
- package/src/components/InboxList.tsx +17 -10
- package/src/components/KeybindsDialog.tsx +19 -3
- package/src/components/MarkerPopover.stories.tsx +58 -0
- package/src/components/MarkerPopover.tsx +102 -0
- package/src/components/MenuBar.stories.tsx +5 -1
- package/src/components/MenuBar.tsx +18 -2
- package/src/components/PlanSheet.stories.tsx +34 -2
- package/src/components/PlanSheet.tsx +141 -32
- package/src/components/PromptDialog.tsx +18 -2
- package/src/components/ReviewPanel.stories.tsx +13 -3
- package/src/components/ReviewPanel.tsx +8 -4
- package/src/components/ReviewRail.stories.tsx +145 -72
- package/src/components/ReviewRail.tsx +177 -38
- package/src/components/SettingsDialog.stories.tsx +51 -3
- package/src/components/SettingsDialog.tsx +8 -3
- package/src/components/SettingsRows.tsx +19 -3
- package/src/components/Toast.tsx +17 -2
- package/src/components/WalkWizard.stories.tsx +13 -2
- package/src/components/WalkWizard.tsx +9 -3
- package/src/components/__snapshots__/stories.test.tsx.snap +281 -83
- package/src/components/diff-sheet-layout.test.ts +127 -0
- package/src/components/diff-sheet-layout.ts +167 -0
- package/src/components/primitives/Button.tsx +5 -1
- package/src/components/primitives/Dialog.tsx +9 -1
- package/src/components/primitives/Tabs.tsx +24 -4
- package/src/components/stories-app.tsx +34 -6
- package/src/components/story-fixtures.ts +11 -3
- package/src/components/story.ts +3 -1
- package/src/components/syntax-highlight.ts +47 -12
- package/src/components/theme-context.stories.tsx +6 -1
- package/src/components/theme-context.tsx +7 -1
- package/src/config.test.ts +178 -2
- package/src/config.ts +121 -23
- 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 +13 -4
- package/src/editor.ts +31 -5
- package/src/herdr.ts +8 -2
- package/src/index.ts +9 -1
- package/src/integrations.ts +3 -1
- package/src/intent-dispatch.test.ts +193 -9
- package/src/intent-dispatch.ts +151 -18
- package/src/key-bindings.test.ts +19 -9
- package/src/key-bindings.ts +36 -14
- package/src/keymap.test.ts +171 -26
- package/src/keymap.ts +83 -16
- package/src/review-panel.test.ts +3 -1
- package/src/review-panel.ts +9 -2
- package/src/serve.test.ts +11 -3
- package/src/session-controller.curate.test.ts +253 -0
- package/src/session-controller.share.test.ts +65 -17
- package/src/session-controller.ts +301 -26
- package/src/share.ts +41 -8
- package/src/test-support.ts +16 -6
- package/src/theme-presets.ts +168 -0
- package/src/theme.ts +1 -3
- package/src/view-diff.ts +15 -4
- package/src/view-plan.test.ts +40 -6
- package/src/view-plan.ts +71 -28
- package/src/walk.test.ts +7 -2
- package/src/walk.ts +7 -2
package/package.json
CHANGED
|
@@ -1,46 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.35",
|
|
4
|
+
"description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
|
|
5
|
+
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/mmurakaru/cueloop/issues"
|
|
8
|
+
},
|
|
4
9
|
"license": "Apache-2.0",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
13
|
+
"directory": "packages/client"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
5
19
|
"type": "module",
|
|
6
20
|
"exports": {
|
|
7
21
|
".": "./src/index.ts"
|
|
8
22
|
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
9
26
|
"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.
|
|
27
|
+
"@cueloop/daemon": "0.1.0-alpha.35",
|
|
28
|
+
"@cueloop/extension-api": "0.1.0-alpha.35",
|
|
29
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.35",
|
|
30
|
+
"@cueloop/schema": "0.1.0-alpha.35",
|
|
14
31
|
"@opentui/core": "^0.5.2",
|
|
15
32
|
"@opentui/keymap": "0.5.2",
|
|
16
33
|
"@opentui/react": "^0.5.2",
|
|
17
34
|
"@opentui/ssh": "0.5.2",
|
|
18
35
|
"@pierre/diffs": "^1.3.5",
|
|
36
|
+
"diff": "^8",
|
|
19
37
|
"react": "^19.2.0"
|
|
20
38
|
},
|
|
21
39
|
"devDependencies": {
|
|
22
40
|
"@types/react": "^19.2.0",
|
|
23
|
-
"ssh2": "^1.
|
|
24
|
-
"
|
|
41
|
+
"@types/ssh2": "^1.15.5",
|
|
42
|
+
"ssh2": "^1.17.0"
|
|
25
43
|
},
|
|
26
|
-
"publishConfig": {
|
|
27
|
-
"access": "public"
|
|
28
|
-
},
|
|
29
|
-
"files": [
|
|
30
|
-
"src",
|
|
31
|
-
"README.md"
|
|
32
|
-
],
|
|
33
44
|
"engines": {
|
|
34
45
|
"bun": ">=1.3.0"
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
"type": "git",
|
|
38
|
-
"url": "git+https://github.com/mmurakaru/cueloop.git",
|
|
39
|
-
"directory": "packages/client"
|
|
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
|
-
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -40,10 +40,13 @@ afterEach(() => {
|
|
|
40
40
|
});
|
|
41
41
|
|
|
42
42
|
async function renderApp(onExit?: (code: number) => void) {
|
|
43
|
-
const setup = await testRender(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
const setup = await testRender(
|
|
44
|
+
<App home={home} sessionId={session.id} onExit={onExit} clock={clock} />,
|
|
45
|
+
{
|
|
46
|
+
width: 120,
|
|
47
|
+
height: 30,
|
|
48
|
+
},
|
|
49
|
+
);
|
|
47
50
|
await waitForText(setup, "cueloop");
|
|
48
51
|
return setup;
|
|
49
52
|
}
|
package/src/App.diff.test.tsx
CHANGED
|
@@ -44,7 +44,10 @@ afterEach(() => {
|
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
async function renderApp() {
|
|
47
|
-
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
47
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
48
|
+
width: 120,
|
|
49
|
+
height: 30,
|
|
50
|
+
});
|
|
48
51
|
await waitForText(setup, "cueloop");
|
|
49
52
|
return setup;
|
|
50
53
|
}
|
|
@@ -96,14 +99,137 @@ describe("diff review", () => {
|
|
|
96
99
|
expect(resolved.verdict!.feedback).toContain("Map needs an eviction story.");
|
|
97
100
|
});
|
|
98
101
|
|
|
99
|
-
test("
|
|
100
|
-
// Arrange
|
|
102
|
+
test("curation needs full file contents; a legacy diff answers", async () => {
|
|
103
|
+
// Arrange - the session carries no artifact.files
|
|
101
104
|
const setup = await renderApp();
|
|
102
105
|
|
|
103
106
|
// Act
|
|
104
107
|
await press(setup, "x");
|
|
105
108
|
|
|
106
109
|
// Assert
|
|
107
|
-
await waitForText(setup, "
|
|
110
|
+
await waitForText(setup, "hunk curation needs full file contents");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("rejecting the only change empties the curated working copy", async () => {
|
|
114
|
+
// Arrange - a diff session that carries full file contents
|
|
115
|
+
const withFiles = server.core.sessionCreate({
|
|
116
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
117
|
+
artifact: {
|
|
118
|
+
type: "diff",
|
|
119
|
+
content: PATCH,
|
|
120
|
+
meta: { title: "working tree" },
|
|
121
|
+
files: [
|
|
122
|
+
{
|
|
123
|
+
path: "src/store.ts",
|
|
124
|
+
oldContents: "export class Store {\n private items = [];\n}\n",
|
|
125
|
+
newContents: "export class Store {\n private items = new Map();\n}\n",
|
|
126
|
+
status: "modified",
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
const setup = await testRender(<App home={home} sessionId={withFiles.id} />, {
|
|
132
|
+
width: 120,
|
|
133
|
+
height: 30,
|
|
134
|
+
});
|
|
135
|
+
await waitForText(setup, "cueloop");
|
|
136
|
+
|
|
137
|
+
// Act - move to the added line and reject its change
|
|
138
|
+
// rows: file(0), hunk(1), ctx(2), del(3), add(4)
|
|
139
|
+
for (let i = 0; i < 4; i++) await press(setup, "j");
|
|
140
|
+
await press(setup, "x");
|
|
141
|
+
|
|
142
|
+
// Assert - the single change is gone, so the curated working copy is empty
|
|
143
|
+
await waitForText(setup, "change rejected");
|
|
144
|
+
const stored = server.core.sessionGet(withFiles.id);
|
|
145
|
+
expect(stored.workingCopy).toBe("");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("a rejected change lists in the rail and undo (u) restores it", async () => {
|
|
149
|
+
// Arrange - a diff session that carries full file contents
|
|
150
|
+
const withFiles = server.core.sessionCreate({
|
|
151
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
152
|
+
artifact: {
|
|
153
|
+
type: "diff",
|
|
154
|
+
content: PATCH,
|
|
155
|
+
meta: { title: "working tree" },
|
|
156
|
+
files: [
|
|
157
|
+
{
|
|
158
|
+
path: "src/store.ts",
|
|
159
|
+
oldContents: "export class Store {\n private items = [];\n}\n",
|
|
160
|
+
newContents: "export class Store {\n private items = new Map();\n}\n",
|
|
161
|
+
status: "modified",
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
const setup = await testRender(<App home={home} sessionId={withFiles.id} />, {
|
|
167
|
+
width: 120,
|
|
168
|
+
height: 30,
|
|
169
|
+
});
|
|
170
|
+
await waitForText(setup, "cueloop");
|
|
171
|
+
|
|
172
|
+
// Act - reject the change under the added line
|
|
173
|
+
// rows: file(0), hunk(1), ctx(2), del(3), add(4)
|
|
174
|
+
for (let i = 0; i < 4; i++) await press(setup, "j");
|
|
175
|
+
await press(setup, "x");
|
|
176
|
+
|
|
177
|
+
// Assert - the rail shows a removal card titled REJECT · me for the change
|
|
178
|
+
await waitForText(setup, "REJECT · me");
|
|
179
|
+
expect(server.core.sessionGet(withFiles.id).workingCopy).toBe("");
|
|
180
|
+
|
|
181
|
+
// Act - undo restores it (no selection, so the last item)
|
|
182
|
+
await press(setup, "u");
|
|
183
|
+
|
|
184
|
+
// Assert - the change returns to the curated diff (working copy cleared)
|
|
185
|
+
await waitForText(setup, "removal restored");
|
|
186
|
+
expect(server.core.sessionGet(withFiles.id).workingCopy).toBeUndefined();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("a selected removal card shows an undo button that restores it on click", async () => {
|
|
190
|
+
// Arrange - a diff session that carries full file contents
|
|
191
|
+
const withFiles = server.core.sessionCreate({
|
|
192
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
193
|
+
artifact: {
|
|
194
|
+
type: "diff",
|
|
195
|
+
content: PATCH,
|
|
196
|
+
meta: { title: "working tree" },
|
|
197
|
+
files: [
|
|
198
|
+
{
|
|
199
|
+
path: "src/store.ts",
|
|
200
|
+
oldContents: "export class Store {\n private items = [];\n}\n",
|
|
201
|
+
newContents: "export class Store {\n private items = new Map();\n}\n",
|
|
202
|
+
status: "modified",
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
const setup = await testRender(<App home={home} sessionId={withFiles.id} />, {
|
|
208
|
+
width: 120,
|
|
209
|
+
height: 30,
|
|
210
|
+
});
|
|
211
|
+
await waitForText(setup, "cueloop");
|
|
212
|
+
|
|
213
|
+
// Act - reject the change; the removal card lands unselected (no undo button)
|
|
214
|
+
for (let i = 0; i < 4; i++) await press(setup, "j");
|
|
215
|
+
await press(setup, "x");
|
|
216
|
+
await waitForText(setup, "REJECT · me");
|
|
217
|
+
expect(setup.captureCharFrame()).not.toContain("undo");
|
|
218
|
+
|
|
219
|
+
// Act - click the card to select it; the undo button appears
|
|
220
|
+
const clickText = async (needle: string): Promise<void> => {
|
|
221
|
+
const lines = setup.captureCharFrame().split("\n");
|
|
222
|
+
const row = lines.findIndex((line) => line.includes(needle));
|
|
223
|
+
await setup.mockMouse.click(lines[row]!.indexOf(needle) + 1, row);
|
|
224
|
+
};
|
|
225
|
+
await clickText("REJECT · me");
|
|
226
|
+
await waitForText(setup, "undo");
|
|
227
|
+
|
|
228
|
+
// Act - click the undo button
|
|
229
|
+
await clickText("undo");
|
|
230
|
+
|
|
231
|
+
// Assert - the change returns to the curated diff (working copy cleared)
|
|
232
|
+
await waitForText(setup, "removal restored");
|
|
233
|
+
expect(server.core.sessionGet(withFiles.id).workingCopy).toBeUndefined();
|
|
108
234
|
});
|
|
109
235
|
});
|
package/src/App.export.test.tsx
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
/** Tier-2 wiring for the notes-vault export: with [integrations.obsidian] set to export on resolve, submitting a review writes the note into the vault and surfaces the path in the status line. */
|
|
2
2
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
rmSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
} from "node:fs";
|
|
5
13
|
import { tmpdir } from "node:os";
|
|
6
14
|
import { join } from "node:path";
|
|
7
15
|
import React from "react";
|
|
@@ -27,13 +35,20 @@ beforeEach(() => {
|
|
|
27
35
|
vault = join(home, "vault");
|
|
28
36
|
mkdirSync(vault);
|
|
29
37
|
const configPath = join(home, "config.toml");
|
|
30
|
-
writeFileSync(
|
|
38
|
+
writeFileSync(
|
|
39
|
+
configPath,
|
|
40
|
+
`[integrations.obsidian]\nvault = ${JSON.stringify(vault)}\nexportOn = "resolve"\n`,
|
|
41
|
+
);
|
|
31
42
|
restoreUserConfig = isolateUserConfig(home, "config.toml");
|
|
32
43
|
server = new DaemonServer({ home, idleExitMs: 0 });
|
|
33
44
|
server.start();
|
|
34
45
|
session = server.core.sessionCreate({
|
|
35
46
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
36
|
-
artifact: {
|
|
47
|
+
artifact: {
|
|
48
|
+
type: "plan",
|
|
49
|
+
content: PLAN,
|
|
50
|
+
meta: { title: "Migration Plan", planPath: "plan.md" },
|
|
51
|
+
},
|
|
37
52
|
});
|
|
38
53
|
});
|
|
39
54
|
afterEach(() => {
|
|
@@ -45,7 +60,10 @@ afterEach(() => {
|
|
|
45
60
|
describe("obsidian export on resolve", () => {
|
|
46
61
|
test("submitting a review writes the plan into the vault and shows the path", async () => {
|
|
47
62
|
// Arrange
|
|
48
|
-
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
63
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
64
|
+
width: 120,
|
|
65
|
+
height: 32,
|
|
66
|
+
});
|
|
49
67
|
await waitForText(setup, "cueloop");
|
|
50
68
|
|
|
51
69
|
// Act
|
|
@@ -9,8 +9,16 @@ 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 { DARK
|
|
13
|
-
import {
|
|
12
|
+
import { DARK } from "./theme";
|
|
13
|
+
import {
|
|
14
|
+
isolateUserConfig,
|
|
15
|
+
press,
|
|
16
|
+
settle,
|
|
17
|
+
typeText as type,
|
|
18
|
+
waitForState,
|
|
19
|
+
waitForText,
|
|
20
|
+
waitForTextGone,
|
|
21
|
+
} from "./test-support";
|
|
14
22
|
|
|
15
23
|
const PLAN = `# Migration Plan
|
|
16
24
|
|
|
@@ -36,7 +44,11 @@ beforeEach(() => {
|
|
|
36
44
|
server.start();
|
|
37
45
|
session = server.core.sessionCreate({
|
|
38
46
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
39
|
-
artifact: {
|
|
47
|
+
artifact: {
|
|
48
|
+
type: "plan",
|
|
49
|
+
content: PLAN,
|
|
50
|
+
meta: { title: "Migration Plan", planPath: "plan.md", agent: "agent/worker-3" },
|
|
51
|
+
},
|
|
40
52
|
});
|
|
41
53
|
});
|
|
42
54
|
afterEach(() => {
|
|
@@ -66,12 +78,28 @@ function backgroundsOf(setup: Setup, needle: string): string[] {
|
|
|
66
78
|
for (const span of line.spans) {
|
|
67
79
|
if (!span.text.includes(needle)) continue;
|
|
68
80
|
const [red, green, blue] = span.bg.toInts();
|
|
69
|
-
backgrounds.push(
|
|
81
|
+
backgrounds.push(
|
|
82
|
+
"#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""),
|
|
83
|
+
);
|
|
70
84
|
}
|
|
71
85
|
}
|
|
72
86
|
return backgrounds;
|
|
73
87
|
}
|
|
74
88
|
|
|
89
|
+
/** Whether any border character (box-drawing) is painted in the given foreground hex. */
|
|
90
|
+
function hasBorderColor(setup: Setup, hex: string): boolean {
|
|
91
|
+
for (const line of setup.captureSpans().lines) {
|
|
92
|
+
for (const span of line.spans) {
|
|
93
|
+
if (!/[╭╮╰╯│─┌┐└┘]/.test(span.text)) continue;
|
|
94
|
+
const [red, green, blue] = span.fg.toInts();
|
|
95
|
+
const rendered =
|
|
96
|
+
"#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
|
|
97
|
+
if (rendered === hex) return true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
75
103
|
/** Move the cursor to "The daemon persists sessions to disk atomically." */
|
|
76
104
|
async function toContextParagraph(setup: Setup): Promise<void> {
|
|
77
105
|
await press(setup, "j");
|
|
@@ -85,14 +113,20 @@ describe("share button", () => {
|
|
|
85
113
|
|
|
86
114
|
// Assert
|
|
87
115
|
// both word-buttons ride the same header row, so Share sits next to Edit
|
|
88
|
-
const headerLine = setup
|
|
116
|
+
const headerLine = setup
|
|
117
|
+
.captureCharFrame()
|
|
118
|
+
.split("\n")
|
|
119
|
+
.find((line) => line.includes("submitted by"));
|
|
89
120
|
expect(headerLine).toContain("Edit");
|
|
90
121
|
expect(headerLine).toContain("Share");
|
|
91
122
|
});
|
|
92
123
|
|
|
93
124
|
test("a read-only viewer (a plan shared over ssh) never sees the Share button", async () => {
|
|
94
125
|
// Arrange / Act
|
|
95
|
-
const viewer = await testRender(<App home={home} sessionId={session.id} readOnly />, {
|
|
126
|
+
const viewer = await testRender(<App home={home} sessionId={session.id} readOnly />, {
|
|
127
|
+
width: 120,
|
|
128
|
+
height: 32,
|
|
129
|
+
});
|
|
96
130
|
await waitForText(viewer, "cueloop");
|
|
97
131
|
|
|
98
132
|
// Assert
|
|
@@ -108,7 +142,10 @@ describe("share button", () => {
|
|
|
108
142
|
await waitForText(setup, "submitted by");
|
|
109
143
|
|
|
110
144
|
// Assert - the owner toolbar is gone once the review is resolved
|
|
111
|
-
const headerLine = setup
|
|
145
|
+
const headerLine = setup
|
|
146
|
+
.captureCharFrame()
|
|
147
|
+
.split("\n")
|
|
148
|
+
.find((line) => line.includes("submitted by"));
|
|
112
149
|
expect(headerLine).not.toContain("Edit");
|
|
113
150
|
expect(headerLine).not.toContain("Share");
|
|
114
151
|
});
|
|
@@ -125,7 +162,10 @@ describe("edit affordance", () => {
|
|
|
125
162
|
|
|
126
163
|
test("a read-only viewer (a plan shared over ssh) never sees the Edit button", async () => {
|
|
127
164
|
// Arrange / Act
|
|
128
|
-
const viewer = await testRender(<App home={home} sessionId={session.id} readOnly />, {
|
|
165
|
+
const viewer = await testRender(<App home={home} sessionId={session.id} readOnly />, {
|
|
166
|
+
width: 120,
|
|
167
|
+
height: 32,
|
|
168
|
+
});
|
|
129
169
|
await waitForText(viewer, "cueloop");
|
|
130
170
|
|
|
131
171
|
// Assert
|
|
@@ -138,7 +178,9 @@ describe("attribution", () => {
|
|
|
138
178
|
// Arrange - a pulled collaborator note carries an author fingerprint
|
|
139
179
|
const { makeAnchor, parseBlocks } = await import("@cueloop/schema");
|
|
140
180
|
const blocks = parseBlocks(PLAN);
|
|
141
|
-
const contextBlockIndex = blocks.findIndex((block) =>
|
|
181
|
+
const contextBlockIndex = blocks.findIndex((block) =>
|
|
182
|
+
block.text.startsWith("The daemon persists"),
|
|
183
|
+
);
|
|
142
184
|
server.core.sessionAnnotate(session.id, {
|
|
143
185
|
id: "collab-1",
|
|
144
186
|
kind: "comment",
|
|
@@ -222,9 +264,9 @@ describe("inline compose keeps the anchor painted", () => {
|
|
|
222
264
|
// compose open: the anchor is painted selection-style. The box can render
|
|
223
265
|
// a frame before the anchor repaint settles, so wait on the color itself.
|
|
224
266
|
await waitForText(setup, "Save");
|
|
225
|
-
expect(setup.captureCharFrame()).toContain("Cancel
|
|
226
|
-
await waitForState(setup, () => backgroundsOf(setup, "The daemon").includes(
|
|
227
|
-
expect(backgroundsOf(setup, "The daemon")).toContain(
|
|
267
|
+
expect(setup.captureCharFrame()).toContain("Cancel");
|
|
268
|
+
await waitForState(setup, () => backgroundsOf(setup, "The daemon").includes(DARK.accent));
|
|
269
|
+
expect(backgroundsOf(setup, "The daemon")).toContain(DARK.accent);
|
|
228
270
|
|
|
229
271
|
// Act
|
|
230
272
|
// cancel un-paints (a bare ESC settles after the parser's escape window)
|
|
@@ -232,7 +274,7 @@ describe("inline compose keeps the anchor painted", () => {
|
|
|
232
274
|
|
|
233
275
|
// Assert
|
|
234
276
|
await waitForTextGone(setup, 'comment on "');
|
|
235
|
-
expect(backgroundsOf(setup, "The daemon")).not.toContain(
|
|
277
|
+
expect(backgroundsOf(setup, "The daemon")).not.toContain(DARK.accent);
|
|
236
278
|
|
|
237
279
|
// Act
|
|
238
280
|
// save converts the paint to the kind-colored annotation highlight
|
|
@@ -246,7 +288,9 @@ describe("inline compose keeps the anchor painted", () => {
|
|
|
246
288
|
// the whole cursor block is the anchor here; check the highlight landed
|
|
247
289
|
const stored = server.core.sessionGet(session.id);
|
|
248
290
|
expect(stored.annotations.length).toBe(1);
|
|
249
|
-
expect(backgroundsOf(setup, stored.annotations[0]!.anchor.quote.slice(0, 20))).toContain(
|
|
291
|
+
expect(backgroundsOf(setup, stored.annotations[0]!.anchor.quote.slice(0, 20))).toContain(
|
|
292
|
+
DARK.markCommentBackground,
|
|
293
|
+
);
|
|
250
294
|
// renderApp plus this many frame-waits grazes the 5s default on a loaded CI
|
|
251
295
|
// runner, and the whole-suite publish lane has timed even 15s out; give the
|
|
252
296
|
// heaviest frame-wait chain generous headroom so runner load cannot flake it.
|
|
@@ -267,7 +311,10 @@ describe("compose newline convention", () => {
|
|
|
267
311
|
return setup;
|
|
268
312
|
}
|
|
269
313
|
|
|
270
|
-
async function pressReturnWith(
|
|
314
|
+
async function pressReturnWith(
|
|
315
|
+
setup: Setup,
|
|
316
|
+
modifiers: { shift?: boolean; meta?: boolean },
|
|
317
|
+
): Promise<void> {
|
|
271
318
|
setup.mockInput.pressKey("RETURN", modifiers);
|
|
272
319
|
await settle(setup);
|
|
273
320
|
}
|
|
@@ -345,7 +392,7 @@ describe("the document selects, the rail edits", () => {
|
|
|
345
392
|
await press(setup, "c");
|
|
346
393
|
await type(setup, "Needs a citation.");
|
|
347
394
|
await press(setup, "enter");
|
|
348
|
-
await waitForText(setup, "COMMENT ·
|
|
395
|
+
await waitForText(setup, "COMMENT · me");
|
|
349
396
|
|
|
350
397
|
// Act
|
|
351
398
|
// the saved card is selected; e turns its body into an input in place
|
|
@@ -356,7 +403,8 @@ describe("the document selects, the rail edits", () => {
|
|
|
356
403
|
// Assert
|
|
357
404
|
await waitForState(
|
|
358
405
|
setup,
|
|
359
|
-
() =>
|
|
406
|
+
() =>
|
|
407
|
+
server.core.sessionGet(session.id).annotations[0]!.body === "Needs a citation. And a link.",
|
|
360
408
|
);
|
|
361
409
|
});
|
|
362
410
|
|
|
@@ -364,7 +412,9 @@ describe("the document selects, the rail edits", () => {
|
|
|
364
412
|
// Arrange - a pulled collaborator note carries an author fingerprint
|
|
365
413
|
const { makeAnchor, parseBlocks } = await import("@cueloop/schema");
|
|
366
414
|
const blocks = parseBlocks(PLAN);
|
|
367
|
-
const contextBlockIndex = blocks.findIndex((block) =>
|
|
415
|
+
const contextBlockIndex = blocks.findIndex((block) =>
|
|
416
|
+
block.text.startsWith("The daemon persists"),
|
|
417
|
+
);
|
|
368
418
|
server.core.sessionAnnotate(session.id, {
|
|
369
419
|
id: "collab-1",
|
|
370
420
|
kind: "comment",
|
|
@@ -390,17 +440,39 @@ describe("the document selects, the rail edits", () => {
|
|
|
390
440
|
await press(setup, "c");
|
|
391
441
|
await type(setup, "Delete me.");
|
|
392
442
|
await press(setup, "enter");
|
|
393
|
-
await waitForText(setup, "COMMENT ·
|
|
443
|
+
await waitForText(setup, "COMMENT · me");
|
|
394
444
|
expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
|
|
395
|
-
expect(backgroundsOf(setup, "persists sessions")).toContain(
|
|
445
|
+
expect(backgroundsOf(setup, "persists sessions")).toContain(DARK.markCommentBackground);
|
|
396
446
|
|
|
397
447
|
// Act
|
|
398
448
|
await press(setup, "x");
|
|
399
449
|
|
|
400
450
|
// Assert
|
|
401
|
-
await waitForTextGone(setup, "COMMENT ·
|
|
451
|
+
await waitForTextGone(setup, "COMMENT · me");
|
|
402
452
|
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
403
|
-
expect(backgroundsOf(setup, "persists sessions")).not.toContain(
|
|
453
|
+
expect(backgroundsOf(setup, "persists sessions")).not.toContain(DARK.markCommentBackground);
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
describe("plan cut removals", () => {
|
|
458
|
+
test("a cut block becomes a rail removal card and u restores it", async () => {
|
|
459
|
+
// Arrange
|
|
460
|
+
const setup = await renderApp();
|
|
461
|
+
await toContextParagraph(setup);
|
|
462
|
+
|
|
463
|
+
// Act - no card is selected, so x cuts the block under the cursor
|
|
464
|
+
await press(setup, "x");
|
|
465
|
+
|
|
466
|
+
// Assert - the rail shows a removal card titled CUT · me for the cut block
|
|
467
|
+
await waitForText(setup, "CUT · me");
|
|
468
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy !== undefined);
|
|
469
|
+
|
|
470
|
+
// Act - undo restores it (no selection, so the last removal)
|
|
471
|
+
await press(setup, "u");
|
|
472
|
+
|
|
473
|
+
// Assert - the block returns and the working copy is pristine again
|
|
474
|
+
await waitForText(setup, "removal restored");
|
|
475
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
|
|
404
476
|
});
|
|
405
477
|
});
|
|
406
478
|
|
|
@@ -409,7 +481,9 @@ describe("addressed annotations leave the open list", () => {
|
|
|
409
481
|
// Arrange: two annotations through the daemon, then a revision addressing one
|
|
410
482
|
const { makeAnchor, parseBlocks } = await import("@cueloop/schema");
|
|
411
483
|
const blocks = parseBlocks(PLAN);
|
|
412
|
-
const contextBlockIndex = blocks.findIndex((block) =>
|
|
484
|
+
const contextBlockIndex = blocks.findIndex((block) =>
|
|
485
|
+
block.text.startsWith("The daemon persists"),
|
|
486
|
+
);
|
|
413
487
|
server.core.sessionAnnotate(session.id, {
|
|
414
488
|
id: "a_settled",
|
|
415
489
|
kind: "comment",
|
|
@@ -433,7 +507,7 @@ describe("addressed annotations leave the open list", () => {
|
|
|
433
507
|
expect(frame).toContain("✓ 1 addressed by revision");
|
|
434
508
|
expect(frame).not.toContain("settled note");
|
|
435
509
|
expect(frame).toContain("still open note"); // the open card survives; the addressed one does not
|
|
436
|
-
expect(backgroundsOf(setup, "The daemon")).not.toContain(
|
|
510
|
+
expect(backgroundsOf(setup, "The daemon")).not.toContain(DARK.markCommentBackground); // no highlight paint
|
|
437
511
|
});
|
|
438
512
|
});
|
|
439
513
|
|
|
@@ -483,12 +557,13 @@ describe("edit-exit reconciliation", () => {
|
|
|
483
557
|
await press(setup, "c");
|
|
484
558
|
await type(setup, "Anchor me to the doomed passage.");
|
|
485
559
|
await press(setup, "enter");
|
|
486
|
-
await waitForText(setup, "·
|
|
560
|
+
await waitForText(setup, "COMMENT · me");
|
|
487
561
|
|
|
488
562
|
// Act
|
|
489
|
-
// deselect the card so e reaches the editor hand-off, then edit
|
|
563
|
+
// deselect the card so e reaches the editor hand-off, then edit; deselection
|
|
564
|
+
// shows as the card's accent border fading to a dim border (transparent fill)
|
|
490
565
|
await press(setup, "escape");
|
|
491
|
-
await
|
|
566
|
+
await waitForState(setup, () => !hasBorderColor(setup, DARK.accent));
|
|
492
567
|
await press(setup, "e");
|
|
493
568
|
|
|
494
569
|
// Assert
|
|
@@ -502,3 +577,28 @@ describe("edit-exit reconciliation", () => {
|
|
|
502
577
|
}
|
|
503
578
|
});
|
|
504
579
|
});
|
|
580
|
+
|
|
581
|
+
describe("marker-actions popover", () => {
|
|
582
|
+
test("v shows the toolbar, a opens quick-actions, enter inserts the preset comment", async () => {
|
|
583
|
+
// Arrange
|
|
584
|
+
const setup = await renderApp();
|
|
585
|
+
await toContextParagraph(setup);
|
|
586
|
+
|
|
587
|
+
// Act - marking a span reveals the inline toolbar
|
|
588
|
+
await press(setup, "v");
|
|
589
|
+
await waitForText(setup, "actions");
|
|
590
|
+
|
|
591
|
+
// open the quick-actions list; step to the second default and pick it. The
|
|
592
|
+
// list renders as a floating overlay the virtual terminal cannot composite,
|
|
593
|
+
// so drive the grammar directly rather than waiting on its painted text.
|
|
594
|
+
await press(setup, "a");
|
|
595
|
+
await press(setup, "j");
|
|
596
|
+
await press(setup, "enter");
|
|
597
|
+
|
|
598
|
+
// Assert - a comment annotation was created with the second default's body
|
|
599
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
600
|
+
const stored = server.core.sessionGet(session.id);
|
|
601
|
+
expect(stored.annotations[0]!.kind).toBe("comment");
|
|
602
|
+
expect(stored.annotations[0]!.body).toBe("Restate simplified");
|
|
603
|
+
});
|
|
604
|
+
});
|
|
@@ -9,7 +9,8 @@ import { testRender } from "@opentui/react/test-utils";
|
|
|
9
9
|
import { DaemonServer } from "@cueloop/daemon";
|
|
10
10
|
import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
|
|
11
11
|
import { App } from "./App";
|
|
12
|
-
import {
|
|
12
|
+
import { DARK } from "./theme";
|
|
13
|
+
import { isolateUserConfig, press, waitForState, waitForText } from "./test-support";
|
|
13
14
|
|
|
14
15
|
const PLAN = `# Migration Plan
|
|
15
16
|
|
|
@@ -35,7 +36,11 @@ beforeEach(() => {
|
|
|
35
36
|
server.start();
|
|
36
37
|
session = server.core.sessionCreate({
|
|
37
38
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
38
|
-
artifact: {
|
|
39
|
+
artifact: {
|
|
40
|
+
type: "plan",
|
|
41
|
+
content: PLAN,
|
|
42
|
+
meta: { title: "Migration Plan", planPath: "plan.md" },
|
|
43
|
+
},
|
|
39
44
|
});
|
|
40
45
|
});
|
|
41
46
|
afterEach(() => {
|
|
@@ -56,7 +61,11 @@ async function renderObserver() {
|
|
|
56
61
|
/** Session state that any mutating verb would change. */
|
|
57
62
|
function snapshot() {
|
|
58
63
|
const stored = server.core.sessionGet(session.id);
|
|
59
|
-
return {
|
|
64
|
+
return {
|
|
65
|
+
annotations: stored.annotations.length,
|
|
66
|
+
workingCopy: stored.workingCopy,
|
|
67
|
+
status: stored.status,
|
|
68
|
+
};
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
describe("observer rendering", () => {
|
|
@@ -74,7 +83,6 @@ describe("observer rendering", () => {
|
|
|
74
83
|
describe("observer verbs are blocked", () => {
|
|
75
84
|
for (const [key, verb] of [
|
|
76
85
|
["c", "comment"],
|
|
77
|
-
["s", "suggest"],
|
|
78
86
|
["x", "cut"],
|
|
79
87
|
["e", "edit"],
|
|
80
88
|
["enter", "submit"],
|
|
@@ -88,13 +96,12 @@ describe("observer verbs are blocked", () => {
|
|
|
88
96
|
const frame = await waitForText(setup, "observer - read-only");
|
|
89
97
|
// no overlay opened: compose/submit bars never appear
|
|
90
98
|
expect(frame).not.toContain('comment on "');
|
|
91
|
-
expect(frame).not.toContain('suggest replacement for "');
|
|
92
99
|
expect(frame).not.toContain("verdict ←/→");
|
|
93
100
|
expect(snapshot()).toEqual(before);
|
|
94
101
|
});
|
|
95
102
|
}
|
|
96
103
|
|
|
97
|
-
test("span mode c
|
|
104
|
+
test("span mode c is blocked too", async () => {
|
|
98
105
|
// Arrange
|
|
99
106
|
const setup = await renderObserver();
|
|
100
107
|
|
|
@@ -143,7 +150,23 @@ describe("observer navigation still works", () => {
|
|
|
143
150
|
await press(setup, "n");
|
|
144
151
|
await setup.renderOnce();
|
|
145
152
|
|
|
146
|
-
// Assert
|
|
147
|
-
|
|
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));
|
|
148
157
|
});
|
|
149
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
|
+
}
|