@cueloop/client 0.1.0-alpha.2 → 0.1.0-alpha.20

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 (84) hide show
  1. package/package.json +17 -11
  2. package/src/App.autoclose.test.tsx +109 -0
  3. package/src/App.diff.test.tsx +5 -18
  4. package/src/App.export.test.tsx +8 -13
  5. package/src/App.plan-review.test.tsx +232 -0
  6. package/src/App.readonly.test.tsx +6 -21
  7. package/src/App.rendering.test.tsx +100 -0
  8. package/src/App.submit-confirm.test.tsx +165 -0
  9. package/src/App.test.tsx +20 -58
  10. package/src/App.tsx +0 -0
  11. package/src/App.walk.test.tsx +208 -0
  12. package/src/components/AnnotationCard.stories.tsx +75 -0
  13. package/src/components/AnnotationCard.tsx +136 -0
  14. package/src/components/Breadcrumb.stories.tsx +19 -0
  15. package/src/components/Breadcrumb.tsx +38 -0
  16. package/src/components/CodeBlock.stories.tsx +25 -0
  17. package/src/components/CodeBlock.tsx +68 -0
  18. package/src/components/CompletionOverlay.stories.tsx +23 -0
  19. package/src/components/CompletionOverlay.tsx +49 -0
  20. package/src/components/ComposeBar.stories.tsx +18 -0
  21. package/src/components/ComposeBar.tsx +54 -0
  22. package/src/components/ConfirmCard.stories.tsx +37 -0
  23. package/src/components/ConfirmCard.tsx +117 -0
  24. package/src/components/DiffSheet.stories.tsx +44 -0
  25. package/src/components/DiffSheet.tsx +243 -0
  26. package/src/components/InboxList.stories.tsx +18 -0
  27. package/src/components/InboxList.tsx +42 -0
  28. package/src/components/PlanSheet.stories.tsx +81 -0
  29. package/src/components/PlanSheet.tsx +309 -0
  30. package/src/components/ReviewRail.stories.tsx +103 -0
  31. package/src/components/ReviewRail.tsx +158 -0
  32. package/src/components/SettingsDialog.stories.tsx +63 -0
  33. package/src/components/SettingsDialog.tsx +132 -0
  34. package/src/components/SettingsRows.stories.tsx +18 -0
  35. package/src/components/SettingsRows.tsx +92 -0
  36. package/src/components/WalkWizard.stories.tsx +89 -0
  37. package/src/components/WalkWizard.tsx +146 -0
  38. package/src/components/__snapshots__/stories.test.tsx.snap +1319 -0
  39. package/src/components/primitives/Button.stories.tsx +32 -0
  40. package/src/components/primitives/Button.tsx +44 -0
  41. package/src/components/primitives/Card.stories.tsx +25 -0
  42. package/src/components/primitives/Card.tsx +58 -0
  43. package/src/components/primitives/Dialog.stories.tsx +24 -0
  44. package/src/components/primitives/Dialog.tsx +52 -0
  45. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  46. package/src/components/primitives/StatusBar.tsx +22 -0
  47. package/src/components/primitives/Tabs.stories.tsx +29 -0
  48. package/src/components/primitives/Tabs.tsx +97 -0
  49. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  50. package/src/components/primitives/Toolbar.tsx +14 -0
  51. package/src/components/stories-app.tsx +94 -0
  52. package/src/components/stories.test.tsx +51 -0
  53. package/src/components/story-fixtures.ts +90 -0
  54. package/src/components/story.ts +66 -0
  55. package/src/components/syntax-highlight.ts +57 -0
  56. package/src/components/theme-context.stories.tsx +41 -0
  57. package/src/components/theme-context.tsx +30 -0
  58. package/src/components/truncate-text.ts +6 -0
  59. package/src/config.test.ts +54 -29
  60. package/src/config.ts +53 -13
  61. package/src/editor.test.ts +110 -0
  62. package/src/editor.ts +101 -13
  63. package/src/herdr.test.ts +37 -0
  64. package/src/herdr.ts +21 -0
  65. package/src/integrations.test.ts +29 -0
  66. package/src/integrations.ts +28 -0
  67. package/src/key-bindings.test.ts +120 -0
  68. package/src/key-bindings.ts +353 -0
  69. package/src/keymap.test.ts +307 -0
  70. package/src/keymap.ts +204 -0
  71. package/src/run.ts +9 -5
  72. package/src/serve.test.ts +6 -6
  73. package/src/serve.ts +7 -7
  74. package/src/session-controller.ts +496 -0
  75. package/src/test-support.ts +87 -0
  76. package/src/theme.ts +23 -1
  77. package/src/view-diff.test.ts +8 -8
  78. package/src/view-diff.ts +22 -22
  79. package/src/view-plan.test.ts +179 -0
  80. package/src/view-plan.ts +362 -0
  81. package/src/walk.test.ts +110 -0
  82. package/src/walk.ts +65 -0
  83. package/src/view.test.ts +0 -157
  84. package/src/view.ts +0 -0
