@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,229 @@
1
+ /** The rail submit confirm (tier 2): pressing submit expands the rail's Submit button into a bordered confirm card - counts, verdict selector, summary input, word-buttons. Char-frame assertions over the real App and an in-process daemon, like App.test.tsx. */
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 { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
11
+ import { App } from "./App";
12
+ import { isolateUserConfig, press, settle, waitForText, waitForTextGone } from "./test-support";
13
+
14
+ const PLAN = `# Migration Plan
15
+
16
+ ## Context
17
+
18
+ The daemon persists sessions to disk atomically.
19
+
20
+ ## Steps
21
+
22
+ - move the store
23
+ - add recovery
24
+ `;
25
+
26
+ let home: string;
27
+ let restoreUserConfig: () => void;
28
+ let server: DaemonServer;
29
+ let session: ReviewSession;
30
+
31
+ beforeEach(() => {
32
+ home = mkdtempSync(join(tmpdir(), "cueloop-confirm-"));
33
+ restoreUserConfig = isolateUserConfig(home);
34
+ server = new DaemonServer({ home, idleExitMs: 0 });
35
+ server.start();
36
+ session = server.core.sessionCreate({
37
+ workspace: { repoRoot: "/repo", branch: "main" },
38
+ artifact: {
39
+ type: "plan",
40
+ content: PLAN,
41
+ meta: { title: "Migration Plan", planPath: "plan.md" },
42
+ },
43
+ });
44
+ });
45
+ afterEach(() => {
46
+ restoreUserConfig();
47
+ server.stop();
48
+ rmSync(home, { recursive: true, force: true });
49
+ });
50
+
51
+ async function renderApp(options: { readOnly?: boolean } = {}) {
52
+ const setup = await testRender(
53
+ <App home={home} sessionId={session.id} readOnly={options.readOnly ?? false} />,
54
+ {
55
+ width: 120,
56
+ height: 32,
57
+ },
58
+ );
59
+
60
+ await waitForText(setup, "cueloop");
61
+
62
+ return setup;
63
+ }
64
+
65
+ /** Seed annotations directly through the daemon core (all on one block). */
66
+ function seedAnnotations(count: number): void {
67
+ const blocks = parseBlocks(PLAN);
68
+
69
+ for (let index = 1; index <= count; index++) {
70
+ server.core.sessionAnnotate(session.id, {
71
+ id: `a_confirm_${index}`,
72
+ kind: "comment",
73
+ anchor: makeAnchor(blocks, 2, 0, 10),
74
+ body: `note ${String(index).padStart(2, "0")}`,
75
+ });
76
+ }
77
+ }
78
+
79
+ describe("rail submit confirm", () => {
80
+ test("submit expands the Submit button into the confirm card", async () => {
81
+ // Arrange
82
+ const setup = await renderApp();
83
+
84
+ // Assert
85
+ expect(setup.captureCharFrame()).toContain("Submit review");
86
+
87
+ // Act
88
+ await press(setup, "enter");
89
+
90
+ // Assert
91
+ await waitForText(setup, "submit review");
92
+ const frame = setup.captureCharFrame();
93
+
94
+ // the card: title, verdict selector, word-buttons
95
+ expect(frame).toContain("submit review");
96
+ expect(frame).toContain("[Approve]"); // nothing pending: approve default
97
+ expect(frame).toContain(" Submit ");
98
+ expect(frame).toContain(" Cancel ");
99
+ });
100
+
101
+ test("left/right cycles the verdict selector in the card", async () => {
102
+ // Arrange
103
+ const setup = await renderApp();
104
+
105
+ await press(setup, "enter");
106
+
107
+ // Assert
108
+ await waitForText(setup, "[Approve]");
109
+
110
+ // Act
111
+ await press(setup, "right");
112
+
113
+ // Assert
114
+ await waitForText(setup, "[Changes]");
115
+
116
+ // Act
117
+ await press(setup, "right");
118
+
119
+ // Assert
120
+ await waitForText(setup, "[Comment]");
121
+
122
+ // Act
123
+ await press(setup, "left");
124
+
125
+ // Assert
126
+ await waitForText(setup, "[Changes]");
127
+ });
128
+
129
+ test("esc cancels the card and restores the plain Submit button", async () => {
130
+ // Arrange
131
+ const setup = await renderApp();
132
+
133
+ await press(setup, "enter");
134
+
135
+ // Assert
136
+ await waitForText(setup, "[Approve]");
137
+
138
+ // Act
139
+ await press(setup, "escape");
140
+
141
+ // Assert
142
+ // a bare ESC settles after the parser's escape-sequence window
143
+ const frame = await waitForTextGone(setup, "[Approve]");
144
+
145
+ expect(frame).not.toContain("submit review");
146
+ expect(frame).toContain("Submit review");
147
+ });
148
+
149
+ test("enter in the card resolves the session through the controller", async () => {
150
+ // Arrange
151
+ seedAnnotations(1);
152
+ const setup = await renderApp();
153
+
154
+ // Act
155
+ await press(setup, "enter");
156
+
157
+ // Assert
158
+ await waitForText(setup, "[Changes]"); // pending items: request changes default
159
+ const frame = setup.captureCharFrame();
160
+
161
+ expect(frame).toContain("submit review");
162
+
163
+ // Act
164
+ await setup.mockInput.typeText("Tighten the steps.");
165
+ await press(setup, "enter");
166
+
167
+ // Assert
168
+ // the completion flow after submit is unchanged
169
+ await waitForText(setup, "feedback sent");
170
+ const stored = server.core.sessionGet(session.id);
171
+
172
+ expect(stored.status).toBe("resolved");
173
+ expect(stored.verdict!.kind).toBe("request_changes");
174
+ });
175
+
176
+ test("the annotation stack stays scrollable while the card is open", async () => {
177
+ // Arrange
178
+ seedAnnotations(12);
179
+ const setup = await renderApp();
180
+
181
+ // Act
182
+ // walk annotation focus to the last card: the rail scrolls it into view
183
+ for (let index = 0; index < 12; index++) await press(setup, "n");
184
+
185
+ // Assert
186
+ await waitForText(setup, "note 12");
187
+ expect(setup.captureCharFrame()).not.toContain("note 01");
188
+
189
+ // Act
190
+ await press(setup, "enter");
191
+
192
+ // Assert
193
+ await waitForText(setup, "[Changes]");
194
+ // the card is pinned outside the scrollbox; the stack stays scrolled off the top
195
+ expect(setup.captureCharFrame()).not.toContain("note 01");
196
+
197
+ // Act
198
+ // the stack still scrolls with the card open: wheel over the rail
199
+ // brings the last card back into view while the confirm card stays put
200
+ for (let turn = 0; turn < 12; turn++) await setup.mockMouse.scroll(100, 10, "down");
201
+ await settle(setup);
202
+
203
+ // Assert
204
+ await waitForText(setup, "note 12");
205
+ const scrolledWithCard = setup.captureCharFrame();
206
+
207
+ expect(scrolledWithCard).toContain("submit review");
208
+ expect(scrolledWithCard).not.toContain("note 01");
209
+ });
210
+
211
+ test("read-only observers never see the confirm card", async () => {
212
+ // Arrange
213
+ seedAnnotations(1);
214
+ const setup = await renderApp({ readOnly: true });
215
+
216
+ // Act
217
+ await press(setup, "enter");
218
+
219
+ // Assert
220
+ await waitForText(setup, "observer - read-only");
221
+ const frame = setup.captureCharFrame();
222
+
223
+ expect(frame).toContain("observer - read-only");
224
+ expect(frame).not.toContain("1 annotations");
225
+ expect(frame).not.toContain("[Changes]");
226
+ expect(setup.captureCharFrame()).not.toContain("verdict ←/→");
227
+ expect(server.core.sessionGet(session.id).status).toBe("pending");
228
+ });
229
+ });
package/src/App.test.tsx CHANGED
@@ -1,8 +1,4 @@
1
- /**
2
- * Virtual-terminal component tests (tier 2): the real App over a real
3
- * in-process daemon in a temp home. Char-frame assertions + mock keys -
4
- * the whole review loop drivable without a terminal.
5
- */
1
+ /** Virtual-terminal component tests (tier 2): the real App over a real in-process daemon in a temp home. Char-frame assertions + mock keys - the whole review loop drivable without a terminal. */
6
2
 
7
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
4
  import { mkdtempSync, rmSync } from "node:fs";
@@ -13,6 +9,13 @@ import { testRender } from "@opentui/react/test-utils";
13
9
  import { DaemonServer } from "@cueloop/daemon";
14
10
  import type { ReviewSession } from "@cueloop/schema";
15
11
  import { App } from "./App";
12
+ import {
13
+ isolateUserConfig,
14
+ press,
15
+ typeText as type,
16
+ waitForState,
17
+ waitForText,
18
+ } from "./test-support";
16
19
 
17
20
  const PLAN = `# Migration Plan
18
21
 
@@ -27,19 +30,26 @@ The daemon persists sessions to disk atomically.
27
30
  `;
28
31
 
29
32
  let home: string;
33
+ let restoreUserConfig: () => void;
30
34
  let server: DaemonServer;
31
35
  let session: ReviewSession;
32
36
 
33
37
  beforeEach(() => {
34
38
  home = mkdtempSync(join(tmpdir(), "cueloop-app-"));
39
+ restoreUserConfig = isolateUserConfig(home);
35
40
  server = new DaemonServer({ home, idleExitMs: 0 });
36
41
  server.start();
37
42
  session = server.core.sessionCreate({
38
43
  workspace: { repoRoot: "/repo", branch: "main" },
39
- artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
44
+ artifact: {
45
+ type: "plan",
46
+ content: PLAN,
47
+ meta: { title: "Migration Plan", planPath: "plan.md" },
48
+ },
40
49
  });
41
50
  });
42
51
  afterEach(() => {
52
+ restoreUserConfig();
43
53
  server.stop();
44
54
  rmSync(home, { recursive: true, force: true });
45
55
  });
@@ -49,78 +59,77 @@ async function renderApp(sessionId?: string) {
49
59
  width: 120,
50
60
  height: 32,
51
61
  });
