@cueloop/client 0.1.0-alpha.32 → 0.1.0-alpha.34

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 (101) hide show
  1. package/package.json +27 -26
  2. package/src/App.autoclose.test.tsx +7 -4
  3. package/src/App.diff.test.tsx +131 -5
  4. package/src/App.export.test.tsx +23 -5
  5. package/src/App.plan-review.test.tsx +133 -32
  6. package/src/App.readonly.test.tsx +35 -13
  7. package/src/App.rendering.test.tsx +14 -4
  8. package/src/App.review-panel.test.tsx +27 -15
  9. package/src/App.submit-confirm.test.tsx +14 -13
  10. package/src/App.test.tsx +50 -13
  11. package/src/App.toast.test.tsx +75 -0
  12. package/src/App.tsx +505 -101
  13. package/src/App.walk.test.tsx +31 -11
  14. package/src/attribution.ts +8 -2
  15. package/src/clipboard.ts +5 -1
  16. package/src/components/AnnotationCard.stories.tsx +29 -10
  17. package/src/components/AnnotationCard.tsx +57 -37
  18. package/src/components/Breadcrumb.tsx +7 -1
  19. package/src/components/CodeBlock.stories.tsx +19 -2
  20. package/src/components/CodeBlock.tsx +24 -9
  21. package/src/components/CompletionOverlay.tsx +13 -3
  22. package/src/components/ConfirmCard.stories.tsx +22 -3
  23. package/src/components/ConfirmCard.tsx +23 -5
  24. package/src/components/ConfirmDialog.tsx +10 -2
  25. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  26. package/src/components/DiffSheet.stories.tsx +35 -1
  27. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  28. package/src/components/DiffSheet.tsx +213 -101
  29. package/src/components/InboxList.stories.tsx +16 -3
  30. package/src/components/InboxList.tsx +17 -10
  31. package/src/components/KeybindsDialog.stories.tsx +31 -0
  32. package/src/components/KeybindsDialog.tsx +58 -0
  33. package/src/components/MarkerPopover.stories.tsx +58 -0
  34. package/src/components/MarkerPopover.tsx +102 -0
  35. package/src/components/MenuBar.stories.tsx +37 -0
  36. package/src/components/MenuBar.tsx +74 -0
  37. package/src/components/PlanSheet.stories.tsx +35 -3
  38. package/src/components/PlanSheet.tsx +160 -100
  39. package/src/components/PromptDialog.tsx +19 -3
  40. package/src/components/ReviewPanel.stories.tsx +17 -19
  41. package/src/components/ReviewPanel.tsx +17 -32
  42. package/src/components/ReviewRail.stories.tsx +159 -47
  43. package/src/components/ReviewRail.tsx +206 -62
  44. package/src/components/SettingsDialog.stories.tsx +51 -3
  45. package/src/components/SettingsDialog.tsx +11 -5
  46. package/src/components/SettingsRows.stories.tsx +1 -1
  47. package/src/components/SettingsRows.tsx +19 -3
  48. package/src/components/Toast.stories.tsx +12 -0
  49. package/src/components/Toast.tsx +44 -0
  50. package/src/components/WalkWizard.stories.tsx +13 -2
  51. package/src/components/WalkWizard.tsx +9 -3
  52. package/src/components/__snapshots__/stories.test.tsx.snap +853 -623
  53. package/src/components/diff-sheet-layout.test.ts +127 -0
  54. package/src/components/diff-sheet-layout.ts +167 -0
  55. package/src/components/primitives/Button.stories.tsx +2 -2
  56. package/src/components/primitives/Button.tsx +5 -1
  57. package/src/components/primitives/Dialog.tsx +12 -1
  58. package/src/components/primitives/Tabs.tsx +40 -39
  59. package/src/components/stories-app.tsx +34 -6
  60. package/src/components/story-fixtures.ts +11 -3
  61. package/src/components/story.ts +3 -1
  62. package/src/components/syntax-highlight.ts +47 -12
  63. package/src/components/theme-context.stories.tsx +6 -1
  64. package/src/components/theme-context.tsx +7 -1
  65. package/src/config.test.ts +178 -2
  66. package/src/config.ts +121 -23
  67. package/src/diff-hunk-curate.test.ts +315 -0
  68. package/src/diff-hunk-curate.ts +233 -0
  69. package/src/diff-intraline.test.ts +164 -0
  70. package/src/diff-intraline.ts +132 -0
  71. package/src/diff-syntax.test.ts +96 -0
  72. package/src/diff-syntax.ts +154 -0
  73. package/src/editor.test.ts +13 -4
  74. package/src/editor.ts +31 -5
  75. package/src/herdr.ts +8 -2
  76. package/src/index.ts +9 -1
  77. package/src/integrations.ts +3 -1
  78. package/src/intent-dispatch.test.ts +193 -9
  79. package/src/intent-dispatch.ts +151 -18
  80. package/src/key-bindings.test.ts +19 -9
  81. package/src/key-bindings.ts +77 -16
  82. package/src/keymap.test.ts +171 -26
  83. package/src/keymap.ts +83 -16
  84. package/src/review-panel.test.ts +3 -1
  85. package/src/review-panel.ts +9 -2
  86. package/src/serve.test.ts +13 -5
  87. package/src/session-controller.curate.test.ts +253 -0
  88. package/src/session-controller.share.test.ts +77 -16
  89. package/src/session-controller.ts +319 -26
  90. package/src/share.ts +41 -8
  91. package/src/test-support.ts +97 -23
  92. package/src/theme-presets.ts +168 -0
  93. package/src/theme.ts +1 -3
  94. package/src/version.ts +5 -0
  95. package/src/view-diff.ts +15 -4
  96. package/src/view-plan.test.ts +40 -6
  97. package/src/view-plan.ts +71 -28
  98. package/src/walk.test.ts +7 -2
  99. package/src/walk.ts +7 -2
  100. package/src/components/ComposeBar.stories.tsx +0 -18
  101. package/src/components/ComposeBar.tsx +0 -54