package/package.json CHANGED
@@ -1,21 +1,22 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.20",
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.2",
11
- "@cueloop/extension-api": "0.1.0-alpha.2",
12
- "@cueloop/integration-obsidian": "0.1.0-alpha.2",
13
- "@cueloop/schema": "0.1.0-alpha.2",
14
- "@opentui/core": "^0.4.3",
15
- "@opentui/react": "^0.4.3",
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",
14
+ "@opentui/core": "^0.5.1",
15
+ "@opentui/keymap": "0.5.1",
16
+ "@opentui/react": "^0.5.1",
17
+ "@opentui/ssh": "0.5.1",
16
18
  "@pierre/diffs": "^1.3.5",
17
- "react": "^19.2.0",
18
- "@opentui/ssh": "0.4.5"
19
+ "react": "^19.2.0"
19
20
  },
20
21
  "devDependencies": {
21
22
  "@types/react": "^19.2.0",
@@ -36,5 +37,10 @@
36
37
  "type": "git",
37
38
  "url": "git+https://github.com/mmurakaru/cueloop.git",
38
39
  "directory": "packages/client"
39
- }
40
- }
40
+ },
41
+ "homepage": "https://github.com/mmurakaru/cueloop#readme",
42
+ "bugs": {
43
+ "url": "https://github.com/mmurakaru/cueloop/issues"
44
+ },
45
+ "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail"
46
+ }
@@ -0,0 +1,109 @@
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
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import React from "react";
12
+ import { testRender } from "@opentui/react/test-utils";
13
+ import { ManualClock } from "@opentui/core/testing";
14
+ import { DaemonServer } from "@cueloop/daemon";
15
+ import type { ReviewSession } from "@cueloop/schema";
16
+ import { App } from "./App";
17
+ import { press, settle, waitForText } from "./test-support";
18
+
19
+ const PLAN = "# Plan\n\nShip the thing.\n";
20
+
21
+ let home: string;
22
+ let server: DaemonServer;
23
+ let session: ReviewSession;
24
+ let configPath: string;
25
+ let clock: ManualClock;
26
+
27
+ beforeEach(() => {
28
+ home = mkdtempSync(join(tmpdir(), "cueloop-close-"));
29
+ configPath = join(home, "config.toml");
30
+ process.env.CUELOOP_CONFIG = configPath;
31
+ server = new DaemonServer({ home, idleExitMs: 0 });
32
+ server.start();
33
+ session = server.core.sessionCreate({
34
+ workspace: { repoRoot: "/repo", branch: "main" },
35
+ artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
36
+ });
37
+ clock = new ManualClock();
38
+ });
39
+ afterEach(() => {
40
+ delete process.env.CUELOOP_CONFIG;
41
+ server.stop();
42
+ rmSync(home, { recursive: true, force: true });
43
+ });
44
+
45
+ async function renderApp(onExit?: (code: number) => void) {
46
+ const setup = await testRender(<App home={home} sessionId={session.id} onExit={onExit} clock={clock} />, {
47
+ width: 120,
48
+ height: 30,
49
+ });
50
+ await waitForText(setup, "cueloop");
51
+ return setup;
52
+ }
53
+
54
+ type Setup = Awaited<ReturnType<typeof renderApp>>;
55
+
56
+ async function submitApprove(setup: Setup): Promise<void> {
57
+ await press(setup, "enter"); // open submit (approve default: no pending items)
58
+ await press(setup, "enter"); // confirm
59
+ await waitForText(setup, "review approved");
60
+ }
61
+
62
+ describe("completion overlay", () => {
63
+ test("submit with auto-close off counts down from 5; ⏎ exits now", async () => {
64
+ let exited = -1;
65
+ const setup = await renderApp((code) => (exited = code));
66
+ await submitApprove(setup);
67
+ const frame = await waitForText(setup, "closing in 5s");
68
+ expect(frame).toContain("review approved");
69
+ expect(frame).toContain("close [return]");
70
+ expect(frame).toContain("return to plan [esc]");
71
+ await press(setup, "enter");
72
+ expect(exited).toBe(0);
73
+ });
74
+
75
+ test("a remembers the countdown as the persisted default", async () => {
76
+ const setup = await renderApp();
77
+ await submitApprove(setup);
78
+ await waitForText(setup, "closing in 5s");
79
+ await press(setup, "a");
80
+ expect(existsSync(configPath)).toBe(true);
81
+ expect(readFileSync(configPath, "utf8")).toContain("auto_close = 5");
82
+ // the countdown keeps running; each second is one manual-clock tick
83
+ clock.advance(1000);
84
+ await waitForText(setup, "closing in 4s");
85
+ });
86
+
87
+ test("esc dismisses to the resolved read-only view", async () => {
88
+ const setup = await renderApp();
89
+ await submitApprove(setup);
90
+ await waitForText(setup, "closing in 5s");
91
+ // a bare ESC sits in the input parser's escape-sequence disambiguation
92
+ // window before it is delivered - the frame wait absorbs it
93
+ await press(setup, "escape");
94
+ const frame = await waitForText(setup, "resolved: approve");
95
+ expect(frame).not.toContain("closing in");
96
+ });
97
+
98
+ test("configured auto_close counts down and exits without interaction", async () => {
99
+ writeFileSync(configPath, "[ui]\nauto_close = 1\n");
100
+ let exited = -1;
101
+ const setup = await renderApp((code) => (exited = code));
102
+ await submitApprove(setup);
103
+ await waitForText(setup, "closing in 1s");
104
+ expect(exited).toBe(-1);
105
+ clock.advance(1000);
106
+ await settle(setup);
107
+ expect(exited).toBe(0);
108
+ });
109
+ });
@@ -9,6 +9,7 @@ import { testRender } from "@opentui/react/test-utils";
9
9
  import { DaemonServer } from "@cueloop/daemon";
