@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.
Files changed (47) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +29 -5
  3. package/src/App.diff.test.tsx +20 -0
  4. package/src/App.export.test.tsx +12 -8
  5. package/src/App.plan-review.test.tsx +182 -7
  6. package/src/App.readonly.test.tsx +19 -5
  7. package/src/App.rendering.test.tsx +21 -2
  8. package/src/App.review-panel.test.tsx +120 -0
  9. package/src/App.submit-confirm.test.tsx +57 -6
  10. package/src/App.test.tsx +62 -5
  11. package/src/App.tsx +147 -185
  12. package/src/App.walk.test.tsx +63 -8
  13. package/src/clipboard.test.ts +23 -0
  14. package/src/clipboard.ts +26 -0
  15. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  16. package/src/components/AnnotationCard.tsx +66 -13
  17. package/src/components/ReviewPanel.stories.tsx +86 -0
  18. package/src/components/ReviewPanel.tsx +116 -0
  19. package/src/components/ReviewRail.tsx +30 -5
  20. package/src/components/__snapshots__/stories.test.tsx.snap +140 -0
  21. package/src/components/primitives/Card.tsx +2 -1
  22. package/src/components/primitives/Dialog.tsx +2 -1
  23. package/src/components/primitives/frame.test.ts +12 -0
  24. package/src/components/primitives/frame.ts +14 -0
  25. package/src/components/stories-app.tsx +2 -1
  26. package/src/components/stories.test.tsx +5 -0
  27. package/src/config.test.ts +113 -1
  28. package/src/config.ts +58 -22
  29. package/src/editor.test.ts +24 -0
  30. package/src/herdr.test.ts +4 -0
  31. package/src/index.ts +2 -0
  32. package/src/integrations.test.ts +12 -0
  33. package/src/intent-dispatch.test.ts +213 -0
  34. package/src/intent-dispatch.ts +270 -0
  35. package/src/key-bindings.test.ts +16 -5
  36. package/src/keymap.test.ts +141 -9
  37. package/src/keymap.ts +54 -7
  38. package/src/review-panel.test.ts +111 -0
  39. package/src/review-panel.ts +76 -0
  40. package/src/serve.test.ts +7 -0
  41. package/src/session-controller.ts +39 -4
  42. package/src/share.ts +47 -0
  43. package/src/view-diff.test.ts +11 -0
  44. package/src/view-diff.ts +6 -6
  45. package/src/view-plan.test.ts +81 -0
  46. package/src/view-plan.ts +3 -0
  47. 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.20",
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.20",
11
- "@cueloop/extension-api": "0.1.0-alpha.20",
12
- "@cueloop/integration-obsidian": "0.1.0-alpha.20",
13
- "@cueloop/schema": "0.1.0-alpha.20",
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
  });
@@ -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
  });
@@ -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
- // open the submit overlay, keep the default verdict (approve), submit
56
- await press(setup, "enter");
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
- await press(setup, "enter");
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
- const lines = (await frame()).split("\n").map((line) => line.trimEnd());
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
- const lines = (await frame()).split("\n").map((line) => line.trimEnd());
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");