@cueloop/client 0.1.0-alpha.20 → 0.1.0-alpha.22
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 +5 -5
- package/src/App.autoclose.test.tsx +29 -5
- package/src/App.diff.test.tsx +20 -0
- package/src/App.export.test.tsx +12 -8
- package/src/App.plan-review.test.tsx +182 -7
- package/src/App.readonly.test.tsx +19 -5
- package/src/App.rendering.test.tsx +21 -2
- package/src/App.review-panel.test.tsx +120 -0
- package/src/App.submit-confirm.test.tsx +57 -6
- package/src/App.test.tsx +62 -5
- package/src/App.tsx +147 -185
- package/src/App.walk.test.tsx +63 -8
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +26 -0
- package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
- package/src/components/AnnotationCard.tsx +66 -13
- package/src/components/ReviewPanel.stories.tsx +86 -0
- package/src/components/ReviewPanel.tsx +116 -0
- package/src/components/ReviewRail.tsx +30 -5
- package/src/components/__snapshots__/stories.test.tsx.snap +140 -0
- package/src/components/primitives/Card.tsx +2 -1
- package/src/components/primitives/Dialog.tsx +2 -1
- package/src/components/primitives/frame.test.ts +12 -0
- package/src/components/primitives/frame.ts +14 -0
- package/src/components/stories-app.tsx +2 -1
- package/src/components/stories.test.tsx +5 -0
- package/src/config.test.ts +113 -1
- package/src/config.ts +58 -22
- package/src/editor.test.ts +24 -0
- package/src/herdr.test.ts +4 -0
- package/src/index.ts +2 -0
- package/src/integrations.test.ts +12 -0
- package/src/intent-dispatch.test.ts +213 -0
- package/src/intent-dispatch.ts +270 -0
- package/src/key-bindings.test.ts +16 -5
- package/src/keymap.test.ts +141 -9
- package/src/keymap.ts +54 -7
- package/src/review-panel.test.ts +111 -0
- package/src/review-panel.ts +76 -0
- package/src/serve.test.ts +7 -0
- package/src/session-controller.ts +39 -4
- package/src/share.ts +47 -0
- package/src/view-diff.test.ts +11 -0
- package/src/view-diff.ts +6 -6
- package/src/view-plan.test.ts +81 -0
- package/src/view-plan.ts +3 -0
- package/src/walk.test.ts +6 -0
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/client",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.22",
|
|
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.
|
|
10
|
+
"@cueloop/daemon": "0.1.0-alpha.22",
|
|
11
|
+
"@cueloop/extension-api": "0.1.0-alpha.22",
|
|
12
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.22",
|
|
13
|
+
"@cueloop/schema": "0.1.0-alpha.22",
|
|
14
14
|
"@opentui/core": "^0.5.1",
|
|
15
15
|
"@opentui/keymap": "0.5.1",
|
|
16
16
|
"@opentui/react": "^0.5.1",
|
|
@@ -1,8 +1,4 @@
|
|
|
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
|
-
*/
|
|
1
|
+
/** Post-submit completion overlay: prompt, opt-in persistence, countdown, dismissal. Countdown seconds tick on an injected ManualClock, so tests advance time instead of waiting it out. */
|
|
6
2
|
|
|
7
3
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
4
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -61,49 +57,77 @@ async function submitApprove(setup: Setup): Promise<void> {
|
|
|
61
57
|
|
|
62
58
|
describe("completion overlay", () => {
|
|
63
59
|
test("submit with auto-close off counts down from 5; ⏎ exits now", async () => {
|
|
60
|
+
// Arrange
|
|
64
61
|
let exited = -1;
|
|
65
62
|
const setup = await renderApp((code) => (exited = code));
|
|
63
|
+
|
|
64
|
+
// Act
|
|
66
65
|
await submitApprove(setup);
|
|
66
|
+
|
|
67
|
+
// Assert
|
|
67
68
|
const frame = await waitForText(setup, "closing in 5s");
|
|
68
69
|
expect(frame).toContain("review approved");
|
|
69
70
|
expect(frame).toContain("close [return]");
|
|
70
71
|
expect(frame).toContain("return to plan [esc]");
|
|
72
|
+
|
|
73
|
+
// Act
|
|
71
74
|
await press(setup, "enter");
|
|
75
|
+
|
|
76
|
+
// Assert
|
|
72
77
|
expect(exited).toBe(0);
|
|
73
78
|
});
|
|
74
79
|
|
|
75
80
|
test("a remembers the countdown as the persisted default", async () => {
|
|
81
|
+
// Arrange
|
|
76
82
|
const setup = await renderApp();
|
|
77
83
|
await submitApprove(setup);
|
|
78
84
|
await waitForText(setup, "closing in 5s");
|
|
85
|
+
|
|
86
|
+
// Act
|
|
79
87
|
await press(setup, "a");
|
|
88
|
+
|
|
89
|
+
// Assert
|
|
80
90
|
expect(existsSync(configPath)).toBe(true);
|
|
81
91
|
expect(readFileSync(configPath, "utf8")).toContain("auto_close = 5");
|
|
92
|
+
|
|
93
|
+
// Act
|
|
82
94
|
// the countdown keeps running; each second is one manual-clock tick
|
|
83
95
|
clock.advance(1000);
|
|
96
|
+
|
|
97
|
+
// Assert
|
|
84
98
|
await waitForText(setup, "closing in 4s");
|
|
85
99
|
});
|
|
86
100
|
|
|
87
101
|
test("esc dismisses to the resolved read-only view", async () => {
|
|
102
|
+
// Arrange
|
|
88
103
|
const setup = await renderApp();
|
|
89
104
|
await submitApprove(setup);
|
|
90
105
|
await waitForText(setup, "closing in 5s");
|
|
106
|
+
|
|
107
|
+
// Act
|
|
91
108
|
// a bare ESC sits in the input parser's escape-sequence disambiguation
|
|
92
109
|
// window before it is delivered - the frame wait absorbs it
|
|
93
110
|
await press(setup, "escape");
|
|
111
|
+
|
|
112
|
+
// Assert
|
|
94
113
|
const frame = await waitForText(setup, "resolved: approve");
|
|
95
114
|
expect(frame).not.toContain("closing in");
|
|
96
115
|
});
|
|
97
116
|
|
|
98
117
|
test("configured auto_close counts down and exits without interaction", async () => {
|
|
118
|
+
// Arrange
|
|
99
119
|
writeFileSync(configPath, "[ui]\nauto_close = 1\n");
|
|
100
120
|
let exited = -1;
|
|
101
121
|
const setup = await renderApp((code) => (exited = code));
|
|
102
122
|
await submitApprove(setup);
|
|
103
123
|
await waitForText(setup, "closing in 1s");
|
|
104
124
|
expect(exited).toBe(-1);
|
|
125
|
+
|
|
126
|
+
// Act
|
|
105
127
|
clock.advance(1000);
|
|
106
128
|
await settle(setup);
|
|
129
|
+
|
|
130
|
+
// Assert
|
|
107
131
|
expect(exited).toBe(0);
|
|
108
132
|
});
|
|
109
133
|
});
|
package/src/App.diff.test.tsx
CHANGED
|
@@ -48,7 +48,10 @@ async function renderApp() {
|
|
|
48
48
|
|
|
49
49
|
describe("diff review", () => {
|
|
50
50
|
test("renders file header, hunks, and signed lines", async () => {
|
|
51
|
+
// Arrange
|
|
51
52
|
const setup = await renderApp();
|
|
53
|
+
|
|
54
|
+
// Assert
|
|
52
55
|
const frame = setup.captureCharFrame();
|
|
53
56
|
expect(frame).toContain("■ src/store.ts");
|
|
54
57
|
expect(frame).toContain("@@ -1,4 +1,4 @@");
|
|
@@ -57,21 +60,33 @@ describe("diff review", () => {
|
|
|
57
60
|
});
|
|
58
61
|
|
|
59
62
|
test("line comment: quote-anchored, lands in feedback with the code line", async () => {
|
|
63
|
+
// Arrange
|
|
60
64
|
const setup = await renderApp();
|
|
65
|
+
|
|
66
|
+
// Act
|
|
61
67
|
// rows: file(0), hunk(1), ctx(2), del(3), add(4)
|
|
62
68
|
for (let i = 0; i < 4; i++) await press(setup, "j");
|
|
63
69
|
await press(setup, "c");
|
|
70
|
+
|
|
71
|
+
// Assert
|
|
64
72
|
expect(setup.captureCharFrame()).toContain("COMMENT ON");
|
|
73
|
+
|
|
74
|
+
// Act
|
|
65
75
|
await setup.mockInput.typeText("Map needs an eviction story.");
|
|
66
76
|
await press(setup, "enter");
|
|
77
|
+
|
|
78
|
+
// Assert
|
|
67
79
|
// inline annotation card rendered under the line
|
|
68
80
|
await waitForText(setup, "◆ Map needs an eviction story.");
|
|
69
81
|
const stored = server.core.sessionGet(session.id);
|
|
70
82
|
expect(stored.annotations.length).toBe(1);
|
|
71
83
|
expect(stored.annotations[0]!.anchor.quote).toContain("new Map()");
|
|
72
84
|
|
|
85
|
+
// Act
|
|
73
86
|
await press(setup, "enter"); // submit
|
|
74
87
|
await press(setup, "enter"); // confirm request_changes
|
|
88
|
+
|
|
89
|
+
// Assert
|
|
75
90
|
await waitForText(setup, "feedback sent");
|
|
76
91
|
const resolved = server.core.sessionGet(session.id);
|
|
77
92
|
expect(resolved.verdict!.feedback).toContain("new Map()");
|
|
@@ -79,8 +94,13 @@ describe("diff review", () => {
|
|
|
79
94
|
});
|
|
80
95
|
|
|
81
96
|
test("plan verbs are guarded in diff sessions", async () => {
|
|
97
|
+
// Arrange
|
|
82
98
|
const setup = await renderApp();
|
|
99
|
+
|
|
100
|
+
// Act
|
|
83
101
|
await press(setup, "x");
|
|
102
|
+
|
|
103
|
+
// Assert
|
|
84
104
|
expect(setup.captureCharFrame()).toContain("plan-only verb");
|
|
85
105
|
});
|
|
86
106
|
});
|
package/src/App.export.test.tsx
CHANGED
|
@@ -1,8 +1,4 @@
|
|
|
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
|
-
*/
|
|
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. */
|
|
6
2
|
|
|
7
3
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
4
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -50,12 +46,20 @@ afterEach(() => {
|
|
|
50
46
|
|
|
51
47
|
describe("obsidian export on resolve", () => {
|
|
52
48
|
test("submitting a review writes the plan into the vault and shows the path", async () => {
|
|
49
|
+
// Arrange
|
|
53
50
|
const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 32 });
|
|
54
51
|
await waitForText(setup, "cueloop");
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
|
|
53
|
+
// Act
|
|
54
|
+
await press(setup, "enter"); // open the submit overlay, keep the default verdict (approve)
|
|
55
|
+
|
|
56
|
+
// Assert
|
|
57
57
|
expect(setup.captureCharFrame()).toContain("verdict");
|
|
58
|
-
|
|
58
|
+
|
|
59
|
+
// Act
|
|
60
|
+
await press(setup, "enter"); // submit
|
|
61
|
+
|
|
62
|
+
// Assert
|
|
59
63
|
// the export is an async round-trip after resolve; the status line lands
|
|
60
64
|
// on a later render than the file write
|
|
61
65
|
const dir = join(vault, "cueloop");
|
|
@@ -1,10 +1,4 @@
|
|
|
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
|
-
*/
|
|
1
|
+
/** The plan review surface v2: native selection feeds the annotation quote, the inline compose box keeps its anchor painted, the rail edits what the document selects, and edit-exit reconciliation orphans annotations whose passage was removed. Char-frame + styled-span assertions over the real App and a real in-process daemon. */
|
|
8
2
|
|
|
9
3
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
10
4
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
@@ -81,34 +75,62 @@ async function toContextParagraph(setup: Setup): Promise<void> {
|
|
|
81
75
|
await press(setup, "j");
|
|
82
76
|
}
|
|
83
77
|
|
|
78
|
+
describe("share button", () => {
|
|
79
|
+
test("the owner's rail shows the one-click Share button", async () => {
|
|
80
|
+
// Arrange / Act
|
|
81
|
+
const setup = await renderApp();
|
|
82
|
+
|
|
83
|
+
// Assert
|
|
84
|
+
expect(setup.captureCharFrame()).toContain("Share plan");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
84
88
|
describe("selection feeds the annotation quote", () => {
|
|
85
89
|
test("v anchors, l extends, c composes with the char-precise quote", async () => {
|
|
90
|
+
// Arrange
|
|
86
91
|
const setup = await renderApp();
|
|
87
92
|
await toContextParagraph(setup);
|
|
93
|
+
|
|
94
|
+
// Act
|
|
88
95
|
await press(setup, "v");
|
|
89
96
|
await press(setup, "l"); // "The daemon"
|
|
90
97
|
await press(setup, "c");
|
|
98
|
+
|
|
99
|
+
// Assert
|
|
91
100
|
expect(setup.captureCharFrame()).toContain('comment on "The daemon"');
|
|
101
|
+
|
|
102
|
+
// Act
|
|
92
103
|
await type(setup, "Which daemon?");
|
|
93
104
|
await press(setup, "enter");
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
94
107
|
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
95
108
|
const stored = server.core.sessionGet(session.id);
|
|
96
109
|
expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
|
|
97
110
|
});
|
|
98
111
|
|
|
99
112
|
test("a mouse drag over the text becomes the compose quote", async () => {
|
|
113
|
+
// Arrange
|
|
100
114
|
const setup = await renderApp();
|
|
101
115
|
const lines = setup.captureCharFrame().split("\n");
|
|
102
116
|
const row = lines.findIndex((line) => line.includes("persists sessions"));
|
|
103
117
|
const startColumn = lines[row]!.indexOf("persists");
|
|
104
118
|
expect(row).toBeGreaterThan(-1);
|
|
119
|
+
|
|
120
|
+
// Act
|
|
105
121
|
// the drag's end cell is exclusive: release one past the last character
|
|
106
122
|
await setup.mockMouse.drag(startColumn, row, startColumn + "persists sessions".length, row);
|
|
107
123
|
await settle(setup);
|
|
108
124
|
await press(setup, "c");
|
|
125
|
+
|
|
126
|
+
// Assert
|
|
109
127
|
expect(setup.captureCharFrame()).toContain('comment on "persists sessions"');
|
|
128
|
+
|
|
129
|
+
// Act
|
|
110
130
|
await type(setup, "sessions plural?");
|
|
111
131
|
await press(setup, "enter");
|
|
132
|
+
|
|
133
|
+
// Assert
|
|
112
134
|
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
113
135
|
const stored = server.core.sessionGet(session.id);
|
|
114
136
|
expect(stored.annotations[0]!.anchor.quote).toBe("persists sessions");
|
|
@@ -117,25 +139,38 @@ describe("selection feeds the annotation quote", () => {
|
|
|
117
139
|
|
|
118
140
|
describe("inline compose keeps the anchor painted", () => {
|
|
119
141
|
test("the selection stays painted while composing; cancel un-paints; save converts to the comment highlight", async () => {
|
|
142
|
+
// Arrange
|
|
120
143
|
const setup = await renderApp();
|
|
121
144
|
await toContextParagraph(setup);
|
|
145
|
+
|
|
146
|
+
// Act
|
|
122
147
|
await press(setup, "v");
|
|
123
148
|
await press(setup, "l");
|
|
124
149
|
await press(setup, "c");
|
|
150
|
+
|
|
151
|
+
// Assert
|
|
125
152
|
// compose open: the anchor is painted selection-style. The box can render
|
|
126
153
|
// a frame before the anchor repaint settles, so wait on the color itself.
|
|
127
154
|
expect(setup.captureCharFrame()).toContain("Save ⏎");
|
|
128
155
|
expect(setup.captureCharFrame()).toContain("Cancel esc");
|
|
129
156
|
await waitForState(setup, () => backgroundsOf(setup, "The daemon").includes(T.accent));
|
|
130
157
|
expect(backgroundsOf(setup, "The daemon")).toContain(T.accent);
|
|
158
|
+
|
|
159
|
+
// Act
|
|
131
160
|
// cancel un-paints (a bare ESC settles after the parser's escape window)
|
|
132
161
|
await press(setup, "escape");
|
|
162
|
+
|
|
163
|
+
// Assert
|
|
133
164
|
await waitForTextGone(setup, 'comment on "');
|
|
134
165
|
expect(backgroundsOf(setup, "The daemon")).not.toContain(T.accent);
|
|
166
|
+
|
|
167
|
+
// Act
|
|
135
168
|
// save converts the paint to the kind-colored annotation highlight
|
|
136
169
|
await press(setup, "c");
|
|
137
170
|
await type(setup, "Which daemon?");
|
|
138
171
|
await press(setup, "enter");
|
|
172
|
+
|
|
173
|
+
// Assert
|
|
139
174
|
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
140
175
|
await settle(setup);
|
|
141
176
|
// the whole cursor block is the anchor here; check the highlight landed
|
|
@@ -147,18 +182,107 @@ describe("inline compose keeps the anchor painted", () => {
|
|
|
147
182
|
}, 15_000);
|
|
148
183
|
});
|
|
149
184
|
|
|
185
|
+
describe("compose newline convention", () => {
|
|
186
|
+
// Option/Alt and Shift reach the App as `meta`/`shift` on the return key.
|
|
187
|
+
// modifyOtherKeys encoding preserves those modifiers so the grammar and the
|
|
188
|
+
// focused textarea can tell a bare submit from a newline.
|
|
189
|
+
async function renderModifierAwareApp() {
|
|
190
|
+
const setup = await testRender(<App home={home} sessionId={session.id} />, {
|
|
191
|
+
width: 120,
|
|
192
|
+
height: 32,
|
|
193
|
+
otherModifiersMode: true,
|
|
194
|
+
});
|
|
195
|
+
await waitForText(setup, "cueloop");
|
|
196
|
+
return setup;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function pressReturnWith(setup: Setup, modifiers: { shift?: boolean; meta?: boolean }): Promise<void> {
|
|
200
|
+
setup.mockInput.pressKey("RETURN", modifiers);
|
|
201
|
+
await settle(setup);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
test("option+return inserts a newline; a bare return submits the multiline body", async () => {
|
|
205
|
+
// Arrange
|
|
206
|
+
const setup = await renderModifierAwareApp();
|
|
207
|
+
await toContextParagraph(setup);
|
|
208
|
+
await press(setup, "v");
|
|
209
|
+
await press(setup, "l");
|
|
210
|
+
await press(setup, "c");
|
|
211
|
+
await waitForText(setup, 'comment on "The daemon"');
|
|
212
|
+
|
|
213
|
+
// Act
|
|
214
|
+
await type(setup, "first line");
|
|
215
|
+
await pressReturnWith(setup, { meta: true }); // option/alt+return -> newline
|
|
216
|
+
await type(setup, "second line");
|
|
217
|
+
|
|
218
|
+
// Assert
|
|
219
|
+
// still composing: the newline did not submit
|
|
220
|
+
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
221
|
+
|
|
222
|
+
// Act
|
|
223
|
+
await press(setup, "enter"); // bare return -> save
|
|
224
|
+
|
|
225
|
+
// Assert
|
|
226
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
227
|
+
expect(server.core.sessionGet(session.id).annotations[0]!.body).toBe("first line\nsecond line");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("shift+return still inserts a newline (existing muscle memory)", async () => {
|
|
231
|
+
// Arrange
|
|
232
|
+
const setup = await renderModifierAwareApp();
|
|
233
|
+
await toContextParagraph(setup);
|
|
234
|
+
await press(setup, "c");
|
|
235
|
+
|
|
236
|
+
// Act
|
|
237
|
+
await type(setup, "alpha");
|
|
238
|
+
await pressReturnWith(setup, { shift: true }); // shift+return -> newline
|
|
239
|
+
await type(setup, "beta");
|
|
240
|
+
|
|
241
|
+
// Assert
|
|
242
|
+
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
243
|
+
|
|
244
|
+
// Act
|
|
245
|
+
await press(setup, "enter");
|
|
246
|
+
|
|
247
|
+
// Assert
|
|
248
|
+
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
249
|
+
expect(server.core.sessionGet(session.id).annotations[0]!.body).toBe("alpha\nbeta");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("escape cancels the composer without saving", async () => {
|
|
253
|
+
// Arrange
|
|
254
|
+
const setup = await renderModifierAwareApp();
|
|
255
|
+
await toContextParagraph(setup);
|
|
256
|
+
await press(setup, "c");
|
|
257
|
+
await waitForText(setup, 'comment on "');
|
|
258
|
+
await type(setup, "never saved");
|
|
259
|
+
|
|
260
|
+
// Act
|
|
261
|
+
await press(setup, "escape");
|
|
262
|
+
|
|
263
|
+
// Assert
|
|
264
|
+
await waitForTextGone(setup, 'comment on "');
|
|
265
|
+
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
150
269
|
describe("the document selects, the rail edits", () => {
|
|
151
270
|
test("e edits the selected card in place; enter saves through the controller", async () => {
|
|
271
|
+
// Arrange
|
|
152
272
|
const setup = await renderApp();
|
|
153
273
|
await toContextParagraph(setup);
|
|
154
274
|
await press(setup, "c");
|
|
155
275
|
await type(setup, "Needs a citation.");
|
|
156
276
|
await press(setup, "enter");
|
|
157
277
|
await waitForText(setup, "COMMENT · pending");
|
|
278
|
+
|
|
279
|
+
// Act
|
|
158
280
|
// the saved card is selected; e turns its body into an input in place
|
|
159
281
|
await press(setup, "e");
|
|
160
282
|
await type(setup, " And a link.");
|
|
161
283
|
await press(setup, "enter");
|
|
284
|
+
|
|
285
|
+
// Assert
|
|
162
286
|
await waitForState(
|
|
163
287
|
setup,
|
|
164
288
|
() => server.core.sessionGet(session.id).annotations[0]!.body === "Needs a citation. And a link.",
|
|
@@ -166,6 +290,7 @@ describe("the document selects, the rail edits", () => {
|
|
|
166
290
|
});
|
|
167
291
|
|
|
168
292
|
test("x deletes the selected card and un-paints the document highlight", async () => {
|
|
293
|
+
// Arrange
|
|
169
294
|
const setup = await renderApp();
|
|
170
295
|
await toContextParagraph(setup);
|
|
171
296
|
await press(setup, "c");
|
|
@@ -174,28 +299,73 @@ describe("the document selects, the rail edits", () => {
|
|
|
174
299
|
await waitForText(setup, "COMMENT · pending");
|
|
175
300
|
expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
|
|
176
301
|
expect(backgroundsOf(setup, "persists sessions")).toContain(T.markCommentBg);
|
|
302
|
+
|
|
303
|
+
// Act
|
|
177
304
|
await press(setup, "x");
|
|
305
|
+
|
|
306
|
+
// Assert
|
|
178
307
|
await waitForTextGone(setup, "COMMENT · pending");
|
|
179
308
|
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
180
309
|
expect(backgroundsOf(setup, "persists sessions")).not.toContain(T.markCommentBg);
|
|
181
310
|
});
|
|
182
311
|
});
|
|
183
312
|
|
|
313
|
+
describe("addressed annotations leave the open list", () => {
|
|
314
|
+
test("a revision that addresses one card hides it, un-paints it, and shows the count", async () => {
|
|
315
|
+
// Arrange: two annotations through the daemon, then a revision addressing one
|
|
316
|
+
const { makeAnchor, parseBlocks } = await import("@cueloop/schema");
|
|
317
|
+
const blocks = parseBlocks(PLAN);
|
|
318
|
+
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("The daemon persists"));
|
|
319
|
+
server.core.sessionAnnotate(session.id, {
|
|
320
|
+
id: "a_settled",
|
|
321
|
+
kind: "comment",
|
|
322
|
+
anchor: makeAnchor(blocks, contextBlockIndex, 0, 10), // "The daemon"
|
|
323
|
+
body: "settled note",
|
|
324
|
+
});
|
|
325
|
+
server.core.sessionAnnotate(session.id, {
|
|
326
|
+
id: "a_open",
|
|
327
|
+
kind: "comment",
|
|
328
|
+
anchor: makeAnchor(blocks, contextBlockIndex, 11, 19), // "persists"
|
|
329
|
+
body: "still open note",
|
|
330
|
+
});
|
|
331
|
+
server.core.sessionSubmitRevision(session.id, PLAN, ["a_settled"]);
|
|
332
|
+
|
|
333
|
+
// Act
|
|
334
|
+
const setup = await renderApp();
|
|
335
|
+
await waitForText(setup, "still open note");
|
|
336
|
+
|
|
337
|
+
// Assert: the open card renders, the addressed one is gone behind the count
|
|
338
|
+
const frame = setup.captureCharFrame();
|
|
339
|
+
expect(frame).toContain("✓ 1 addressed by revision");
|
|
340
|
+
expect(frame).not.toContain("settled note");
|
|
341
|
+
expect(frame).toContain("Review (1)"); // the addressed card no longer counts as pending
|
|
342
|
+
expect(backgroundsOf(setup, "The daemon")).not.toContain(T.markCommentBg); // no highlight paint
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
184
346
|
describe("sheet header", () => {
|
|
185
347
|
test("shows the submitting agent, the revision, and the Edit word-button", async () => {
|
|
348
|
+
// Arrange
|
|
186
349
|
const setup = await renderApp();
|
|
350
|
+
|
|
351
|
+
// Assert
|
|
187
352
|
const frame = setup.captureCharFrame();
|
|
188
353
|
expect(frame).toContain("submitted by agent/worker-3 · revision 1");
|
|
189
354
|
expect(frame).toContain("Edit");
|
|
190
355
|
});
|
|
191
356
|
|
|
192
357
|
test("the Agent tab shows the submitter, status, and revision", async () => {
|
|
358
|
+
// Arrange
|
|
193
359
|
const setup = await renderApp();
|
|
194
360
|
const lines = setup.captureCharFrame().split("\n");
|
|
195
361
|
const tabRow = lines.findIndex((line) => line.includes("Review (0)"));
|
|
196
362
|
const tabColumn = lines[tabRow]!.indexOf("Agent");
|
|
363
|
+
|
|
364
|
+
// Act
|
|
197
365
|
await setup.mockMouse.click(tabColumn + 1, tabRow);
|
|
198
366
|
await setup.renderOnce();
|
|
367
|
+
|
|
368
|
+
// Assert
|
|
199
369
|
const frame = setup.captureCharFrame();
|
|
200
370
|
expect(frame).toContain("agent/worker-3");
|
|
201
371
|
expect(frame).toContain("status: pending");
|
|
@@ -210,16 +380,21 @@ describe("edit-exit reconciliation", () => {
|
|
|
210
380
|
Bun.spawnSync(["chmod", "+x", script]);
|
|
211
381
|
process.env.CUELOOP_EDITOR = script;
|
|
212
382
|
try {
|
|
383
|
+
// Arrange
|
|
213
384
|
const setup = await renderApp();
|
|
214
385
|
await toContextParagraph(setup);
|
|
215
386
|
await press(setup, "c");
|
|
216
387
|
await type(setup, "Anchor me to the doomed passage.");
|
|
217
388
|
await press(setup, "enter");
|
|
218
389
|
await waitForText(setup, "· pending");
|
|
390
|
+
|
|
391
|
+
// Act
|
|
219
392
|
// deselect the card so e reaches the editor hand-off, then edit
|
|
220
393
|
await press(setup, "escape");
|
|
221
394
|
await waitForTextGone(setup, "▸ COMMENT");
|
|
222
395
|
await press(setup, "e");
|
|
396
|
+
|
|
397
|
+
// Assert
|
|
223
398
|
await waitForText(setup, "1 annotation no longer match - the passage was removed.");
|
|
224
399
|
// the rail card flips to ORPHANED once the working-copy refresh lands
|
|
225
400
|
await waitForText(setup, "· ORPHANED");
|
|
@@ -1,8 +1,4 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Observer mode (tier 2): the App with readOnly renders and navigates like the
|
|
3
|
-
* controller's, but every mutating verb answers "observer - read-only" and
|
|
4
|
-
* leaves daemon state untouched.
|
|
5
|
-
*/
|
|
1
|
+
/** Observer mode (tier 2): the App with readOnly renders and navigates like the controller's, but every mutating verb answers "observer - read-only" and leaves daemon state untouched. */
|
|
6
2
|
|
|
7
3
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
8
4
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
@@ -62,7 +58,10 @@ function snapshot() {
|
|
|
62
58
|
|
|
63
59
|
describe("observer rendering", () => {
|
|
64
60
|
test("shows the observer badge and read-only hint bar", async () => {
|
|
61
|
+
// Arrange
|
|
65
62
|
const setup = await renderObserver();
|
|
63
|
+
|
|
64
|
+
// Assert
|
|
66
65
|
const frame = setup.captureCharFrame();
|
|
67
66
|
expect(frame).toContain("· observer");
|
|
68
67
|
expect(frame).toContain("observer - read-only · j/k move");
|
|
@@ -94,12 +93,17 @@ describe("observer verbs are blocked", () => {
|
|
|
94
93
|
}
|
|
95
94
|
|
|
96
95
|
test("span mode c/s is blocked too", async () => {
|
|
96
|
+
// Arrange
|
|
97
97
|
const setup = await renderObserver();
|
|
98
|
+
|
|
99
|
+
// Act
|
|
98
100
|
await press(setup, "j");
|
|
99
101
|
await press(setup, "j");
|
|
100
102
|
await press(setup, "v");
|
|
101
103
|
await press(setup, "c");
|
|
102
104
|
await setup.renderOnce();
|
|
105
|
+
|
|
106
|
+
// Assert
|
|
103
107
|
const frame = setup.captureCharFrame();
|
|
104
108
|
expect(frame).toContain("observer - read-only");
|
|
105
109
|
expect(frame).not.toContain("COMMENT ON");
|
|
@@ -109,16 +113,22 @@ describe("observer verbs are blocked", () => {
|
|
|
109
113
|
|
|
110
114
|
describe("observer navigation still works", () => {
|
|
111
115
|
test("j/k moves the cursor between blocks", async () => {
|
|
116
|
+
// Arrange
|
|
112
117
|
const setup = await renderObserver();
|
|
118
|
+
|
|
119
|
+
// Act
|
|
113
120
|
await press(setup, "j");
|
|
114
121
|
await press(setup, "j");
|
|
115
122
|
await setup.renderOnce();
|
|
123
|
+
|
|
124
|
+
// Assert
|
|
116
125
|
const lines = setup.captureCharFrame().split("\n");
|
|
117
126
|
const cursorLine = lines.find((line) => line.includes("▎"))!;
|
|
118
127
|
expect(cursorLine).toContain("persists sessions");
|
|
119
128
|
});
|
|
120
129
|
|
|
121
130
|
test("n focuses annotations made by the controller", async () => {
|
|
131
|
+
// Arrange
|
|
122
132
|
server.core.sessionAnnotate(session.id, {
|
|
123
133
|
id: "a_obs1",
|
|
124
134
|
kind: "comment",
|
|
@@ -126,8 +136,12 @@ describe("observer navigation still works", () => {
|
|
|
126
136
|
body: "From the controller.",
|
|
127
137
|
});
|
|
128
138
|
const setup = await renderObserver();
|
|
139
|
+
|
|
140
|
+
// Act
|
|
129
141
|
await press(setup, "n");
|
|
130
142
|
await setup.renderOnce();
|
|
143
|
+
|
|
144
|
+
// Assert
|
|
131
145
|
expect(setup.captureCharFrame()).toContain("▸ COMMENT");
|
|
132
146
|
});
|
|
133
147
|
});
|
|
@@ -57,35 +57,52 @@ async function frame(): Promise<string> {
|
|
|
57
57
|
|
|
58
58
|
describe("code blocks", () => {
|
|
59
59
|
test("lines render verbatim: indentation preserved, no word-wrap", async () => {
|
|
60
|
+
// Arrange
|
|
60
61
|
const rendered = await frame();
|
|
62
|
+
|
|
63
|
+
// Assert
|
|
61
64
|
expect(rendered).toContain(" const blocked = full < 0.6;");
|
|
62
65
|
expect(rendered).toContain(' return <text>{blocked ? "blocked" : "clear"}</text>;');
|
|
63
66
|
});
|
|
64
67
|
|
|
65
68
|
test("the block carries its language tag", async () => {
|
|
69
|
+
// Arrange
|
|
66
70
|
const rendered = await frame();
|
|
71
|
+
|
|
72
|
+
// Assert
|
|
67
73
|
expect(rendered).toContain("tsx");
|
|
68
74
|
});
|
|
69
75
|
});
|
|
70
76
|
|
|
71
77
|
describe("block spacing", () => {
|
|
78
|
+
// the review panel's full-height divider paints a `│` on every row, so read
|
|
79
|
+
// the plan column (everything left of the divider) before asserting spacing
|
|
80
|
+
const planColumn = (line: string): string => line.split("│")[0]!.trimEnd();
|
|
81
|
+
|
|
72
82
|
test("a code block never glues to the list item above it", async () => {
|
|
73
|
-
|
|
83
|
+
// Arrange
|
|
84
|
+
const lines = (await frame()).split("\n").map(planColumn);
|
|
74
85
|
const listItemLineIndex = lines.findIndex((line) => line.includes("wire the duck over MQTT"));
|
|
75
86
|
const codeHeader = lines.findIndex((line) => line.trim().startsWith("tsx"));
|
|
87
|
+
|
|
88
|
+
// Assert
|
|
76
89
|
expect(listItemLineIndex).toBeGreaterThan(-1);
|
|
77
90
|
expect(codeHeader).toBeGreaterThan(listItemLineIndex);
|
|
78
91
|
expect(lines.slice(listItemLineIndex + 1, codeHeader).some((line) => line === "")).toBe(true);
|
|
79
92
|
});
|
|
80
93
|
|
|
81
94
|
test("a heading never sits directly on the previous block", async () => {
|
|
82
|
-
|
|
95
|
+
// Arrange
|
|
96
|
+
const lines = (await frame()).split("\n").map(planColumn);
|
|
83
97
|
const gate = lines.findIndex((line) => line.trim() === "Gate");
|
|
98
|
+
|
|
99
|
+
// Assert
|
|
84
100
|
expect(gate).toBeGreaterThan(0);
|
|
85
101
|
expect(lines[gate - 1]).toBe("");
|
|
86
102
|
});
|
|
87
103
|
|
|
88
104
|
test("consecutive list items stay tight", async () => {
|
|
105
|
+
// Arrange
|
|
89
106
|
const listPlan = "# T\n\n- one\n- two\n";
|
|
90
107
|
const s2 = server.core.sessionCreate({
|
|
91
108
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
@@ -93,6 +110,8 @@ describe("block spacing", () => {
|
|
|
93
110
|
});
|
|
94
111
|
const setup = await testRender(<App home={home} sessionId={s2.id} />, { width: 120, height: 30 });
|
|
95
112
|
await waitForText(setup, "one");
|
|
113
|
+
|
|
114
|
+
// Assert
|
|
96
115
|
const lines = setup.captureCharFrame().split("\n").map((line) => line.trimEnd());
|
|
97
116
|
const one = lines.findIndex((line) => line.includes("- one"));
|
|
98
117
|
expect(lines[one + 1]).toContain("- two");
|