@cueloop/client 0.1.0-alpha.6 → 0.1.0-alpha.61

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 (161) hide show
  1. package/native/build-pty.sh +51 -0
  2. package/native/build.sh +82 -0
  3. package/native/darwin-arm64/libcuelooppty.dylib +0 -0
  4. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  5. package/native/src/pty.zig +193 -0
  6. package/native/src/shim.zig +114 -0
  7. package/package.json +37 -33
  8. package/src/App.appearance.test.tsx +51 -0
  9. package/src/App.autoclose.test.tsx +73 -47
  10. package/src/App.diff.test.tsx +169 -22
  11. package/src/App.export.test.tsx +49 -37
  12. package/src/App.plan-review.test.tsx +714 -0
  13. package/src/App.prototype.test.tsx +194 -0
  14. package/src/App.readonly.test.tsx +69 -35
  15. package/src/App.rendering.test.tsx +59 -31
  16. package/src/App.review-panel.test.tsx +143 -0
  17. package/src/App.submit-confirm.test.tsx +229 -0
  18. package/src/App.test.tsx +139 -68
  19. package/src/App.toast.test.tsx +77 -0
  20. package/src/App.tsx +0 -0
  21. package/src/App.walk.test.tsx +299 -0
  22. package/src/__snapshots__/view-plan.regression.test.ts.snap +100 -0
  23. package/src/app-key-state.ts +61 -0
  24. package/src/app-screens.tsx +270 -0
  25. package/src/app-view-model.ts +307 -0
  26. package/src/attribution.test.ts +31 -0
  27. package/src/attribution.ts +42 -0
  28. package/src/clipboard.test.ts +23 -0
  29. package/src/clipboard.ts +33 -0
  30. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  31. package/src/components/AnnotationCard.stories.tsx +94 -0
  32. package/src/components/AnnotationCard.tsx +250 -0
  33. package/src/components/Breadcrumb.stories.tsx +19 -0
  34. package/src/components/Breadcrumb.tsx +45 -0
  35. package/src/components/CodeBlock.stories.tsx +42 -0
  36. package/src/components/CodeBlock.tsx +84 -0
  37. package/src/components/CompletionOverlay.stories.tsx +23 -0
  38. package/src/components/CompletionOverlay.tsx +60 -0
  39. package/src/components/ConfirmCard.stories.tsx +35 -0
  40. package/src/components/ConfirmCard.tsx +129 -0
  41. package/src/components/ConfirmDialog.stories.tsx +21 -0
  42. package/src/components/ConfirmDialog.tsx +66 -0
  43. package/src/components/DiffSheet.scroll.test.tsx +135 -0
  44. package/src/components/DiffSheet.stories.tsx +78 -0
  45. package/src/components/DiffSheet.syntax.test.tsx +65 -0
  46. package/src/components/DiffSheet.tsx +374 -0
  47. package/src/components/InboxList.stories.tsx +31 -0
  48. package/src/components/InboxList.tsx +64 -0
  49. package/src/components/KeybindsDialog.stories.tsx +31 -0
  50. package/src/components/KeybindsDialog.tsx +59 -0
  51. package/src/components/MarkerPopover.stories.tsx +58 -0
  52. package/src/components/MarkerPopover.tsx +109 -0
  53. package/src/components/MenuBar.stories.tsx +37 -0
  54. package/src/components/MenuBar.tsx +75 -0
  55. package/src/components/PlanSheet.stories.tsx +113 -0
  56. package/src/components/PlanSheet.tsx +422 -0
  57. package/src/components/PromptDialog.stories.tsx +36 -0
  58. package/src/components/PromptDialog.tsx +80 -0
  59. package/src/components/PrototypeSheet.stories.tsx +20 -0
  60. package/src/components/PrototypeSheet.tsx +450 -0
  61. package/src/components/ReviewPanel.stories.tsx +86 -0
  62. package/src/components/ReviewPanel.tsx +103 -0
  63. package/src/components/ReviewRail.stories.tsx +218 -0
  64. package/src/components/ReviewRail.tsx +318 -0
  65. package/src/components/SettingsDialog.stories.tsx +111 -0
  66. package/src/components/SettingsDialog.tsx +147 -0
  67. package/src/components/SettingsRows.stories.tsx +18 -0
  68. package/src/components/SettingsRows.tsx +112 -0
  69. package/src/components/Toast.stories.tsx +12 -0
  70. package/src/components/Toast.tsx +45 -0
  71. package/src/components/WalkWizard.stories.tsx +100 -0
  72. package/src/components/WalkWizard.tsx +152 -0
  73. package/src/components/__snapshots__/stories.test.tsx.snap +1857 -0
  74. package/src/components/agent-launcher.stories.tsx +26 -0
  75. package/src/components/agent-launcher.test.tsx +50 -0
  76. package/src/components/agent-launcher.tsx +149 -0
  77. package/src/components/diff-sheet-layout.test.ts +128 -0
  78. package/src/components/diff-sheet-layout.ts +184 -0
  79. package/src/components/plan-sheet-run-style.ts +90 -0
  80. package/src/components/primitives/Button.stories.tsx +32 -0
  81. package/src/components/primitives/Button.tsx +52 -0
  82. package/src/components/primitives/Card.stories.tsx +25 -0
  83. package/src/components/primitives/Card.tsx +60 -0
  84. package/src/components/primitives/Dialog.stories.tsx +24 -0
  85. package/src/components/primitives/Dialog.tsx +66 -0
  86. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  87. package/src/components/primitives/StatusBar.tsx +23 -0
  88. package/src/components/primitives/Tabs.stories.tsx +29 -0
  89. package/src/components/primitives/Tabs.tsx +102 -0
  90. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  91. package/src/components/primitives/Toolbar.tsx +14 -0
  92. package/src/components/primitives/frame.test.ts +12 -0
  93. package/src/components/primitives/frame.ts +14 -0
  94. package/src/components/quick-actions-editor.stories.tsx +54 -0
  95. package/src/components/quick-actions-editor.tsx +138 -0
  96. package/src/components/stories-app.tsx +124 -0
  97. package/src/components/stories.test.tsx +66 -0
  98. package/src/components/story-fixtures.ts +98 -0
  99. package/src/components/story.ts +75 -0
  100. package/src/components/syntax-highlight.ts +102 -0
  101. package/src/components/terminal-pane.test.tsx +40 -0
  102. package/src/components/terminal-pane.ts +193 -0
  103. package/src/components/theme-context.stories.tsx +46 -0
  104. package/src/components/theme-context.tsx +37 -0
  105. package/src/components/truncate-text.ts +7 -0
  106. package/src/config.test.ts +448 -31
  107. package/src/config.ts +339 -57
  108. package/src/diff-hunk-curate.test.ts +315 -0
  109. package/src/diff-hunk-curate.ts +253 -0
  110. package/src/diff-intraline.test.ts +165 -0
  111. package/src/diff-intraline.ts +146 -0
  112. package/src/diff-syntax.test.ts +96 -0
  113. package/src/diff-syntax.ts +176 -0
  114. package/src/editor.test.ts +149 -0
  115. package/src/editor.ts +135 -9
  116. package/src/ghostty-terminal.test.ts +84 -0
  117. package/src/ghostty-terminal.ts +176 -0
  118. package/src/herdr.test.ts +9 -34
  119. package/src/herdr.ts +17 -21
  120. package/src/index.ts +10 -0
  121. package/src/integrations.test.ts +41 -0
  122. package/src/integrations.ts +32 -0
  123. package/src/intent-dispatch.test.ts +499 -0
  124. package/src/intent-dispatch.ts +585 -0
  125. package/src/key-bindings.test.ts +156 -0
  126. package/src/key-bindings.ts +452 -0
  127. package/src/keymap.test.ts +635 -0
  128. package/src/keymap.ts +404 -0
  129. package/src/kitty-image.test.ts +66 -0
  130. package/src/kitty-image.ts +138 -0
  131. package/src/prototype-browser.test.ts +56 -0
  132. package/src/prototype-browser.ts +289 -0
  133. package/src/pty.test.ts +81 -0
  134. package/src/pty.ts +237 -0
  135. package/src/review-panel.test.ts +113 -0
  136. package/src/review-panel.ts +83 -0
  137. package/src/run.ts +16 -5
  138. package/src/serve.test.ts +25 -9
  139. package/src/serve.ts +19 -8
  140. package/src/session-controller.curate.test.ts +260 -0
  141. package/src/session-controller.share.test.ts +320 -0
  142. package/src/session-controller.ts +993 -0
  143. package/src/share.ts +120 -0
  144. package/src/test-support.test.ts +60 -0
  145. package/src/test-support.ts +197 -0
  146. package/src/theme-presets.test.ts +51 -0
  147. package/src/theme-presets.ts +187 -0
  148. package/src/theme.ts +64 -17
  149. package/src/use-settings-dialog.tsx +275 -0
  150. package/src/version.ts +5 -0
  151. package/src/view-diff.test.ts +22 -8
  152. package/src/view-diff.ts +39 -23
  153. package/src/view-plan.regression.test.ts +60 -0
  154. package/src/view-plan.test.ts +406 -0
  155. package/src/view-plan.ts +554 -0
  156. package/src/walk.test.ts +121 -0
  157. package/src/walk.ts +74 -0
  158. package/src/syntax.test.ts +0 -41
  159. package/src/syntax.ts +0 -88
  160. package/src/view.test.ts +0 -157
  161. package/src/view.ts +0 -0