10
10
  import type { ReviewSession } from "@cueloop/schema";
11
11
  import { App } from "./App";
12
+ import { press, waitForText } from "./test-support";
12
13
 
13
14
  const PATCH = `diff --git a/src/store.ts b/src/store.ts
14
15
  index 111..222 100644
@@ -41,22 +42,10 @@ afterEach(() => {
41
42
 
42
43
  async function renderApp() {
43
44
  const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 30 });
44
- for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
45
- await Bun.sleep(25);
46
- await setup.renderOnce();
47
- }
48
- await setup.renderOnce();
45
+ await waitForText(setup, "cueloop");
49
46
  return setup;
50
47
  }
51
48
 
52
- type Setup = Awaited<ReturnType<typeof renderApp>>;
53
- async function press(setup: Setup, k: string): Promise<void> {
54
- if (k === "enter") setup.mockInput.pressKey("RETURN");
55
- else await setup.mockInput.typeText(k);
56
- await Bun.sleep(15);
57
- await setup.renderOnce();
58
- }
59
-
60
49
  describe("diff review", () => {
61
50
  test("renders file header, hunks, and signed lines", async () => {
62
51
  const setup = await renderApp();
@@ -75,17 +64,15 @@ describe("diff review", () => {
75
64
  expect(setup.captureCharFrame()).toContain("COMMENT ON");
76
65
  await setup.mockInput.typeText("Map needs an eviction story.");
77
66
  await press(setup, "enter");
78
- await Bun.sleep(80);
79
- await setup.renderOnce();
67
+ // inline annotation card rendered under the line
68
+ await waitForText(setup, "◆ Map needs an eviction story.");
80
69
  const stored = server.core.sessionGet(session.id);
81
70
  expect(stored.annotations.length).toBe(1);
82
71
  expect(stored.annotations[0]!.anchor.quote).toContain("new Map()");
83
- // inline annotation card rendered under the line
84
- expect(setup.captureCharFrame()).toContain("◆ Map needs an eviction story.");
85
72
 
86
73
  await press(setup, "enter"); // submit
87
74
  await press(setup, "enter"); // confirm request_changes
88
- await Bun.sleep(100);
75
+ await waitForText(setup, "feedback sent");
89
76
  const resolved = server.core.sessionGet(session.id);
90
77
  expect(resolved.verdict!.feedback).toContain("new Map()");
91
78
  expect(resolved.verdict!.feedback).toContain("Map needs an eviction story.");
@@ -13,6 +13,7 @@ import { testRender } from "@opentui/react/test-utils";
13
13
  import { DaemonServer } from "@cueloop/daemon";
14
14
  import type { ReviewSession } from "@cueloop/schema";
15
15
  import { App } from "./App";
16
+ import { press, waitForState, waitForText } from "./test-support";
16
17
 
17
18
  const PLAN = `# Migration Plan
