@cueloop/client 0.1.0-alpha.21 → 0.1.0-alpha.23
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 +140 -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 +77 -183
- package/src/App.walk.test.tsx +63 -8
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +26 -0
- package/src/components/PlanSheet.stories.tsx +1 -1
- package/src/components/PlanSheet.tsx +13 -6
- 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 +27 -5
- package/src/key-bindings.ts +11 -1
- package/src/keymap.test.ts +97 -5
- package/src/keymap.ts +25 -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.23",
|
|
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.23",
|
|
11
|
+
"@cueloop/extension-api": "0.1.0-alpha.23",
|
|
12
|
+
"@cueloop/integration-obsidian": "0.1.0-alpha.23",
|
|
13
|
+
"@cueloop/schema": "0.1.0-alpha.23",
|
|
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,81 @@ 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
|
+
|
|
88
|
+
describe("edit affordance", () => {
|
|
89
|
+
test("the owner sees the Edit button", async () => {
|
|
90
|
+
// Arrange / Act
|
|
91
|
+
const owner = await renderApp();
|
|
92
|
+
|
|
93
|
+
// Assert
|
|
94
|
+
expect(owner.captureCharFrame()).toContain("Edit");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("a read-only viewer (a plan shared over ssh) never sees the Edit button", async () => {
|
|
98
|
+
// Arrange / Act
|
|
99
|
+
const viewer = await testRender(<App home={home} sessionId={session.id} readOnly />, { width: 120, height: 32 });
|
|
100
|
+
await waitForText(viewer, "cueloop");
|
|
101
|
+
|
|
102
|
+
// Assert
|
|
103
|
+
expect(viewer.captureCharFrame()).not.toContain("Edit");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
84
107
|
describe("selection feeds the annotation quote", () => {
|
|
85
108
|
test("v anchors, l extends, c composes with the char-precise quote", async () => {
|
|
109
|
+
// Arrange
|
|
86
110
|
const setup = await renderApp();
|
|
87
111
|
await toContextParagraph(setup);
|
|
112
|
+
|
|
113
|
+
// Act
|
|
88
114
|
await press(setup, "v");
|
|
89
115
|
await press(setup, "l"); // "The daemon"
|
|
90
116
|
await press(setup, "c");
|
|
117
|
+
|
|
118
|
+
// Assert
|
|
91
119
|
expect(setup.captureCharFrame()).toContain('comment on "The daemon"');
|
|
120
|
+
|
|
121
|
+
// Act
|
|
92
122
|
await type(setup, "Which daemon?");
|
|
93
123
|
await press(setup, "enter");
|
|
124
|
+
|
|
125
|
+
// Assert
|
|
94
126
|
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
95
127
|
const stored = server.core.sessionGet(session.id);
|
|
96
128
|
expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
|
|
97
129
|
});
|
|
98
130
|
|
|
99
131
|
test("a mouse drag over the text becomes the compose quote", async () => {
|
|
132
|
+
// Arrange
|
|
100
133
|
const setup = await renderApp();
|
|
101
134
|
const lines = setup.captureCharFrame().split("\n");
|
|
102
135
|
const row = lines.findIndex((line) => line.includes("persists sessions"));
|
|
103
136
|
const startColumn = lines[row]!.indexOf("persists");
|
|
104
137
|
expect(row).toBeGreaterThan(-1);
|
|
138
|
+
|
|
139
|
+
// Act
|
|
105
140
|
// the drag's end cell is exclusive: release one past the last character
|
|
106
141
|
await setup.mockMouse.drag(startColumn, row, startColumn + "persists sessions".length, row);
|
|
107
142
|
await settle(setup);
|
|
108
143
|
await press(setup, "c");
|
|
144
|
+
|
|
145
|
+
// Assert
|
|
109
146
|
expect(setup.captureCharFrame()).toContain('comment on "persists sessions"');
|
|
147
|
+
|
|
148
|
+
// Act
|
|
110
149
|
await type(setup, "sessions plural?");
|
|
111
150
|
await press(setup, "enter");
|
|
151
|
+
|
|
152
|
+
// Assert
|
|
112
153
|
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
113
154
|
const stored = server.core.sessionGet(session.id);
|
|
114
155
|
expect(stored.annotations[0]!.anchor.quote).toBe("persists sessions");
|
|
@@ -117,25 +158,38 @@ describe("selection feeds the annotation quote", () => {
|
|
|
117
158
|
|
|
118
159
|
describe("inline compose keeps the anchor painted", () => {
|
|
119
160
|
test("the selection stays painted while composing; cancel un-paints; save converts to the comment highlight", async () => {
|
|
161
|
+
// Arrange
|
|
120
162
|
const setup = await renderApp();
|
|
121
163
|
await toContextParagraph(setup);
|
|
164
|
+
|
|
165
|
+
// Act
|
|
122
166
|
await press(setup, "v");
|
|
123
167
|
await press(setup, "l");
|
|
124
168
|
await press(setup, "c");
|
|
169
|
+
|
|
170
|
+
// Assert
|
|
125
171
|
// compose open: the anchor is painted selection-style. The box can render
|
|
126
172
|
// a frame before the anchor repaint settles, so wait on the color itself.
|
|
127
173
|
expect(setup.captureCharFrame()).toContain("Save ⏎");
|
|
128
174
|
expect(setup.captureCharFrame()).toContain("Cancel esc");
|
|
129
175
|
await waitForState(setup, () => backgroundsOf(setup, "The daemon").includes(T.accent));
|
|
130
176
|
expect(backgroundsOf(setup, "The daemon")).toContain(T.accent);
|
|
177
|
+
|
|
178
|
+
// Act
|
|
131
179
|
// cancel un-paints (a bare ESC settles after the parser's escape window)
|
|
132
180
|
await press(setup, "escape");
|
|
181
|
+
|
|
182
|
+
// Assert
|
|
133
183
|
await waitForTextGone(setup, 'comment on "');
|
|
134
184
|
expect(backgroundsOf(setup, "The daemon")).not.toContain(T.accent);
|
|
185
|
+
|
|
186
|
+
// Act
|
|
135
187
|
// save converts the paint to the kind-colored annotation highlight
|
|
136
188
|
await press(setup, "c");
|
|
137
189
|
await type(setup, "Which daemon?");
|
|
138
190
|
await press(setup, "enter");
|
|
191
|
+
|
|
192
|
+
// Assert
|
|
139
193
|
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
140
194
|
await settle(setup);
|
|
141
195
|
// the whole cursor block is the anchor here; check the highlight landed
|
|
@@ -167,42 +221,65 @@ describe("compose newline convention", () => {
|
|
|
167
221
|
}
|
|
168
222
|
|
|
169
223
|
test("option+return inserts a newline; a bare return submits the multiline body", async () => {
|
|
224
|
+
// Arrange
|
|
170
225
|
const setup = await renderModifierAwareApp();
|
|
171
226
|
await toContextParagraph(setup);
|
|
172
227
|
await press(setup, "v");
|
|
173
228
|
await press(setup, "l");
|
|
174
229
|
await press(setup, "c");
|
|
175
230
|
await waitForText(setup, 'comment on "The daemon"');
|
|
231
|
+
|
|
232
|
+
// Act
|
|
176
233
|
await type(setup, "first line");
|
|
177
234
|
await pressReturnWith(setup, { meta: true }); // option/alt+return -> newline
|
|
178
235
|
await type(setup, "second line");
|
|
236
|
+
|
|
237
|
+
// Assert
|
|
179
238
|
// still composing: the newline did not submit
|
|
180
239
|
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
240
|
+
|
|
241
|
+
// Act
|
|
181
242
|
await press(setup, "enter"); // bare return -> save
|
|
243
|
+
|
|
244
|
+
// Assert
|
|
182
245
|
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
183
246
|
expect(server.core.sessionGet(session.id).annotations[0]!.body).toBe("first line\nsecond line");
|
|
184
247
|
});
|
|
185
248
|
|
|
186
249
|
test("shift+return still inserts a newline (existing muscle memory)", async () => {
|
|
250
|
+
// Arrange
|
|
187
251
|
const setup = await renderModifierAwareApp();
|
|
188
252
|
await toContextParagraph(setup);
|
|
189
253
|
await press(setup, "c");
|
|
254
|
+
|
|
255
|
+
// Act
|
|
190
256
|
await type(setup, "alpha");
|
|
191
257
|
await pressReturnWith(setup, { shift: true }); // shift+return -> newline
|
|
192
258
|
await type(setup, "beta");
|
|
259
|
+
|
|
260
|
+
// Assert
|
|
193
261
|
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
262
|
+
|
|
263
|
+
// Act
|
|
194
264
|
await press(setup, "enter");
|
|
265
|
+
|
|
266
|
+
// Assert
|
|
195
267
|
await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
|
|
196
268
|
expect(server.core.sessionGet(session.id).annotations[0]!.body).toBe("alpha\nbeta");
|
|
197
269
|
});
|
|
198
270
|
|
|
199
271
|
test("escape cancels the composer without saving", async () => {
|
|
272
|
+
// Arrange
|
|
200
273
|
const setup = await renderModifierAwareApp();
|
|
201
274
|
await toContextParagraph(setup);
|
|
202
275
|
await press(setup, "c");
|
|
203
276
|
await waitForText(setup, 'comment on "');
|
|
204
277
|
await type(setup, "never saved");
|
|
278
|
+
|
|
279
|
+
// Act
|
|
205
280
|
await press(setup, "escape");
|
|
281
|
+
|
|
282
|
+
// Assert
|
|
206
283
|
await waitForTextGone(setup, 'comment on "');
|
|
207
284
|
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
208
285
|
});
|
|
@@ -210,16 +287,21 @@ describe("compose newline convention", () => {
|
|
|
210
287
|
|
|
211
288
|
describe("the document selects, the rail edits", () => {
|
|
212
289
|
test("e edits the selected card in place; enter saves through the controller", async () => {
|
|
290
|
+
// Arrange
|
|
213
291
|
const setup = await renderApp();
|
|
214
292
|
await toContextParagraph(setup);
|
|
215
293
|
await press(setup, "c");
|
|
216
294
|
await type(setup, "Needs a citation.");
|
|
217
295
|
await press(setup, "enter");
|
|
218
296
|
await waitForText(setup, "COMMENT · pending");
|
|
297
|
+
|
|
298
|
+
// Act
|
|
219
299
|
// the saved card is selected; e turns its body into an input in place
|
|
220
300
|
await press(setup, "e");
|
|
221
301
|
await type(setup, " And a link.");
|
|
222
302
|
await press(setup, "enter");
|
|
303
|
+
|
|
304
|
+
// Assert
|
|
223
305
|
await waitForState(
|
|
224
306
|
setup,
|
|
225
307
|
() => server.core.sessionGet(session.id).annotations[0]!.body === "Needs a citation. And a link.",
|
|
@@ -227,6 +309,7 @@ describe("the document selects, the rail edits", () => {
|
|
|
227
309
|
});
|
|
228
310
|
|
|
229
311
|
test("x deletes the selected card and un-paints the document highlight", async () => {
|
|
312
|
+
// Arrange
|
|
230
313
|
const setup = await renderApp();
|
|
231
314
|
await toContextParagraph(setup);
|
|
232
315
|
await press(setup, "c");
|
|
@@ -235,28 +318,73 @@ describe("the document selects, the rail edits", () => {
|
|
|
235
318
|
await waitForText(setup, "COMMENT · pending");
|
|
236
319
|
expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
|
|
237
320
|
expect(backgroundsOf(setup, "persists sessions")).toContain(T.markCommentBg);
|
|
321
|
+
|
|
322
|
+
// Act
|
|
238
323
|
await press(setup, "x");
|
|
324
|
+
|
|
325
|
+
// Assert
|
|
239
326
|
await waitForTextGone(setup, "COMMENT · pending");
|
|
240
327
|
expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
|
|
241
328
|
expect(backgroundsOf(setup, "persists sessions")).not.toContain(T.markCommentBg);
|
|
242
329
|
});
|
|
243
330
|
});
|
|
244
331
|
|
|
332
|
+
describe("addressed annotations leave the open list", () => {
|
|
333
|
+
test("a revision that addresses one card hides it, un-paints it, and shows the count", async () => {
|
|
334
|
+
// Arrange: two annotations through the daemon, then a revision addressing one
|
|
335
|
+
const { makeAnchor, parseBlocks } = await import("@cueloop/schema");
|
|
336
|
+
const blocks = parseBlocks(PLAN);
|
|
337
|
+
const contextBlockIndex = blocks.findIndex((block) => block.text.startsWith("The daemon persists"));
|
|
338
|
+
server.core.sessionAnnotate(session.id, {
|
|
339
|
+
id: "a_settled",
|
|
340
|
+
kind: "comment",
|
|
341
|
+
anchor: makeAnchor(blocks, contextBlockIndex, 0, 10), // "The daemon"
|
|
342
|
+
body: "settled note",
|
|
343
|
+
});
|
|
344
|
+
server.core.sessionAnnotate(session.id, {
|
|
345
|
+
id: "a_open",
|
|
346
|
+
kind: "comment",
|
|
347
|
+
anchor: makeAnchor(blocks, contextBlockIndex, 11, 19), // "persists"
|
|
348
|
+
body: "still open note",
|
|
349
|
+
});
|
|
350
|
+
server.core.sessionSubmitRevision(session.id, PLAN, ["a_settled"]);
|
|
351
|
+
|
|
352
|
+
// Act
|
|
353
|
+
const setup = await renderApp();
|
|
354
|
+
await waitForText(setup, "still open note");
|
|
355
|
+
|
|
356
|
+
// Assert: the open card renders, the addressed one is gone behind the count
|
|
357
|
+
const frame = setup.captureCharFrame();
|
|
358
|
+
expect(frame).toContain("✓ 1 addressed by revision");
|
|
359
|
+
expect(frame).not.toContain("settled note");
|
|
360
|
+
expect(frame).toContain("Review (1)"); // the addressed card no longer counts as pending
|
|
361
|
+
expect(backgroundsOf(setup, "The daemon")).not.toContain(T.markCommentBg); // no highlight paint
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
245
365
|
describe("sheet header", () => {
|
|
246
366
|
test("shows the submitting agent, the revision, and the Edit word-button", async () => {
|
|
367
|
+
// Arrange
|
|
247
368
|
const setup = await renderApp();
|
|
369
|
+
|
|
370
|
+
// Assert
|
|
248
371
|
const frame = setup.captureCharFrame();
|
|
249
372
|
expect(frame).toContain("submitted by agent/worker-3 · revision 1");
|
|
250
373
|
expect(frame).toContain("Edit");
|
|
251
374
|
});
|
|
252
375
|
|
|
253
376
|
test("the Agent tab shows the submitter, status, and revision", async () => {
|
|
377
|
+
// Arrange
|
|
254
378
|
const setup = await renderApp();
|
|
255
379
|
const lines = setup.captureCharFrame().split("\n");
|
|
256
380
|
const tabRow = lines.findIndex((line) => line.includes("Review (0)"));
|
|
257
381
|
const tabColumn = lines[tabRow]!.indexOf("Agent");
|
|
382
|
+
|
|
383
|
+
// Act
|
|
258
384
|
await setup.mockMouse.click(tabColumn + 1, tabRow);
|
|
259
385
|
await setup.renderOnce();
|
|
386
|
+
|
|
387
|
+
// Assert
|
|
260
388
|
const frame = setup.captureCharFrame();
|
|
261
389
|
expect(frame).toContain("agent/worker-3");
|
|
262
390
|
expect(frame).toContain("status: pending");
|
|
@@ -271,16 +399,21 @@ describe("edit-exit reconciliation", () => {
|
|
|
271
399
|
Bun.spawnSync(["chmod", "+x", script]);
|
|
272
400
|
process.env.CUELOOP_EDITOR = script;
|
|
273
401
|
try {
|
|
402
|
+
// Arrange
|
|
274
403
|
const setup = await renderApp();
|
|
275
404
|
await toContextParagraph(setup);
|
|
276
405
|
await press(setup, "c");
|
|
277
406
|
await type(setup, "Anchor me to the doomed passage.");
|
|
278
407
|
await press(setup, "enter");
|
|
279
408
|
await waitForText(setup, "· pending");
|
|
409
|
+
|
|
410
|
+
// Act
|
|
280
411
|
// deselect the card so e reaches the editor hand-off, then edit
|
|
281
412
|
await press(setup, "escape");
|
|
282
413
|
await waitForTextGone(setup, "▸ COMMENT");
|
|
283
414
|
await press(setup, "e");
|
|
415
|
+
|
|
416
|
+
// Assert
|
|
284
417
|
await waitForText(setup, "1 annotation no longer match - the passage was removed.");
|
|
285
418
|
// the rail card flips to ORPHANED once the working-copy refresh lands
|
|
286
419
|
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");
|