52
- // let the async daemon connect + first fetch land
53
- for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
54
- await Bun.sleep(25);
55
- await setup.renderOnce();
56
- }
57
- await setup.renderOnce();
58
- return setup;
59
- }
60
62
 
61
- type Setup = Awaited<ReturnType<typeof renderApp>>;
62
-
63
- /** Drive one key press: letters type as text; named keys use KeyCodes ids. */
64
- async function press(setup: Setup, k: string): Promise<void> {
65
- if (k === "enter") setup.mockInput.pressKey("RETURN");
66
- else if (k === "escape") setup.mockInput.pressKey("ESCAPE");
67
- else if (k === "backspace") setup.mockInput.pressKey("BACKSPACE");
68
- else if (k === "left") setup.mockInput.pressKey("ARROW_LEFT");
69
- else if (k === "right") setup.mockInput.pressKey("ARROW_RIGHT");
70
- else await type(setup, k);
71
- await Bun.sleep(15);
72
- await setup.renderOnce();
73
- }
63
+ // the async daemon connect + first fetch land within the frame wait
64
+ await waitForText(setup, "cueloop");
74
65
 
75
- async function type(setup: Setup, text: string): Promise<void> {
76
- await setup.mockInput.typeText(text);
77
- await Bun.sleep(15);
78
- await setup.renderOnce();
66
+ return setup;
79
67
  }