@@ -0,0 +1,194 @@
1
+ /** Prototype review end to end: a click on the rendered page selects a DOM element, the marker actions bar opens, a typed comment saves through the controller, and the annotation shows in the rail. The headless browser is replaced with a fake so no Chrome or kitty output is needed. Char-frame assertions over the real App and a real in-process daemon. */
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 {
13
+ setPrototypeRendererFactory,
14
+ type PrototypeElement,
15
+ type PrototypeRenderer,
16
+ } from "./prototype-browser";
17
+ import {
18
+ isolateUserConfig,
19
+ press,
20
+ settle,
21
+ typeText,
22
+ waitForState,
23
+ waitForText,
24
+ waitForTextGone,
25
+ } from "./test-support";
26
+
27
+ const HTML = "<main><div class='card'><h2>Pro</h2><p>$24/mo</p></div></main>";
28
+
29
+ const FAKE_ELEMENT: PrototypeElement = {
30
+ selector: "main > div.card",
31
+ quote: "Pro $24/mo",
32
+ // mid-page, so the popover anchors well away from the click point below
33
+ box: { x: 440, y: 320, width: 320, height: 220 },
34
+ };
35
+
36
+ let rendered = false;
37
+ let screenshotCount = 0;
38
+ let scrollDeltas: number[] = [];
39
+ const fakeRenderer: PrototypeRenderer = {
40
+ viewport: { width: 1280, height: 800 },
41
+ screenshot: async () => {
42
+ rendered = true;
43
+ screenshotCount += 1;
44
+
45
+ return new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
46
+ },
47
+ elementAt: async () => FAKE_ELEMENT,
48
+ scrollBy: async (deltaY) => {
49
+ scrollDeltas.push(deltaY);
50
+
51
+ return true;
52
+ },
53
+ close: async () => undefined,
54
+ };
55
+
56
+ let home: string;
57
+ let server: DaemonServer;
58
+ let session: ReviewSession;
59
+ let restoreUserConfig: () => void;
60
+
61
+ beforeEach(() => {
62
+ home = mkdtempSync(join(tmpdir(), "cueloop-prototype-"));
63
+ restoreUserConfig = isolateUserConfig(home);
64
+ server = new DaemonServer({ home, idleExitMs: 0 });
65
+ server.start();
66
+ session = server.core.sessionCreate({
67
+ workspace: { repoRoot: "/repo", branch: "main" },
68
+ artifact: {
69
+ type: "prototype",
70
+ content: HTML,
71
+ meta: { title: "Pricing cards", prototypePath: "/repo/cards.html" },
72
+ },
73
+ });
74
+ rendered = false;
75
+ screenshotCount = 0;
76
+ scrollDeltas = [];
77
+ setPrototypeRendererFactory(async () => fakeRenderer);
78
+ });
79
+ afterEach(() => {
80
+ setPrototypeRendererFactory(null);
81
+ restoreUserConfig();
82
+ server.stop();
83
+ rmSync(home, { recursive: true, force: true });
84
+ });
85
+
86
+ async function renderApp() {
87
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
88
+ width: 120,
89
+ height: 32,
90
+ });
91
+
92
+ await waitForText(setup, "cueloop");
93
+
94
+ return setup;
95
+ }
96
+
97
+ type Setup = Awaited<ReturnType<typeof renderApp>>;
98
+
99
+ async function clickText(setup: Setup, needle: string): Promise<void> {
100
+ const lines = setup.captureCharFrame().split("\n");
101
+ const row = lines.findIndex((line) => line.includes(needle));
102
+
103
+ await setup.mockMouse.click(lines[row]!.indexOf(needle) + 1, row);
104
+ await setup.renderOnce();
105
+ }
106
+
107
+ describe("prototype review", () => {
108
+ test("click an element, comment on it, and it lands in the rail", async () => {
109
+ // Arrange - wait until the fake browser produced its first screenshot, so
110
+ // the sheet is interactive before the click
111
+ const setup = await renderApp();
112
+
113
+ await waitForState(setup, () => rendered);
114
+ await settle(setup);
115
+ // selecting an element must not re-screenshot the page (the popover-click
116
+ // lag regression): the highlight is the popover, not a fresh Chromium capture
117
+ const screenshotsAfterLoad = screenshotCount;
118
+
119
+ // Act
120
+ await setup.mockMouse.click(6, 6);
121
+ await waitForText(setup, "comment");
122
+ expect(screenshotCount).toBe(screenshotsAfterLoad);
123
+ await clickText(setup, "comment");
124
+ await waitForText(setup, "write a note");
125
+ // the keymap-suppression flag must propagate before typing so keys reach the
126
+ // textarea, not the global keymap
127
+ await settle(setup);
128
+ await typeText(setup, "tighten the padding");
129
+ await press(setup, "enter");
130
+
131
+ // Assert - the annotation is stored against the element's selector
132
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
133
+ const stored = server.core.sessionGet(session.id).annotations[0]!;
134
+
135
+ expect(stored.kind).toBe("comment");
136
+ expect(stored.body).toBe("tighten the padding");
137
+ expect(stored.anchor.selector).toBe("main > div.card");
138
+
139
+ // and it renders in the rail
140
+ await settle(setup);
141
+ const frame = setup.captureCharFrame();
142
+
143
+ expect(frame).toContain("tighten the padding");
144
+ expect(frame).toContain("COMMENT");
145
+ });
146
+
147
+ test("escape cancels the compose, matching the plan composer", async () => {
148
+ // Arrange
149
+ const setup = await renderApp();
150
+
151
+ await waitForState(setup, () => rendered);
152
+ await settle(setup);
153
+ await setup.mockMouse.click(6, 6);
154
+ await waitForText(setup, "comment");
155
+ await clickText(setup, "comment");
156
+ await waitForText(setup, "write a note");
157
+ await settle(setup);
158
+
159
+ // Act
160
+ await press(setup, "escape");
161
+
162
+ // Assert - the composer closes and nothing was saved
163
+ await waitForTextGone(setup, "write a note");
164
+ expect(server.core.sessionGet(session.id).annotations).toHaveLength(0);
165
+ });
166
+
167
+ test("wheel over the preview scrolls the page down, then up, and clears a selection", async () => {
168
+ // Arrange
169
+ const setup = await renderApp();
170
+
171
+ await waitForState(setup, () => rendered);
172
+ await settle(setup);
173
+
174
+ // open a selection, then scroll: the scroll drops it (it would drift once
175
+ // the page moves under it)
176
+ await setup.mockMouse.click(6, 6);
177
+ await waitForText(setup, "comment");
178
+ // scrolling moves the page, so it DOES re-screenshot (unlike a click)
179
+ const screenshotsBeforeScroll = screenshotCount;
180
+
181
+ await setup.mockMouse.scroll(6, 6, "down");
182
+ await waitForState(setup, () => scrollDeltas.length === 1);
183
+ await waitForTextGone(setup, "comment");
184
+ await waitForState(setup, () => screenshotCount > screenshotsBeforeScroll);
185
+
186
+ // Act - scroll back up
187
+ await setup.mockMouse.scroll(6, 6, "up");
188
+ await waitForState(setup, () => scrollDeltas.length === 2);
189
+
190
+ // Assert - down was a positive delta, up was negative
191
+ expect(scrollDeltas[0]!).toBeGreaterThan(0);
192
+ expect(scrollDeltas[1]!).toBeLessThan(0);
193
+ });
194
+ });
@@ -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";
@@ -13,6 +9,8 @@ import { testRender } from "@opentui/react/test-utils";
13
9
  import { DaemonServer } from "@cueloop/daemon";