@@ -9,7 +9,8 @@ import { testRender } from "@opentui/react/test-utils";
9
9
  import { DaemonServer } from "@cueloop/daemon";
10
10
  import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
11
11
  import { App } from "./App";
12
- import { isolateUserConfig, press, waitForText } from "./test-support";
12
+ import { DARK } from "./theme";
13
+ import { isolateUserConfig, press, waitForState, waitForText } from "./test-support";
13
14
 
14
15
  const PLAN = `# Migration Plan
15
16
 
@@ -35,7 +36,11 @@ beforeEach(() => {
35
36
  server.start();
36
37
  session = server.core.sessionCreate({
37
38
  workspace: { repoRoot: "/repo", branch: "main" },
38
- 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
+ },
39
44
  });
40
45
  });
41
46
  afterEach(() => {
@@ -56,26 +61,28 @@ async function renderObserver() {
56
61
  /** Session state that any mutating verb would change. */
57
62
  function snapshot() {
58
63
  const stored = server.core.sessionGet(session.id);
59
- return { annotations: stored.annotations.length, workingCopy: stored.workingCopy, status: stored.status };
64
+ return {
65
+ annotations: stored.annotations.length,
66
+ workingCopy: stored.workingCopy,
67
+ status: stored.status,
68
+ };
60
69
  }
61
70
 
62
71
  describe("observer rendering", () => {
63
- test("shows the observer badge and read-only hint bar", async () => {
72
+ test("shows the observer badge and the bottom menu", async () => {
64
73
  // Arrange
65
74
  const setup = await renderObserver();
66
75
 
67
76
  // Assert
68
77
  const frame = setup.captureCharFrame();
69
78
  expect(frame).toContain("· observer");
70
- expect(frame).toContain("observer - read-only · j/k move");
71
- expect(frame).not.toContain("⏎ submit");
79
+ expect(frame).toContain("menu");
72
80
  });
73
81
  });
74
82
 
75
83
  describe("observer verbs are blocked", () => {
76
84
  for (const [key, verb] of [
77
85
  ["c", "comment"],
78
- ["s", "suggest"],
79
86
  ["x", "cut"],
80
87
  ["e", "edit"],
81
88
  ["enter", "submit"],
@@ -88,14 +95,13 @@ describe("observer verbs are blocked", () => {
88
95
  await press(setup, key);
89
96
  const frame = await waitForText(setup, "observer - read-only");
90
97
  // no overlay opened: compose/submit bars never appear
91
- expect(frame).not.toContain("COMMENT ON");
92
- expect(frame).not.toContain("SUGGEST REPLACEMENT");
98
+ expect(frame).not.toContain('comment on "');
93
99
  expect(frame).not.toContain("verdict ←/→");
94
100
  expect(snapshot()).toEqual(before);
95
101
  });
96
102
  }
97
103
 
98
- test("span mode c/s is blocked too", async () => {
104
+ test("span mode c is blocked too", async () => {
99
105
  // Arrange
100
106
  const setup = await renderObserver();
101
107
 
@@ -109,7 +115,7 @@ describe("observer verbs are blocked", () => {
109
115
  // Assert
110
116
  const frame = setup.captureCharFrame();
111
117
  expect(frame).toContain("observer - read-only");
112
- expect(frame).not.toContain("COMMENT ON");
118
+ expect(frame).not.toContain('comment on "');
113
119
  expect(snapshot().annotations).toBe(0);
114
120
  });
115
121
  });
@@ -144,7 +150,23 @@ describe("observer navigation still works", () => {
144
150
  await press(setup, "n");
145
151
  await setup.renderOnce();
146
152
 
147
- // Assert
148
- await waitForText(setup, "▸ COMMENT");
153
+ // Assert - focus shows as the card's bright kind-colored border (transparent
154
+ // fill); a selected card is the only accent-colored border in the frame
155
+ await waitForText(setup, "COMMENT · me");
156
+ await waitForState(setup, () => hasBorderColor(setup, DARK.accent));
149
157
  });
150
158
  });
159
+
160
+ /** Whether any border character (box-drawing) is painted in the given foreground hex. */
161
+ function hasBorderColor(setup: Awaited<ReturnType<typeof renderObserver>>, hex: string): boolean {
162
+ for (const line of setup.captureSpans().lines) {
163
+ for (const span of line.spans) {
164
+ if (!/[╭╮╰╯│─┌┐└┘]/.test(span.text)) continue;
165
+ const [red, green, blue] = span.fg.toInts();
166
+ const rendered =
167
+ "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
168
+ if (rendered === hex) return true;
169
+ }
170
+ }
171
+ return false;
172
+ }
@@ -51,7 +51,10 @@ afterEach(() => {
51
51
  });
52
52
 
53
53
  async function frame(): Promise<string> {
54
- const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 36 });
54
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
55
+ width: 120,
56
+ height: 36,
57
+ });
55
58
  await waitForText(setup, "Render Plan");
56
59
  // async highlights settle within the visual-idle wait
57
60
  await setup.waitForVisualIdle();
@@ -80,7 +83,8 @@ describe("code blocks", () => {
80
83
  describe("block spacing", () => {
81
84
  // the review panel's full-height divider paints a `│` on every row, so read
82
85
  // the plan column (everything left of the divider) before asserting spacing
83
- const planColumn = (line: string): string => line.split("│")[0]!.trimEnd();
86
+ // the plan body is framed: content sits between the left border and the next │
87
+ const planColumn = (line: string): string => line.split("│")[1]?.trimEnd() ?? "";
84
88
 
85
89
  test("a code block never glues to the list item above it", async () => {
86
90
  // Arrange
@@ -111,11 +115,17 @@ describe("block spacing", () => {
111
115
  workspace: { repoRoot: "/repo", branch: "main" },
112
116
  artifact: { type: "plan", content: listPlan, meta: {} },
113
117
  });
114
- const setup = await testRender(<App home={home} sessionId={s2.id} />, { width: 120, height: 30 });
118
+ const setup = await testRender(<App home={home} sessionId={s2.id} />, {
119
+ width: 120,
120
+ height: 30,
121
+ });
115
122
  await waitForText(setup, "one");
116
123
 
117
124
  // Assert
118
- const lines = setup.captureCharFrame().split("\n").map((line) => line.trimEnd());
125
+ const lines = setup
126
+ .captureCharFrame()
127
+ .split("\n")
128
+ .map((line) => line.trimEnd());
119
129
  const one = lines.findIndex((line) => line.includes("- one"));
120
130
  expect(lines[one + 1]).toContain("- two");
121
131
  });
@@ -1,7 +1,7 @@
1
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
2
 
3
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
- import { mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import { mkdtempSync, rmSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import React from "react";
@@ -10,7 +10,7 @@ import { DaemonServer } from "@cueloop/daemon";
10
10
  import type { ReviewSession } from "@cueloop/schema";
11
11
  import { App } from "./App";
12
12
  import { loadConfig } from "./config";
13
- import { isolateUserConfig, press, waitForText, waitForTextGone } from "./test-support";
13
+ import { isolateUserConfig, press, settle, waitForText, waitForTextGone } from "./test-support";
14
14
 
15
15
  const PLAN = "# Plan\n\nShip the thing.\n";
16
16
 
@@ -38,7 +38,10 @@ afterEach(() => {
38
38
  });
39
39
 
40
40
  async function renderApp() {
41
- const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 30 });
41
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
42
+ width: 120,
43
+ height: 30,
44
+ });
42
45
  await waitForText(setup, "Review");
43
46
  return setup;
44
47
  }
@@ -47,26 +50,35 @@ describe("review panel", () => {
47
50
  test("b cycles expanded -> compact -> hidden -> expanded", async () => {
48
51
  // Arrange
49
52
  const setup = await renderApp();
50
-
51
- // Assert
52
- expect(setup.captureCharFrame()).toContain("Submit review"); // expanded rail
53
+ // the plan frame widens as the rail shrinks: expanded < compact < hidden.
54
+ // its width is the column of its top-right corner (the first ╮ on the row).
55
+ const planWidth = () =>
56
+ setup
57
+ .captureCharFrame()
58
+ .split("\n")
59
+ .find((line) => line.includes("╮"))
60
+ ?.indexOf("╮") ?? 0;
61
+
62
+ // Assert: expanded shows the submit button and leaves room for the rail
63
+ expect(setup.captureCharFrame()).toContain("Submit review");
64
+ const expandedWidth = planWidth();
53
65
 
54
66
  // Act
55
67
  await press(setup, "b");
56
68
 
57
- // Assert
58
- await waitForText(setup, "«");
59
- let frame = setup.captureCharFrame();
60
- expect(frame).toContain("«"); // compact strip shows the expand chevron
61
- expect(frame).not.toContain("Submit review");
69
+ // Assert: compact drops the submit button and widens the plan (rail is a strip)
70
+ await waitForTextGone(setup, "Submit review");
71
+ const compactWidth = planWidth();
72
+ expect(compactWidth).toBeGreaterThan(expandedWidth);
62
73
 
63
74
  // Act
64
75
  await press(setup, "b");
76
+ await settle(setup);
65
77
 
66
- // Assert
67
- frame = await waitForTextGone(setup, "«");
68
- expect(frame).not.toContain("«"); // hidden: no strip, no divider
69
- expect(frame).not.toContain("│");
78
+ // Assert: hidden spans the plan full width, no rail column
79
+ const hiddenWidth = planWidth();
80
+ expect(hiddenWidth).toBeGreaterThan(compactWidth);
81
+ expect(hiddenWidth).toBeGreaterThan(115);
70
82
 
71
83
  // Act
72
84
  await press(setup, "b");
@@ -35,7 +35,11 @@ beforeEach(() => {
35
35
  server.start();
36
36
  session = server.core.sessionCreate({
37
37
  workspace: { repoRoot: "/repo", branch: "main" },
38
- artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
38
+ artifact: {
39
+ type: "plan",
40
+ content: PLAN,
41
+ meta: { title: "Migration Plan", planPath: "plan.md" },
42
+ },
39
43
  });
40
44
  });
41
45
  afterEach(() => {
@@ -45,16 +49,17 @@ afterEach(() => {
45
49
  });
46
50
 
47
51
  async function renderApp(options: { readOnly?: boolean } = {}) {
48
- const setup = await testRender(<App home={home} sessionId={session.id} readOnly={options.readOnly ?? false} />, {
49
- width: 120,
50
- height: 32,
51
- });
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
+ );
52
59
  await waitForText(setup, "cueloop");
53
60
  return setup;
54
61
  }
55
62
 
56
- type Setup = Awaited<ReturnType<typeof renderApp>>;
57
-
58
63
  /** Seed annotations directly through the daemon core (all on one block). */
59
64
  function seedAnnotations(count: number): void {
60
65
  const blocks = parseBlocks(PLAN);
@@ -74,7 +79,7 @@ describe("rail submit confirm", () => {
74
79
  const setup = await renderApp();
75
80
 
76
81
  // Assert
77
- expect(setup.captureCharFrame()).toContain("Submit review (0)");
82
+ expect(setup.captureCharFrame()).toContain("Submit review");
78
83
 
79
84
  // Act
80
85
  await press(setup, "enter");
@@ -88,10 +93,6 @@ describe("rail submit confirm", () => {
88
93
  expect(frame).toContain("[Approve]"); // nothing pending: approve default
89
94
  expect(frame).toContain(" Submit ");
90
95
  expect(frame).toContain(" Cancel ");
91
- // key hints live in the status line, not on the buttons
92
- expect(frame).toContain("verdict ←/→ · ⏎ submit · esc cancel");
93
- // the bottom bar stayed a one-line hint: no detached verdict bar
94
- expect(frame).not.toContain("Submit review (0) on ⏎");
95
96
  });
96
97
 
97
98
  test("left/right cycles the verdict selector in the card", async () => {
@@ -136,7 +137,7 @@ describe("rail submit confirm", () => {
136
137
  // a bare ESC settles after the parser's escape-sequence window
137
138
  const frame = await waitForTextGone(setup, "[Approve]");
138
139
  expect(frame).not.toContain("0 annotations");
139
- expect(frame).toContain("Submit review (0)");
140
+ expect(frame).toContain("Submit review");
140
141
  });
141
142
 
142
143
  test("enter in the card resolves the session through the controller", async () => {
package/src/App.test.tsx CHANGED
@@ -9,7 +9,13 @@ 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, press, typeText as type, waitForState, waitForText } from "./test-support";
12
+ import {
13
+ isolateUserConfig,
14
+ press,
15
+ typeText as type,
16
+ waitForState,
17
+ waitForText,
18
+ } from "./test-support";
13
19
 
14
20
  const PLAN = `# Migration Plan
