@cueloop/client 0.1.0-alpha.21 → 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 +121 -7
- package/src/App.readonly.test.tsx +19 -5
- package/src/App.rendering.test.tsx +15 -0
- package/src/App.review-panel.test.tsx +29 -6
- package/src/App.submit-confirm.test.tsx +57 -6
- package/src/App.test.tsx +62 -5
- package/src/App.tsx +71 -182
- package/src/App.walk.test.tsx +63 -8
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +26 -0
- package/src/components/ReviewRail.tsx +16 -4
- package/src/components/stories.test.tsx +5 -0
- package/src/config.test.ts +62 -0
- package/src/config.ts +1 -0
- 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 +97 -5
- package/src/keymap.ts +24 -2
- package/src/serve.test.ts +7 -0
- package/src/session-controller.ts +22 -3
- 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
|
|
@@ -167,42 +202,65 @@ describe("compose newline convention", () => {
|
|
|
167
202
|
}
|
|
168
203
|
|
|
169
204
|
test("option+return inserts a newline; a bare return submits the multiline body", async () => {
|
|
205
|
+
// Arrange
|
|
170
206
|
const setup = await renderModifierAwareApp();
|
|
171
207
|
await toContextParagraph(setup);
|
|
172
208
|
await press(setup, "v");
|
|
173
209
|
await press(setup, "l");
|
|
174
210
|
await press(setup, "c");
|
|
175
211
|
await waitForText(setup, 'comment on "The daemon"');
|
|
212
|
+
|
|
213
|
+
// Act
|
|
176
214
|
await type(setup, "first line");
|
|
177
215
|
await pressReturnWith(setup, { meta: true }); // option/alt+return -> newline
|
|
178
216
|
await type(setup, "second line");
|
|
217
|
+
|
|
218
|
+
// Assert
|
|
179
219
|
// still composing: the newline did not submit
|
|
180
220
|
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
221
|
+
|
|
222
|
+
// Act
|
|
181
223
|
await press(setup, "enter"); // bare return -> save
|
|
224
|
+
|
|
225
|
+
// Assert
|
|
182
226
|
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
183
227
|
expect(server.core.sessionGet(session.id).annotations[0]!.body).toBe("first line\nsecond line");
|
|
184
228
|
});
|
|
185
229
|
|
|
186
230
|
test("shift+return still inserts a newline (existing muscle memory)", async () => {
|
|
231
|
+
// Arrange
|
|
187
232
|
const setup = await renderModifierAwareApp();
|
|
188
233
|
await toContextParagraph(setup);
|
|
189
234
|
await press(setup, "c");
|
|
235
|
+
|
|
236
|
+
// Act
|
|
190
237
|
await type(setup, "alpha");
|
|
191
238
|
await pressReturnWith(setup, { shift: true }); // shift+return -> newline
|
|
192
239
|
await type(setup, "beta");
|
|
240
|
+
|
|
241
|
+
// Assert
|
|
193
242
|
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
243
|
+
|
|
244
|
+
// Act
|
|
194
245
|
await press(setup, "enter");
|
|
246
|
+
|
|
247
|
+
// Assert
|
|
195
248
|
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
196
249
|
expect(server.core.sessionGet(session.id).annotations[0]!.body).toBe("alpha\nbeta");
|
|
197
250
|
});
|
|
198
251
|
|
|
199
252
|
test("escape cancels the composer without saving", async () => {
|
|
253
|
+
// Arrange
|
|
200
254
|
const setup = await renderModifierAwareApp();
|
|
201
255
|
await toContextParagraph(setup);
|
|
202
256
|
await press(setup, "c");
|
|
203
257
|
await waitForText(setup, 'comment on "');
|
|
204
258
|
await type(setup, "never saved");
|
|
259
|
+
|
|
260
|
+
// Act
|
|
205
261
|
await press(setup, "escape");
|
|
262
|
+
|
|
263
|
+
// Assert
|
|
206
264
|
await waitForTextGone(setup, 'comment on "');
|
|
207
265
|
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
208
266
|
});
|
|
@@ -210,16 +268,21 @@ describe("compose newline convention", () => {
|
|
|
210
268
|
|
|
211
269
|
describe("the document selects, the rail edits", () => {
|
|
212
270
|
test("e edits the selected card in place; enter saves through the controller", async () => {
|
|
271
|
+
// Arrange
|
|
213
272
|
const setup = await renderApp();
|
|
214
273
|
await toContextParagraph(setup);
|
|
215
274
|
await press(setup, "c");
|
|
216
275
|
await type(setup, "Needs a citation.");
|
|
217
276
|
await press(setup, "enter");
|
|
218
277
|
await waitForText(setup, "COMMENT · pending");
|
|
278
|
+
|
|
279
|
+
// Act
|
|
219
280
|
// the saved card is selected; e turns its body into an input in place
|
|
220
281
|
await press(setup, "e");
|
|
221
282
|
await type(setup, " And a link.");
|
|
222
283
|
await press(setup, "enter");
|
|
284
|
+
|
|
285
|
+
// Assert
|
|
223
286
|
await waitForState(
|
|
224
287
|
setup,
|
|
225
288
|
() => server.core.sessionGet(session.id).annotations[0]!.body === "Needs a citation. And a link.",
|
|
@@ -227,6 +290,7 @@ describe("the document selects, the rail edits", () => {
|
|
|
227
290
|
});
|
|
228
291
|
|
|
229
292
|
test("x deletes the selected card and un-paints the document highlight", async () => {
|
|
293
|
+
// Arrange
|
|
230
294
|
const setup = await renderApp();
|
|
231
295
|
await toContextParagraph(setup);
|
|
232
296
|
await press(setup, "c");
|
|
@@ -235,28 +299,73 @@ describe("the document selects, the rail edits", () => {
|
|
|
235
299
|
await waitForText(setup, "COMMENT · pending");
|
|
236
300
|
expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
|
|
237
301
|
expect(backgroundsOf(setup, "persists sessions")).toContain(T.markCommentBg);
|
|
302
|
+
|
|
303
|
+
// Act
|
|
238
304
|
await press(setup, "x");
|
|
305
|
+
|
|
306
|
+
// Assert
|
|
239
307
|
await waitForTextGone(setup, "COMMENT · pending");
|
|
240
308
|
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
241
309
|
expect(backgroundsOf(setup, "persists sessions")).not.toContain(T.markCommentBg);
|
|
242
310
|
});
|
|
243
311
|
});
|
|
244
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
|
+
|
|
245
346
|
describe("sheet header", () => {
|
|
246
347
|
test("shows the submitting agent, the revision, and the Edit word-button", async () => {
|
|
348
|
+
// Arrange
|
|
247
349
|
const setup = await renderApp();
|
|
350
|
+
|
|
351
|
+
// Assert
|
|
248
352
|
const frame = setup.captureCharFrame();
|
|
249
353
|
expect(frame).toContain("submitted by agent/worker-3 · revision 1");
|
|
250
354
|
expect(frame).toContain("Edit");
|
|
251
355
|
});
|
|
252
356
|
|
|
253
357
|
test("the Agent tab shows the submitter, status, and revision", async () => {
|
|
358
|
+
// Arrange
|
|
254
359
|
const setup = await renderApp();
|
|
255
360
|
const lines = setup.captureCharFrame().split("\n");
|
|
256
361
|
const tabRow = lines.findIndex((line) => line.includes("Review (0)"));
|
|
257
362
|
const tabColumn = lines[tabRow]!.indexOf("Agent");
|
|
363
|
+
|
|
364
|
+
// Act
|
|
258
365
|
await setup.mockMouse.click(tabColumn + 1, tabRow);
|
|
259
366
|
await setup.renderOnce();
|
|
367
|
+
|
|
368
|
+
// Assert
|
|
260
369
|
const frame = setup.captureCharFrame();
|
|
261
370
|
expect(frame).toContain("agent/worker-3");
|
|
262
371
|
expect(frame).toContain("status: pending");
|
|
@@ -271,16 +380,21 @@ describe("edit-exit reconciliation", () => {
|
|
|
271
380
|
Bun.spawnSync(["chmod", "+x", script]);
|
|
272
381
|
process.env.CUELOOP_EDITOR = script;
|
|
273
382
|
try {
|
|
383
|
+
// Arrange
|
|
274
384
|
const setup = await renderApp();
|
|
275
385
|
await toContextParagraph(setup);
|
|
276
386
|
await press(setup, "c");
|
|
277
387
|
await type(setup, "Anchor me to the doomed passage.");
|
|
278
388
|
await press(setup, "enter");
|
|
279
389
|
await waitForText(setup, "· pending");
|
|
390
|
+
|
|
391
|
+
// Act
|
|
280
392
|
// deselect the card so e reaches the editor hand-off, then edit
|
|
281
393
|
await press(setup, "escape");
|
|
282
394
|
await waitForTextGone(setup, "▸ COMMENT");
|
|
283
395
|
await press(setup, "e");
|
|
396
|
+
|
|
397
|
+
// Assert
|
|
284
398
|
await waitForText(setup, "1 annotation no longer match - the passage was removed.");
|
|
285
399
|
// the rail card flips to ORPHANED once the working-copy refresh lands
|
|
286
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,13 +57,19 @@ 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
|
});
|
|
@@ -74,22 +80,29 @@ describe("block spacing", () => {
|
|
|
74
80
|
const planColumn = (line: string): string => line.split("│")[0]!.trimEnd();
|
|
75
81
|
|
|
76
82
|
test("a code block never glues to the list item above it", async () => {
|
|
83
|
+
// Arrange
|
|
77
84
|
const lines = (await frame()).split("\n").map(planColumn);
|
|
78
85
|
const listItemLineIndex = lines.findIndex((line) => line.includes("wire the duck over MQTT"));
|
|
79
86
|
const codeHeader = lines.findIndex((line) => line.trim().startsWith("tsx"));
|
|
87
|
+
|
|
88
|
+
// Assert
|
|
80
89
|
expect(listItemLineIndex).toBeGreaterThan(-1);
|
|
81
90
|
expect(codeHeader).toBeGreaterThan(listItemLineIndex);
|
|
82
91
|
expect(lines.slice(listItemLineIndex + 1, codeHeader).some((line) => line === "")).toBe(true);
|
|
83
92
|
});
|
|
84
93
|
|
|
85
94
|
test("a heading never sits directly on the previous block", async () => {
|
|
95
|
+
// Arrange
|
|
86
96
|
const lines = (await frame()).split("\n").map(planColumn);
|
|
87
97
|
const gate = lines.findIndex((line) => line.trim() === "Gate");
|
|
98
|
+
|
|
99
|
+
// Assert
|
|
88
100
|
expect(gate).toBeGreaterThan(0);
|
|
89
101
|
expect(lines[gate - 1]).toBe("");
|
|
90
102
|
});
|
|
91
103
|
|
|
92
104
|
test("consecutive list items stay tight", async () => {
|
|
105
|
+
// Arrange
|
|
93
106
|
const listPlan = "# T\n\n- one\n- two\n";
|
|
94
107
|
const s2 = server.core.sessionCreate({
|
|
95
108
|
workspace: { repoRoot: "/repo", branch: "main" },
|
|
@@ -97,6 +110,8 @@ describe("block spacing", () => {
|
|
|
97
110
|
});
|
|
98
111
|
const setup = await testRender(<App home={home} sessionId={s2.id} />, { width: 120, height: 30 });
|
|
99
112
|
await waitForText(setup, "one");
|
|
113
|
+
|
|
114
|
+
// Assert
|
|
100
115
|
const lines = setup.captureCharFrame().split("\n").map((line) => line.trimEnd());
|
|
101
116
|
const one = lines.findIndex((line) => line.includes("- one"));
|
|
102
117
|
expect(lines[one + 1]).toContain("- two");
|