18
19
 
@@ -50,22 +51,16 @@ afterEach(() => {
50
51
  describe("obsidian export on resolve", () => {
51
52
  test("submitting a review writes the plan into the vault and shows the path", async () => {
52
53
  const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 32 });
53
- for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
54
- await Bun.sleep(25);
55
- await setup.renderOnce();
56
- }
54
+ await waitForText(setup, "cueloop");
57
55
  // open the submit overlay, keep the default verdict (approve), submit
58
- setup.mockInput.pressKey("RETURN");
59
- await Bun.sleep(15);
60
- await setup.renderOnce();
56
+ await press(setup, "enter");
61
57
  expect(setup.captureCharFrame()).toContain("verdict");
62
- setup.mockInput.pressKey("RETURN");
58
+ await press(setup, "enter");
59
+ // the export is an async round-trip after resolve; the status line lands
60
+ // on a later render than the file write
63
61
  const dir = join(vault, "cueloop");
64
- for (let i = 0; i < 40 && !existsSync(dir); i++) {
65
- await Bun.sleep(25);
66
- await setup.renderOnce();
67
- }
68
- await setup.renderOnce();
62
+ await waitForState(setup, () => existsSync(dir));
63
+ await waitForText(setup, "exported to");
69
64
 
70
65
  const notes = readdirSync(dir);
71
66
  expect(notes.length).toBe(1);
@@ -0,0 +1,232 @@
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
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
+ import { mkdtempSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import React from "react";
14
+ import { testRender } from "@opentui/react/test-utils";
15
+ import { DaemonServer } from "@cueloop/daemon";
16
+ import type { ReviewSession } from "@cueloop/schema";
17
+ import { App } from "./App";
18
+ import { DARK as T } from "./theme";
19
+ import { press, settle, typeText as type, waitForState, waitForText, waitForTextGone } from "./test-support";
20
+
21
+ const PLAN = `# Migration Plan
22
+
23
+ ## Context
24
+
25
+ The daemon persists sessions to disk atomically.
26
+
27
+ ## Steps
28
+
29
+ - move the store
30
+ - add recovery
31
+ `;
32
+
33
+ let home: string;
34
+ let server: DaemonServer;
35
+ let session: ReviewSession;
36
+
37
+ beforeEach(() => {
38
+ home = mkdtempSync(join(tmpdir(), "cueloop-plan-review-"));
39
+ server = new DaemonServer({ home, idleExitMs: 0 });
40
+ server.start();
41
+ session = server.core.sessionCreate({
42
+ workspace: { repoRoot: "/repo", branch: "main" },
43
+ artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md", agent: "agent/worker-3" } },
44
+ });
45
+ });
46
+ afterEach(() => {
47
+ server.stop();
48
+ rmSync(home, { recursive: true, force: true });
49
+ });
50
+
51
+ async function renderApp() {
52
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
53
+ width: 120,
54
+ height: 32,
55
+ });
56
+ await waitForText(setup, "cueloop");
57
+ return setup;
58
+ }
59
+
60
+ type Setup = Awaited<ReturnType<typeof renderApp>>;
61
+
62
+ /**
63
+ * Background colors (hex) of every styled span containing the needle - the
64
+ * document highlight and the rail's quote excerpt can both match.
65
+ */
66
+ function backgroundsOf(setup: Setup, needle: string): string[] {
67
+ const backgrounds: string[] = [];
68
+ for (const line of setup.captureSpans().lines) {
69
+ for (const span of line.spans) {
70
+ if (!span.text.includes(needle)) continue;
71
+ const [red, green, blue] = span.bg.toInts();
72
+ backgrounds.push("#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""));
73
+ }
74
+ }
75
+ return backgrounds;
76
+ }
77
+
78
+ /** Move the cursor to "The daemon persists sessions to disk atomically." */
79
+ async function toContextParagraph(setup: Setup): Promise<void> {
80
+ await press(setup, "j");
81
+ await press(setup, "j");
82
+ }
83
+
84
+ describe("selection feeds the annotation quote", () => {
85
+ test("v anchors, l extends, c composes with the char-precise quote", async () => {
86
+ const setup = await renderApp();
87
+ await toContextParagraph(setup);
88
+ await press(setup, "v");
89
+ await press(setup, "l"); // "The daemon"
90
+ await press(setup, "c");
91
+ expect(setup.captureCharFrame()).toContain('comment on "The daemon"');
92
+ await type(setup, "Which daemon?");
93
+ await press(setup, "enter");
94
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
95
+ const stored = server.core.sessionGet(session.id);
96
+ expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
97
+ });
98
+
99
+ test("a mouse drag over the text becomes the compose quote", async () => {
100
+ const setup = await renderApp();
101
+ const lines = setup.captureCharFrame().split("\n");
102
+ const row = lines.findIndex((line) => line.includes("persists sessions"));
103
+ const startColumn = lines[row]!.indexOf("persists");
104
+ expect(row).toBeGreaterThan(-1);
105
+ // the drag's end cell is exclusive: release one past the last character
106
+ await setup.mockMouse.drag(startColumn, row, startColumn + "persists sessions".length, row);
107
+ await settle(setup);
108
+ await press(setup, "c");
109
+ expect(setup.captureCharFrame()).toContain('comment on "persists sessions"');
110
+ await type(setup, "sessions plural?");
111
+ await press(setup, "enter");
112
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
113
+ const stored = server.core.sessionGet(session.id);
114
+ expect(stored.annotations[0]!.anchor.quote).toBe("persists sessions");
115
+ });
116
+ });
117
+
118
+ describe("inline compose keeps the anchor painted", () => {
119
+ test("the selection stays painted while composing; cancel un-paints; save converts to the comment highlight", async () => {
120
+ const setup = await renderApp();
121
+ await toContextParagraph(setup);
122
+ await press(setup, "v");
123
+ await press(setup, "l");
124
+ await press(setup, "c");
125
+ // compose open: the anchor is painted selection-style. The box can render
126
+ // a frame before the anchor repaint settles, so wait on the color itself.
127
+ expect(setup.captureCharFrame()).toContain("Save ⏎");
128
+ expect(setup.captureCharFrame()).toContain("Cancel esc");
129
+ await waitForState(setup, () => backgroundsOf(setup, "The daemon").includes(T.accent));
130
+ expect(backgroundsOf(setup, "The daemon")).toContain(T.accent);
131
+ // cancel un-paints (a bare ESC settles after the parser's escape window)
132
+ await press(setup, "escape");
133
+ await waitForTextGone(setup, 'comment on "');
134
+ expect(backgroundsOf(setup, "The daemon")).not.toContain(T.accent);
135
+ // save converts the paint to the kind-colored annotation highlight
136
+ await press(setup, "c");
137
+ await type(setup, "Which daemon?");
138
+ await press(setup, "enter");
139
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
140
+ await settle(setup);
141
+ // the whole cursor block is the anchor here; check the highlight landed
142
+ const stored = server.core.sessionGet(session.id);
143
+ expect(stored.annotations.length).toBe(1);
144
+ expect(backgroundsOf(setup, stored.annotations[0]!.anchor.quote.slice(0, 20))).toContain(T.markCommentBg);
145
+ // renderApp plus this many frame-waits grazes the 5s default on a loaded CI
146
+ // runner; the sibling walk resume test carries the same budget.
147
+ }, 15_000);
148
+ });
149
+
150
+ describe("the document selects, the rail edits", () => {
151
+ test("e edits the selected card in place; enter saves through the controller", async () => {
152
+ const setup = await renderApp();
153
+ await toContextParagraph(setup);
154
+ await press(setup, "c");
155
+ await type(setup, "Needs a citation.");
156
+ await press(setup, "enter");
157
+ await waitForText(setup, "COMMENT · pending");
158
+ // the saved card is selected; e turns its body into an input in place
159
+ await press(setup, "e");
160
+ await type(setup, " And a link.");
161
+ await press(setup, "enter");
162
+ await waitForState(
163
+ setup,
164
+ () => server.core.sessionGet(session.id).annotations[0]!.body === "Needs a citation. And a link.",
165
+ );
166
+ });
167
+
168
+ test("x deletes the selected card and un-paints the document highlight", async () => {
169
+ const setup = await renderApp();
170
+ await toContextParagraph(setup);
171
+ await press(setup, "c");
172
+ await type(setup, "Delete me.");
173
+ await press(setup, "enter");
174
+ await waitForText(setup, "COMMENT · pending");
175
+ expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
176
+ expect(backgroundsOf(setup, "persists sessions")).toContain(T.markCommentBg);
177
+ await press(setup, "x");
178
+ await waitForTextGone(setup, "COMMENT · pending");
179
+ expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
180
+ expect(backgroundsOf(setup, "persists sessions")).not.toContain(T.markCommentBg);
181
+ });
182
+ });
183
+
184
+ describe("sheet header", () => {
185
+ test("shows the submitting agent, the revision, and the Edit word-button", async () => {
186
+ const setup = await renderApp();
187
+ const frame = setup.captureCharFrame();
188
+ expect(frame).toContain("submitted by agent/worker-3 · revision 1");
189
+ expect(frame).toContain("Edit");
190
+ });
191
+
192
+ test("the Agent tab shows the submitter, status, and revision", async () => {
193
+ const setup = await renderApp();
194
+ const lines = setup.captureCharFrame().split("\n");
195
+ const tabRow = lines.findIndex((line) => line.includes("Review (0)"));
196
+ const tabColumn = lines[tabRow]!.indexOf("Agent");
197
+ await setup.mockMouse.click(tabColumn + 1, tabRow);
198
+ await setup.renderOnce();
199
+ const frame = setup.captureCharFrame();
200
+ expect(frame).toContain("agent/worker-3");
201
+ expect(frame).toContain("status: pending");
202
+ expect(frame).toContain("revision 1");
203
+ });
204
+ });
205
+
206
+ describe("edit-exit reconciliation", () => {
207
+ test("an edit that removes an anchored passage orphans the annotation and shows the banner", async () => {
208
+ const script = join(home, "fake-editor.sh");
209
+ await Bun.write(script, `#!/bin/sh\nsed -i '' '/^The daemon persists/d' "$1"\n`);
210
+ Bun.spawnSync(["chmod", "+x", script]);
211
+ process.env.CUELOOP_EDITOR = script;
212
+ try {
213
+ const setup = await renderApp();
214
+ await toContextParagraph(setup);
215
+ await press(setup, "c");
216
+ await type(setup, "Anchor me to the doomed passage.");
217
+ await press(setup, "enter");
218
+ await waitForText(setup, "· pending");
219
+ // deselect the card so e reaches the editor hand-off, then edit
220
+ await press(setup, "escape");
221
+ await waitForTextGone(setup, "▸ COMMENT");
222
+ await press(setup, "e");
223
+ await waitForText(setup, "1 annotation no longer match - the passage was removed.");
224
+ // the rail card flips to ORPHANED once the working-copy refresh lands
225
+ await waitForText(setup, "· ORPHANED");
226
+ // the annotation is NOT deleted: the feedback serializer handles orphans
227
+ expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
228
+ } finally {
229
+ delete process.env.CUELOOP_EDITOR;
230
+ }
231
+ });
232
+ });
@@ -13,6 +13,7 @@ import { testRender } from "@opentui/react/test-utils";
13
13
  import { DaemonServer } from "@cueloop/daemon";
14
14
  import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
15
15
  import { App } from "./App";
16
+ import { press, waitForText } from "./test-support";
16
17
 
17
18
  const PLAN = `# Migration Plan
18
19
 
@@ -49,27 +50,14 @@ async function renderObserver() {
49
50
  width: 120,
50
51
  height: 32,
51
52
  });
52
- for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
53
- await Bun.sleep(25);
54
- await setup.renderOnce();
55
- }
56
- await setup.renderOnce();
53
+ await waitForText(setup, "cueloop");
57
54
  return setup;
58
55
  }
59
56
 
60
- type Setup = Awaited<ReturnType<typeof renderObserver>>;
61
-
62
- async function press(setup: Setup, k: string): Promise<void> {
63
- if (k === "enter") setup.mockInput.pressKey("RETURN");
64
- else await setup.mockInput.typeText(k);
65
- await Bun.sleep(15);
66
- await setup.renderOnce();
67
- }
68
-
69
57
  /** Session state that any mutating verb would change. */
70
58
  function snapshot() {
71
- const s = server.core.sessionGet(session.id);
72
- return { annotations: s.annotations.length, workingCopy: s.workingCopy, status: s.status };
59
+ const stored = server.core.sessionGet(session.id);
60
+ return { annotations: stored.annotations.length, workingCopy: stored.workingCopy, status: stored.status };
73
61
  }
74
62
 
75
63
  describe("observer rendering", () => {
@@ -96,10 +84,7 @@ describe("observer verbs are blocked", () => {
96
84
  await press(setup, "j");
97
85
  await press(setup, "j");
98
86
  await press(setup, key);
99
- await Bun.sleep(60);
100
- await setup.renderOnce();
101
- const frame = setup.captureCharFrame();
102
- expect(frame).toContain("observer - read-only");
87
+ const frame = await waitForText(setup, "observer - read-only");
103
88
  // no overlay opened: compose/submit bars never appear
104
89
  expect(frame).not.toContain("COMMENT ON");
105
90
  expect(frame).not.toContain("SUGGEST REPLACEMENT");
@@ -129,7 +114,7 @@ describe("observer navigation still works", () => {
129
114
  await press(setup, "j");
130
115
  await setup.renderOnce();
131
116
  const lines = setup.captureCharFrame().split("\n");
132
- const cursorLine = lines.find((l) => l.includes("▎"))!;
117
+ const cursorLine = lines.find((line) => line.includes("▎"))!;
133
118
  expect(cursorLine).toContain("persists sessions");
134
119
  });
135
120
 
@@ -0,0 +1,100 @@
1
+ /** Plan rendering: code blocks verbatim + contained, spacing never collapses. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import React from "react";
8
+ import { testRender } from "@opentui/react/test-utils";
9
+ import { DaemonServer } from "@cueloop/daemon";
10
+ import type { ReviewSession } from "@cueloop/schema";
11
+ import { App } from "./App";
12
+ import { waitForText } from "./test-support";
13
+
14
+ const PLAN = `# Render Plan
15
+
16
+ ## Sensor
17
+
18
+ - wire the duck over MQTT
19
+
20
+ \`\`\`tsx
21
+ export function Gate({ full }: { full: number }) {
22
+ const blocked = full < 0.6;
23
+ return <text>{blocked ? "blocked" : "clear"}</text>;
24
+ }
25
+ \`\`\`
26
+
27
+ ## Gate
28
+
29
+ Deploys block below the threshold.
30
+ `;
31
+
32
+ let home: string;
33
+ let server: DaemonServer;
34
+ let session: ReviewSession;
35
+
36
+ beforeEach(() => {
37
+ home = mkdtempSync(join(tmpdir(), "cueloop-render-"));
38
+ server = new DaemonServer({ home, idleExitMs: 0 });
39
+ server.start();
40
+ session = server.core.sessionCreate({
41
+ workspace: { repoRoot: "/repo", branch: "main" },
42
+ artifact: { type: "plan", content: PLAN, meta: { title: "Render Plan" } },
43
+ });
44
+ });
45
+ afterEach(() => {
46
+ server.stop();
47
+ rmSync(home, { recursive: true, force: true });
48
+ });
49
+
50
+ async function frame(): Promise<string> {
51
+ const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 36 });
52
+ await waitForText(setup, "Render Plan");
53
+ // async highlights settle within the visual-idle wait
54
+ await setup.waitForVisualIdle();
55
+ return setup.captureCharFrame();
56
+ }
57
+
58
+ describe("code blocks", () => {
59
+ test("lines render verbatim: indentation preserved, no word-wrap", async () => {
60
+ const rendered = await frame();
61
+ expect(rendered).toContain(" const blocked = full < 0.6;");
62
+ expect(rendered).toContain(' return <text>{blocked ? "blocked" : "clear"}</text>;');
63
+ });
64
+
65
+ test("the block carries its language tag", async () => {
66
+ const rendered = await frame();
67
+ expect(rendered).toContain("tsx");
68
+ });
69
+ });
70
+
71
+ describe("block spacing", () => {
72
+ test("a code block never glues to the list item above it", async () => {
73
+ const lines = (await frame()).split("\n").map((line) => line.trimEnd());
74
+ const listItemLineIndex = lines.findIndex((line) => line.includes("wire the duck over MQTT"));
75
+ const codeHeader = lines.findIndex((line) => line.trim().startsWith("tsx"));
76
+ expect(listItemLineIndex).toBeGreaterThan(-1);
77
+ expect(codeHeader).toBeGreaterThan(listItemLineIndex);
78
+ expect(lines.slice(listItemLineIndex + 1, codeHeader).some((line) => line === "")).toBe(true);
79
+ });
80
+
81
+ test("a heading never sits directly on the previous block", async () => {
82
+ const lines = (await frame()).split("\n").map((line) => line.trimEnd());
83
+ const gate = lines.findIndex((line) => line.trim() === "Gate");
84
+ expect(gate).toBeGreaterThan(0);
85
+ expect(lines[gate - 1]).toBe("");
86
+ });
87
+
88
+ test("consecutive list items stay tight", async () => {
89
+ const listPlan = "# T\n\n- one\n- two\n";
90
+ const s2 = server.core.sessionCreate({
91
+ workspace: { repoRoot: "/repo", branch: "main" },
92
+ artifact: { type: "plan", content: listPlan, meta: {} },
93
+ });
94
+ const setup = await testRender(<App home={home} sessionId={s2.id} />, { width: 120, height: 30 });
95
+ await waitForText(setup, "one");
96
+ const lines = setup.captureCharFrame().split("\n").map((line) => line.trimEnd());
97
+ const one = lines.findIndex((line) => line.includes("- one"));
98
+ expect(lines[one + 1]).toContain("- two");
99
+ });
100
+ });