80
68
 
81
69
  describe("plan rendering", () => {
82
70
  test("renders the plan with headings, list markers, and the rail", async () => {
71
+ // Arrange
83
72
  const setup = await renderApp();
73
+
74
+ // Assert
84
75
  const frame = setup.captureCharFrame();
76
+
85
77
  expect(frame).toContain("Migration Plan");
86
78
  expect(frame).toContain("Context");
87
79
  expect(frame).toContain("persists sessions to disk atomically");
88
80
  expect(frame).toContain("- move the store");
89
- expect(frame).toContain("REVIEW (0)");
90
- expect(frame).toContain("Submit review (0)");
81
+ expect(frame).toContain("Review");
82
+ expect(frame).toContain("Submit review");
91
83
  });
92
84
  });
93
85
 
94
86
  describe("keyboard grammar", () => {
95
87
  test("j/k moves the cursor glyph between blocks", async () => {
88
+ // Arrange
96
89
  const setup = await renderApp();
90
+
91
+ // Act
97
92
  await press(setup, "j");
98
93
  await press(setup, "j");
99
94
  await setup.renderOnce();
95
+
96
+ // Assert
100
97
  const lines = setup.captureCharFrame().split("\n");
101
- const cursorLine = lines.find((l) => l.includes("▎"))!;
98
+ const cursorLine = lines.find((line) => line.includes("▎"))!;
99
+
102
100
  expect(cursorLine).toContain("persists sessions");
103
101
  });
104
102
 
105
103
  test("comment flow: c types a body, ⏎ saves to the daemon", async () => {
104
+ // Arrange
106
105
  const setup = await renderApp();
106
+
107
+ // Act
107
108
  await press(setup, "j");
108
109
  await press(setup, "j");
109
110
  await press(setup, "c");
110
111
  await setup.renderOnce();
111
- expect(setup.captureCharFrame()).toContain("COMMENT ON");
112
+
113
+ // Assert
114
+ await waitForText(setup, 'comment on "The daemon');
115
+
116
+ // Act
112
117
  await type(setup, "Define atomically.");
113
118
  await press(setup, "enter");
114
- await Bun.sleep(80);
115
- await setup.renderOnce();
119
+
120
+ // Assert
121
+ await waitForText(setup, "COMMENT");
116
122
  const stored = server.core.sessionGet(session.id);
123
+
117
124
  expect(stored.annotations.length).toBe(1);
118
125
  expect(stored.annotations[0]!.body).toBe("Define atomically.");
119
- expect(setup.captureCharFrame()).toContain("COMMENT");
120
126
  });
121
127
 
122
128
  test("span mode: v + l selects words, c anchors the exact span", async () => {
129
+ // Arrange
123
130
  const setup = await renderApp();
131
+
132
+ // Act
124
133
  await press(setup, "j");
125
134
  await press(setup, "j");
126
135
  await press(setup, "v");
@@ -128,38 +137,51 @@ describe("keyboard grammar", () => {
128
137
  await press(setup, "c");
129
138
  await type(setup, "Which daemon?");
130
139
  await press(setup, "enter");
131
- await Bun.sleep(80);
140
+
141
+ // Assert
142
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
132
143
  const stored = server.core.sessionGet(session.id);
144
+
133
145
  expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
134
146
  });
135
147
 
136
148
  test("x cuts a block into the working copy; x restores it", async () => {
149
+ // Arrange
137
150
  const setup = await renderApp();
151
+
138
152
  // move to "- move the store" (h1, h2, p, h2 = 4 steps in)
139
153
  for (let i = 0; i < 4; i++) await press(setup, "j");
154
+
155
+ // Act
140
156
  await press(setup, "x");
141
- await Bun.sleep(80);
142
- await setup.renderOnce();
157
+
158
+ // Assert - the rail shows the cut as a removal card titled CUT · me
159
+ await waitForText(setup, "CUT · me");
143
160
  expect(server.core.sessionGet(session.id).workingCopy).not.toContain("move the store");
144
- expect(setup.captureCharFrame()).toContain("[cut]");
161
+
162
+ // Act
145
163
  await press(setup, "x");
146
- await Bun.sleep(80);
147
- await setup.renderOnce();
148
- expect(server.core.sessionGet(session.id).workingCopy).toBeUndefined();
164
+
165
+ // Assert
166
+ await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
149
167
  });
150
168
 
151
169
  test("e runs $EDITOR on the working copy and tracks the diff", async () => {
152
170
  const script = join(home, "fake-editor.sh");
171
+
153
172
  await Bun.write(script, `#!/bin/sh\nsed -i '' 's/atomically/very atomically/' "$1"\n`);
154
173
  Bun.spawnSync(["chmod", "+x", script]);
155
174
  process.env.CUELOOP_EDITOR = script;
156
175
  try {
176
+ // Arrange
157
177
  const setup = await renderApp();
178
+
179
+ // Act
158
180
  await press(setup, "e");
159
- await Bun.sleep(150);
160
- await setup.renderOnce();
181
+
182
+ // Assert
183
+ await waitForText(setup, "[edited]");
161
184
  expect(server.core.sessionGet(session.id).workingCopy).toContain("very atomically");
162
- expect(setup.captureCharFrame()).toContain("[edited]");
163
185
  } finally {
164
186
  delete process.env.CUELOOP_EDITOR;
165
187
  }
@@ -167,52 +189,101 @@ describe("keyboard grammar", () => {
167
189
  });
168
190
 
169
191
  describe("submit", () => {
170
- test("⏎ opens the verdict bar; verdict + summary resolve the session", async () => {
192
+ test("⏎ opens the rail confirm card; verdict + summary resolve the session", async () => {
193
+ // Arrange
171
194
  const setup = await renderApp();
195
+
172
196
  await press(setup, "j");
173
197
  await press(setup, "j");
174
198
  await press(setup, "c");
175
199
  await type(setup, "Needs a phase list.");
176
200
  await press(setup, "enter");
177
- await Bun.sleep(80);
201
+ await waitForText(setup, "Needs a phase list");
202
+
203
+ // Act
178
204
  await press(setup, "enter"); // open submit
179
- await setup.renderOnce();
180
- expect(setup.captureCharFrame()).toContain("[Request changes]");
205
+
206
+ // Assert
207
+ await waitForText(setup, "[Changes]");
208
+
209
+ // Act
181
210
  await type(setup, "Expand the steps.");
182
211
  await press(setup, "enter");
183
- await Bun.sleep(120);
184
- await setup.renderOnce();
212
+
213
+ // Assert
214
+ await waitForText(setup, "feedback sent");
185
215
  const stored = server.core.sessionGet(session.id);
216
+
186
217
  expect(stored.status).toBe("resolved");
187
218
  expect(stored.verdict!.kind).toBe("request_changes");
188
219
  expect(stored.verdict!.feedback).toContain("Needs a phase list.");
189
220
  // submit hands the reviewer back to the agent via the completion overlay
190
- expect(setup.captureCharFrame()).toContain("✎ feedback sent");
221
+ expect(setup.captureCharFrame()).toContain("feedback sent");
191
222
  });
192
223
 
193
224
  test("approve via ←/→ verdict cycling", async () => {
225
+ // Arrange
194
226
  const setup = await renderApp();
227
+
228
+ // Act
195
229
  await press(setup, "enter");
196
- await setup.renderOnce();
197
- expect(setup.captureCharFrame()).toContain("[Approve]"); // no pending items → approve default
230
+
231
+ // Assert
232
+ await waitForText(setup, "[Approve]"); // no pending items → approve default
233
+
234
+ // Act
198
235
  await press(setup, "enter");
199
- await Bun.sleep(120);
236
+
237
+ // Assert
238
+ await waitForState(setup, () => server.core.sessionGet(session.id).verdict !== undefined);
200
239
  expect(server.core.sessionGet(session.id).verdict!.kind).toBe("approve");
201
240
  });
202
241
  });
203
242
 
204
243
  describe("inbox", () => {
205
- test("inbox mode renders and opens a session", async () => {
244
+ test("inbox mode wears the review chrome (header + menu bar) and opens a session", async () => {
245
+ // Arrange
206
246
  const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
207
- for (let i = 0; i < 40 && !setup.captureCharFrame().includes("inbox"); i++) {
208
- await Bun.sleep(25);
209
- await setup.renderOnce();
210
- }
211
- expect(setup.captureCharFrame()).toContain("inbox (1 pending)");
212
- expect(setup.captureCharFrame()).toContain("Migration Plan");
247
+
248
+ await waitForText(setup, "cueloop");
249
+
250
+ // Assert - the same header/menu chrome as review, plus the session row
251
+ const frame = setup.captureCharFrame();
252
+
253
+ expect(frame).toContain("cueloop · resume");
254
+ expect(frame).toContain("Migration Plan");
255
+ expect(frame).toContain("menu"); // the shared MenuBar sits at the bottom
256
+ expect(frame).not.toContain("inbox ("); // the old inline header is gone
257
+
258
+ // Act
213
259
  await press(setup, "enter");
214
- await Bun.sleep(80);
215
- await setup.renderOnce();
216
- expect(setup.captureCharFrame()).toContain("Submit review (0)");
260
+
261
+ // Assert
262
+ await waitForText(setup, "Submit review");
263
+ });
264
+
265
+ test("the menu opens from the inbox and escape is not a trap", async () => {
266
+ // Arrange
267
+ const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
268
+
269
+ await waitForText(setup, "cueloop · resume");
270
+ const lines = setup.captureCharFrame().split("\n");
271
+ const menuRow = lines.findIndex((line) => line.includes("menu"));
272
+ const menuColumn = lines[menuRow]!.indexOf("menu");
273
+
274
+ // Act - open the drop-up from the shared menu bar
275
+ await setup.mockMouse.click(menuColumn + 1, menuRow);
276
+
277
+ // Assert - the drop-up options appear
278
+ await waitForText(setup, "Keybinds");
279
+ expect(setup.captureCharFrame()).toContain("Settings");
280
+
281
+ // Act - escape closes the menu (not a trap), and the inbox nav still works
282
+ await press(setup, "escape");
283
+ await waitForState(setup, () => !setup.captureCharFrame().includes("Keybinds"));
284
+ await press(setup, "enter");
285
+
286
+ // Assert
287
+ await waitForText(setup, "Submit review");
217
288
  });
218
289
  });
@@ -0,0 +1,77 @@
1
+ /** The share toast is non-modal: while it is up, escape still cancels an open overlay. */
2
+
3
+ import { afterEach, beforeEach, describe, mock, 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
+
12
+ // the controller reaches the gateway through "./share"; stub it so `S` yields a toast
13
+ const publishShare = mock(async () => ({ line: "ssh p_share01@cueloop.dev", copied: true }));
14
+
15
+ mock.module("./share", () => ({
16
+ publishShare,
17
+ pullShare: mock(async () => null),
18
+ pushShare: mock(async () => {}),
19
+ shareIdFromLine: (line: string) => line.match(/^ssh (\S+)@/)?.[1],
20
+ collaboratorAnnotations: () => [],
21
+ }));
22
+
23
+ const { App } = await import("./App");
24
+ const { isolateUserConfig, press, waitForText, waitForTextGone } = await import("./test-support");
25
+
26
+ const PLAN = `# Migration Plan\n\n## Context\n\nThe daemon persists sessions to disk atomically.\n`;
27
+
28
+ let home: string;
29
+ let restoreUserConfig: () => void;
30
+ let server: DaemonServer;
31
+ let session: ReviewSession;
32
+
33
+ beforeEach(() => {
34
+ home = mkdtempSync(join(tmpdir(), "cueloop-toast-"));
35
+ restoreUserConfig = isolateUserConfig(home);
36
+ server = new DaemonServer({ home, idleExitMs: 0 });
37
+ server.start();
38
+ session = server.core.sessionCreate({
39
+ workspace: { repoRoot: "/repo", branch: "main" },
40
+ artifact: {
41
+ type: "plan",
42
+ content: PLAN,
43
+ meta: { title: "Migration Plan", planPath: "plan.md" },
44
+ },
45
+ });
46
+ });
47
+ afterEach(() => {
48
+ restoreUserConfig();
49
+ server.stop();
50
+ rmSync(home, { recursive: true, force: true });
51
+ });
52
+
53
+ describe("share toast", () => {
54
+ test("escape cancels an open composer even while the toast is up", async () => {
55
+ // Arrange
56
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
57
+ width: 120,
58
+ height: 32,
59
+ });
60
+
61
+ await waitForText(setup, "cueloop");
62
+
63
+ // Act: share raises the toast, then open a composer under it
64
+ await press(setup, "S");
65
+ await waitForText(setup, "share link copied");
66
+ await press(setup, "j");
67
+ await press(setup, "j");
68
+ await press(setup, "c");
69
+ await waitForText(setup, 'comment on "');
70
+
71
+ // Act: escape must reach the composer, not get eaten by the toast
72
+ await press(setup, "escape");
73
+
74
+ // Assert: the composer closed on the first escape
75
+ await waitForTextGone(setup, 'comment on "');
76
+ });
77
+ });
package/src/App.tsx CHANGED
Binary file