14
10
  import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
15
11
  import { App } from "./App";
12
+ import { DARK } from "./theme";
13
+ import { isolateUserConfig, press, waitForState, waitForText } from "./test-support";
16
14
 
17
15
  const PLAN = `# Migration Plan
18
16
 
@@ -27,19 +25,26 @@ The daemon persists sessions to disk atomically.
27
25
  `;
28
26
 
29
27
  let home: string;
28
+ let restoreUserConfig: () => void;
30
29
  let server: DaemonServer;
31
30
  let session: ReviewSession;
32
31
 
33
32
  beforeEach(() => {
34
33
  home = mkdtempSync(join(tmpdir(), "cueloop-observer-"));
34
+ restoreUserConfig = isolateUserConfig(home);
35
35
  server = new DaemonServer({ home, idleExitMs: 0 });
36
36
  server.start();
37
37
  session = server.core.sessionCreate({
38
38
  workspace: { repoRoot: "/repo", branch: "main" },
39
- artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
39
+ artifact: {
40
+ type: "plan",
41
+ content: PLAN,
42
+ meta: { title: "Migration Plan", planPath: "plan.md" },
43
+ },
40
44
  });
41
45
  });
42
46
  afterEach(() => {
47
+ restoreUserConfig();
43
48
  server.stop();
44
49
  rmSync(home, { recursive: true, force: true });
45
50
  });
