@cueloop/client 0.1.0-alpha.5 → 0.1.0-alpha.50

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