15
21
 
@@ -35,7 +41,11 @@ beforeEach(() => {
35
41
  server.start();
36
42
  session = server.core.sessionCreate({
37
43
  workspace: { repoRoot: "/repo", branch: "main" },
38
- 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
+ },
39
49
  });
40
50
  });
41
51
  afterEach(() => {
@@ -65,8 +75,8 @@ describe("plan rendering", () => {
65
75
  expect(frame).toContain("Context");
66
76
  expect(frame).toContain("persists sessions to disk atomically");
67
77
  expect(frame).toContain("- move the store");
68
- expect(frame).toContain("Review (0)");
69
- expect(frame).toContain("Submit review (0)");
78
+ expect(frame).toContain("Review");
79
+ expect(frame).toContain("Submit review");
70
80
  });
71
81
  });
72
82
 
@@ -138,8 +148,8 @@ describe("keyboard grammar", () => {
138
148
  // Act
139
149
  await press(setup, "x");
140
150
 
141
- // Assert
142
- await waitForText(setup, "[cut]");
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
154
 
145
155
  // Act
@@ -179,7 +189,7 @@ describe("submit", () => {
179
189
  await press(setup, "c");
180
190
  await type(setup, "Needs a phase list.");
181
191
  await press(setup, "enter");
182
- await waitForText(setup, "Review (1)");
192
+ await waitForText(setup, "Needs a phase list");
183
193
 
184
194
  // Act
185
195
  await press(setup, "enter"); // open submit
@@ -221,19 +231,46 @@ describe("submit", () => {
221
231
  });
222
232
 
223
233
  describe("inbox", () => {
224
- 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 () => {
225
235
  // Arrange
226
236
  const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
227
- await waitForText(setup, "inbox");
237
+ await waitForText(setup, "cueloop");
228
238
 
229
- // Assert
230
- expect(setup.captureCharFrame()).toContain("inbox (1 pending)");
231
- expect(setup.captureCharFrame()).toContain("Migration Plan");
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
232
245
 
233
246
  // Act
234
247
  await press(setup, "enter");
235
248
 
236
249
  // Assert
237
- await waitForText(setup, "Submit review (0)");
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");
238
275
  });
239
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
+ });