@@ -49,43 +54,39 @@ async function renderObserver() {
49
54
  width: 120,
50
55
  height: 32,
51
56
  });
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();
57
- return setup;
58
- }
59
57
 
60
- type Setup = Awaited<ReturnType<typeof renderObserver>>;
58
+ await waitForText(setup, "cueloop");
61
59
 
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();
60
+ return setup;
67
61
  }
68
62
 
69
63
  /** Session state that any mutating verb would change. */
70
64
  function snapshot() {
71
- const s = server.core.sessionGet(session.id);
72
- return { annotations: s.annotations.length, workingCopy: s.workingCopy, status: s.status };
65
+ const stored = server.core.sessionGet(session.id);
66
+
67
+ return {
68
+ annotations: stored.annotations.length,
69
+ workingCopy: stored.workingCopy,
70
+ status: stored.status,
71
+ };
73
72
  }
74
73
 
75
74
  describe("observer rendering", () => {
76
- test("shows the observer badge and read-only hint bar", async () => {
75
+ test("shows the observer badge and the bottom menu", async () => {
76
+ // Arrange
77
77
  const setup = await renderObserver();
78
+
79
+ // Assert
78
80
  const frame = setup.captureCharFrame();
81
+
79
82
  expect(frame).toContain("· observer");
80
- expect(frame).toContain("observer - read-only · j/k move");
81
- expect(frame).not.toContain("⏎ submit");
83
+ expect(frame).toContain("menu");
82
84
  });
83
85
  });
84
86
 
85
87
  describe("observer verbs are blocked", () => {
86
88
  for (const [key, verb] of [
87
89
  ["c", "comment"],
88
- ["s", "suggest"],
89
90
  ["x", "cut"],
90
91
  ["e", "edit"],
91
92
  ["enter", "submit"],
@@ -93,47 +94,58 @@ describe("observer verbs are blocked", () => {
93
94
  test(`${verb} (${key}) answers observer - read-only and mutates nothing`, async () => {
94
95
  const setup = await renderObserver();
95
96
  const before = snapshot();
97
+
96
98
  await press(setup, "j");
97
99
  await press(setup, "j");
98
100
  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");
101
+ const frame = await waitForText(setup, "observer - read-only");
102
+
103
103
  // no overlay opened: compose/submit bars never appear
104
- expect(frame).not.toContain("COMMENT ON");
105
- expect(frame).not.toContain("SUGGEST REPLACEMENT");
104
+ expect(frame).not.toContain('comment on "');
106
105
  expect(frame).not.toContain("verdict ←/→");
107
106
  expect(snapshot()).toEqual(before);
108
107
  });
109
108
  }
110
109
 
111
- test("span mode c/s is blocked too", async () => {
110
+ test("span mode c is blocked too", async () => {
111
+ // Arrange
112
112
  const setup = await renderObserver();
113
+
114
+ // Act
113
115
  await press(setup, "j");
114
116
  await press(setup, "j");
115
117
  await press(setup, "v");
116
118
  await press(setup, "c");
117
119
  await setup.renderOnce();
120
+
121
+ // Assert
118
122
  const frame = setup.captureCharFrame();
123
+
119
124
  expect(frame).toContain("observer - read-only");
120
- expect(frame).not.toContain("COMMENT ON");
125
+ expect(frame).not.toContain('comment on "');
121
126
  expect(snapshot().annotations).toBe(0);
122
127
  });
123
128
  });
124
129
 
125
130
  describe("observer navigation still works", () => {
126
131
  test("j/k moves the cursor between blocks", async () => {
132
+ // Arrange
127
133
  const setup = await renderObserver();
134
+
135
+ // Act
128
136
  await press(setup, "j");
129
137
  await press(setup, "j");
130
138
  await setup.renderOnce();
139
+
140
+ // Assert
131
141
  const lines = setup.captureCharFrame().split("\n");
132
- const cursorLine = lines.find((l) => l.includes("▎"))!;
142
+ const cursorLine = lines.find((line) => line.includes("▎"))!;
143
+
133
144
  expect(cursorLine).toContain("persists sessions");
134
145
  });
135
146
 
136
147
  test("n focuses annotations made by the controller", async () => {
148
+ // Arrange
137
149
  server.core.sessionAnnotate(session.id, {
138
150
  id: "a_obs1",
139
151
  kind: "comment",
@@ -141,8 +153,30 @@ describe("observer navigation still works", () => {
141
153
  body: "From the controller.",
142
154
  });
143
155
  const setup = await renderObserver();
156
+
157
+ // Act
144
158
  await press(setup, "n");
145
159
  await setup.renderOnce();
146
- expect(setup.captureCharFrame()).toContain("▸ COMMENT");
160
+
161
+ // Assert - focus shows as the card's blue border (transparent fill); a
162
+ // comment card is the only blue-bordered box in the frame
163
+ await waitForText(setup, "COMMENT · me");
164
+ await waitForState(setup, () => hasBorderColor(setup, DARK.blue));
147
165
  });
148
166
  });
167
+
168
+ /** Whether any border character (box-drawing) is painted in the given foreground hex. */
169
+ function hasBorderColor(setup: Awaited<ReturnType<typeof renderObserver>>, hex: string): boolean {
170
+ for (const line of setup.captureSpans().lines) {
171
+ for (const span of line.spans) {
172
+ if (!/[╭╮╰╯│─┌┐└┘]/.test(span.text)) continue;
173
+ const [red, green, blue] = span.fg.toInts();
174
+ const rendered =
175
+ "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
176
+
177
+ if (rendered === hex) return true;
178
+ }
179
+ }
180
+
181
+ return false;
182
+ }
@@ -1,4 +1,4 @@
1
- /** Plan rendering (#67): code blocks verbatim + contained, spacing never collapses. */
1
+ /** Plan rendering: code blocks verbatim + contained, spacing never collapses. */
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
4
  import { mkdtempSync, rmSync } from "node:fs";
@@ -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 { isolateUserConfig, waitForText } from "./test-support";
12
13
 
13
14
  const PLAN = `# Render Plan
14
15
 
@@ -29,11 +30,13 @@ Deploys block below the threshold.
29
30
  `;
30
31
 
31
32
  let home: string;
33
+ let restoreUserConfig: () => void;
32
34
  let server: DaemonServer;
33
35
  let session: ReviewSession;
34
36
 
35
37
  beforeEach(() => {
36
38
  home = mkdtempSync(join(tmpdir(), "cueloop-render-"));
39
+ restoreUserConfig = isolateUserConfig(home);
37
40
  server = new DaemonServer({ home, idleExitMs: 0 });
38
41
  server.start();
39
42
  session = server.core.sessionCreate({
@@ -42,67 +45,92 @@ beforeEach(() => {
42
45
  });
43
46
  });
44
47
  afterEach(() => {
48
+ restoreUserConfig();
45
49
  server.stop();
46
50
  rmSync(home, { recursive: true, force: true });
47
51
  });
48
52
 
49
53
  async function frame(): Promise<string> {
50
- const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 36 });
51
- const deadline = Date.now() + 10_000;
52
- while (Date.now() < deadline && !setup.captureCharFrame().includes("Render Plan")) {
53
- await Bun.sleep(25);
54
- await setup.renderOnce();
55
- }
56
- // give the async highlighter a beat, then take the final frame
57
- await Bun.sleep(150);
58
- await setup.renderOnce();
54
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
55
+ width: 120,
56
+ height: 36,
57
+ });
58
+
59
+ await waitForText(setup, "Render Plan");
60
+ // async highlights settle within the visual-idle wait
61
+ await setup.waitForVisualIdle();
62
+
59
63
  return setup.captureCharFrame();
60
64
  }
61
65
 
62
66
  describe("code blocks", () => {
63
67
  test("lines render verbatim: indentation preserved, no word-wrap", async () => {
64
- const f = await frame();
65
- expect(f).toContain(" const blocked = full < 0.6;");
66
- expect(f).toContain(' return <text>{blocked ? "blocked" : "clear"}</text>;');
68
+ // Arrange
69
+ const rendered = await frame();
70
+
71
+ // Assert
72
+ expect(rendered).toContain(" const blocked = full < 0.6;");
73
+ expect(rendered).toContain(' return <text>{blocked ? "blocked" : "clear"}</text>;');
67
74
  });
68
75
 
69
76
  test("the block carries its language tag", async () => {
70
- const f = await frame();
71
- expect(f).toContain("tsx");
77
+ // Arrange
78
+ const rendered = await frame();
79
+
80
+ // Assert
81
+ expect(rendered).toContain("tsx");
72
82
  });
73
83
  });
74
84
 
75
85
  describe("block spacing", () => {
86
+ // the review panel's full-height divider paints a `│` on every row, so read
87
+ // the plan column (everything left of the divider) before asserting spacing
88
+ // the plan body is framed: content sits between the left border and the next │
89
+ const planColumn = (line: string): string => line.split("│")[1]?.trimEnd() ?? "";
90
+
76
91
  test("a code block never glues to the list item above it", async () => {
77
- const lines = (await frame()).split("\n").map((l) => l.trimEnd());
78
- const li = lines.findIndex((l) => l.includes("wire the duck over MQTT"));
79
- const codeHeader = lines.findIndex((l) => l.trim().startsWith("tsx"));
80
- expect(li).toBeGreaterThan(-1);
81
- expect(codeHeader).toBeGreaterThan(li);
82
- expect(lines.slice(li + 1, codeHeader).some((l) => l === "")).toBe(true);
92
+ // Arrange
93
+ const lines = (await frame()).split("\n").map(planColumn);
94
+ const listItemLineIndex = lines.findIndex((line) => line.includes("wire the duck over MQTT"));
95
+ const codeHeader = lines.findIndex((line) => line.trim().startsWith("tsx"));
96
+
97
+ // Assert
98
+ expect(listItemLineIndex).toBeGreaterThan(-1);
99
+ expect(codeHeader).toBeGreaterThan(listItemLineIndex);
100
+ expect(lines.slice(listItemLineIndex + 1, codeHeader).some((line) => line === "")).toBe(true);
83
101
  });
84
102
 
85
103
  test("a heading never sits directly on the previous block", async () => {
86
- const lines = (await frame()).split("\n").map((l) => l.trimEnd());
87
- const gate = lines.findIndex((l) => l.trim() === "Gate");
104
+ // Arrange
105
+ const lines = (await frame()).split("\n").map(planColumn);
106
+ const gate = lines.findIndex((line) => line.trim() === "Gate");
107
+
108
+ // Assert
88
109
  expect(gate).toBeGreaterThan(0);
89
110
  expect(lines[gate - 1]).toBe("");
90
111
  });
91
112
 
92
113
  test("consecutive list items stay tight", async () => {
114
+ // Arrange
93
115
  const listPlan = "# T\n\n- one\n- two\n";
94
116
  const s2 = server.core.sessionCreate({
95
117
  workspace: { repoRoot: "/repo", branch: "main" },
96
118
  artifact: { type: "plan", content: listPlan, meta: {} },
97
119
  });
98
- const setup = await testRender(<App home={home} sessionId={s2.id} />, { width: 120, height: 30 });
99
- const deadline = Date.now() + 10_000;
100
- while (Date.now() < deadline && !setup.captureCharFrame().includes("one")) {
101
- await Bun.sleep(25);
102
- await setup.renderOnce();
103
- }
104
- const lines = setup.captureCharFrame().split("\n").map((l) => l.trimEnd());
105
- const one = lines.findIndex((l) => l.includes("- one"));
120
+ const setup = await testRender(<App home={home} sessionId={s2.id} />, {
121
+ width: 120,
122
+ height: 30,
123
+ });
124
+
125
+ await waitForText(setup, "one");
126
+
127
+ // Assert
128
+ const lines = setup
129
+ .captureCharFrame()
130
+ .split("\n")
131
+ .map((line) => line.trimEnd());
132
+ const one = lines.findIndex((line) => line.includes("- one"));
133
+
106
134
  expect(lines[one + 1]).toContain("- two");
107
135
  });
108
136
  });
@@ -0,0 +1,143 @@
1
+ /** The review panel end to end: the b key cycles expanded -> compact -> hidden -> expanded on the live App, and the collapse mode persists to the user config so it survives a restart. Rendering details are locked by the ReviewPanel stories; this suite proves the App wiring and the persistence. */
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 { loadConfig } from "./config";
13
+ import { isolateUserConfig, press, settle, waitForText, waitForTextGone } from "./test-support";
14
+
15
+ const PLAN = "# Plan\n\nShip the thing.\n";
16
+
17
+ let home: string;
18
+ let server: DaemonServer;
19
+ let session: ReviewSession;
20
+ let configPath: string;
21
+ let restoreUserConfig: () => void;
22
+
23
+ beforeEach(() => {
24
+ home = mkdtempSync(join(tmpdir(), "cueloop-panel-"));
25
+ configPath = join(home, "config.toml");
26
+ restoreUserConfig = isolateUserConfig(home, "config.toml");
27
+ server = new DaemonServer({ home, idleExitMs: 0 });
28
+ server.start();
29
+ session = server.core.sessionCreate({
30
+ workspace: { repoRoot: "/repo", branch: "main" },
31
+ artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
32
+ });
33
+ });
34
+ afterEach(() => {
35
+ restoreUserConfig();
36
+ server.stop();
37
+ rmSync(home, { recursive: true, force: true });
38
+ });
39
+
40
+ async function renderApp() {
41
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
42
+ width: 120,
43
+ height: 30,
44
+ });
45
+
46
+ await waitForText(setup, "Review");
47
+
48
+ return setup;
49
+ }
50
+
51
+ describe("review panel", () => {
52
+ test("b cycles expanded -> compact -> hidden -> expanded", async () => {
53
+ // Arrange
54
+ const setup = await renderApp();
55
+ // the plan frame widens as the rail shrinks: expanded < compact < hidden.
56
+ // its width is the column of its top-right corner (the first ╮ on the row).
57
+ const planWidth = () =>
58
+ setup
59
+ .captureCharFrame()
60
+ .split("\n")
61
+ .find((line) => line.includes("╮"))
62
+ ?.indexOf("╮") ?? 0;
63
+
64
+ // Assert: expanded shows the submit button and leaves room for the rail
65
+ expect(setup.captureCharFrame()).toContain("Submit review");
66
+ const expandedWidth = planWidth();
67
+
68
+ // Act
69
+ await press(setup, "b");
70
+
71
+ // Assert: compact drops the submit button and widens the plan (rail is a strip)
72
+ await waitForTextGone(setup, "Submit review");
73
+ const compactWidth = planWidth();
74
+
75
+ expect(compactWidth).toBeGreaterThan(expandedWidth);
76
+
77
+ // Act
78
+ await press(setup, "b");
79
+ await settle(setup);
80
+
81
+ // Assert: hidden spans the plan full width, no rail column
82
+ const hiddenWidth = planWidth();
83
+
84
+ expect(hiddenWidth).toBeGreaterThan(compactWidth);
85
+ expect(hiddenWidth).toBeGreaterThan(115);
86
+
87
+ // Act
88
+ await press(setup, "b");
89
+
90
+ // Assert
91
+ await waitForText(setup, "Submit review"); // back to expanded
92
+ });
93
+
94
+ test("the collapse mode persists to the user config", async () => {
95
+ // Arrange
96
+ const setup = await renderApp();
97
+
98
+ // Act
99
+ await press(setup, "b"); // -> compact
100
+
101
+ // Assert
102
+ expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("compact");
103
+ });
104
+
105
+ test("] widens the rail and persists the new width", async () => {
106
+ // Arrange
107
+ const setup = await renderApp();
108
+ const startWidth = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
109
+
110
+ // Act
111
+ await press(setup, "]");
112
+
113
+ // Assert
114
+ const widened = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
115
+
116
+ expect(widened).toBeGreaterThan(startWidth);
117
+ });
118
+
119
+ test("submitting from a hidden panel force-opens the rail so the confirm card is reachable", async () => {
120
+ // Arrange
121
+ const setup = await renderApp();
122
+
123
+ await press(setup, "b"); // -> compact
124
+ await press(setup, "b"); // -> hidden
125
+
126
+ // Assert
127
+ await waitForTextGone(setup, "Submit review"); // rail is gone
128
+ expect(setup.captureCharFrame()).not.toContain("Submit review");
129
+
130
+ // Act
131
+ await press(setup, "enter"); // submit while hidden must not trap in an invisible modal
132
+
133
+ // Assert
134
+ await waitForText(setup, "submit review");
135
+ const frame = setup.captureCharFrame();
136
+
137
+ expect(frame).toContain("submit review"); // the confirm card is visible again
138
+ expect(frame).toContain("[Approve]");
139
+
140
+ // the reveal is live-only: the saved panel preference stays as the user left it
141
+ expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("hidden");
142
+ });
143
+ });