@cueloop/client 0.1.0-alpha.31 → 0.1.0-alpha.33

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 (61) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +4 -3
  3. package/src/App.diff.test.tsx +5 -2
  4. package/src/App.export.test.tsx +5 -7
  5. package/src/App.plan-review.test.tsx +14 -17
  6. package/src/App.readonly.test.tsx +9 -7
  7. package/src/App.rendering.test.tsx +6 -2
  8. package/src/App.review-panel.test.tsx +19 -14
  9. package/src/App.submit-confirm.test.tsx +6 -7
  10. package/src/App.test.tsx +8 -5
  11. package/src/App.toast.test.tsx +68 -0
  12. package/src/App.tsx +154 -50
  13. package/src/App.walk.test.tsx +7 -4
  14. package/src/components/AnnotationCard.stories.tsx +1 -1
  15. package/src/components/AnnotationCard.tsx +18 -13
  16. package/src/components/CompletionOverlay.tsx +1 -1
  17. package/src/components/ConfirmCard.tsx +1 -0
  18. package/src/components/ConfirmDialog.stories.tsx +1 -1
  19. package/src/components/ConfirmDialog.tsx +1 -1
  20. package/src/components/DiffSheet.stories.tsx +21 -3
  21. package/src/components/DiffSheet.tsx +45 -19
  22. package/src/components/InboxList.stories.tsx +1 -1
  23. package/src/components/InboxList.tsx +3 -3
  24. package/src/components/KeybindsDialog.stories.tsx +31 -0
  25. package/src/components/KeybindsDialog.tsx +42 -0
  26. package/src/components/MenuBar.stories.tsx +33 -0
  27. package/src/components/MenuBar.tsx +58 -0
  28. package/src/components/PlanSheet.stories.tsx +2 -2
  29. package/src/components/PlanSheet.tsx +26 -75
  30. package/src/components/PromptDialog.stories.tsx +2 -2
  31. package/src/components/PromptDialog.tsx +1 -1
  32. package/src/components/ReviewPanel.stories.tsx +5 -17
  33. package/src/components/ReviewPanel.tsx +10 -29
  34. package/src/components/ReviewRail.stories.tsx +39 -0
  35. package/src/components/ReviewRail.tsx +62 -57
  36. package/src/components/SettingsDialog.stories.tsx +1 -1
  37. package/src/components/SettingsDialog.tsx +4 -3
  38. package/src/components/SettingsRows.stories.tsx +1 -1
  39. package/src/components/SettingsRows.tsx +1 -1
  40. package/src/components/Toast.stories.tsx +12 -0
  41. package/src/components/Toast.tsx +29 -0
  42. package/src/components/WalkWizard.stories.tsx +1 -1
  43. package/src/components/WalkWizard.tsx +1 -1
  44. package/src/components/__snapshots__/stories.test.tsx.snap +598 -566
  45. package/src/components/primitives/Button.stories.tsx +2 -2
  46. package/src/components/primitives/Dialog.stories.tsx +1 -1
  47. package/src/components/primitives/Dialog.tsx +7 -5
  48. package/src/components/primitives/Tabs.tsx +19 -38
  49. package/src/components/stories-app.tsx +1 -1
  50. package/src/components/stories.test.tsx +2 -0
  51. package/src/key-bindings.test.ts +4 -4
  52. package/src/key-bindings.ts +41 -2
  53. package/src/serve.test.ts +2 -2
  54. package/src/session-controller.share.test.ts +13 -0
  55. package/src/session-controller.ts +19 -1
  56. package/src/test-support.test.ts +55 -0
  57. package/src/test-support.ts +113 -23
  58. package/src/theme.ts +16 -16
  59. package/src/version.ts +5 -0
  60. package/src/components/ComposeBar.stories.tsx +0 -18
  61. package/src/components/ComposeBar.tsx +0 -54
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.31",
3
+ "version": "0.1.0-alpha.33",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.ts"
8
8
  },
9
9
  "dependencies": {
10
- "@cueloop/daemon": "0.1.0-alpha.31",
11
- "@cueloop/extension-api": "0.1.0-alpha.31",
12
- "@cueloop/integration-obsidian": "0.1.0-alpha.31",
13
- "@cueloop/schema": "0.1.0-alpha.31",
10
+ "@cueloop/daemon": "0.1.0-alpha.33",
11
+ "@cueloop/extension-api": "0.1.0-alpha.33",
12
+ "@cueloop/integration-obsidian": "0.1.0-alpha.33",
13
+ "@cueloop/schema": "0.1.0-alpha.33",
14
14
  "@opentui/core": "^0.5.2",
15
15
  "@opentui/keymap": "0.5.2",
16
16
  "@opentui/react": "^0.5.2",
@@ -10,7 +10,7 @@ import { ManualClock } from "@opentui/core/testing";
10
10
  import { DaemonServer } from "@cueloop/daemon";
11
11
  import type { ReviewSession } from "@cueloop/schema";
12
12
  import { App } from "./App";
13
- import { press, settle, waitForText } from "./test-support";
13
+ import { isolateUserConfig, press, settle, waitForText } from "./test-support";
14
14
 
15
15
  const PLAN = "# Plan\n\nShip the thing.\n";
16
16
 
@@ -18,12 +18,13 @@ let home: string;
18
18
  let server: DaemonServer;
19
19
  let session: ReviewSession;
20
20
  let configPath: string;
21
+ let restoreUserConfig: () => void;
21
22
  let clock: ManualClock;
22
23
 
23
24
  beforeEach(() => {
24
25
  home = mkdtempSync(join(tmpdir(), "cueloop-close-"));
25
26
  configPath = join(home, "config.toml");
26
- process.env.CUELOOP_CONFIG = configPath;
27
+ restoreUserConfig = isolateUserConfig(home, "config.toml");
27
28
  server = new DaemonServer({ home, idleExitMs: 0 });
28
29
  server.start();
29
30
  session = server.core.sessionCreate({
@@ -33,7 +34,7 @@ beforeEach(() => {
33
34
  clock = new ManualClock();
34
35
  });
35
36
  afterEach(() => {
36
- delete process.env.CUELOOP_CONFIG;
37
+ restoreUserConfig();
37
38
  server.stop();
38
39
  rmSync(home, { recursive: true, force: true });
39
40
  });
@@ -9,7 +9,7 @@ import { testRender } from "@opentui/react/test-utils";
9
9
  import { DaemonServer } from "@cueloop/daemon";
10
10
  import type { ReviewSession } from "@cueloop/schema";
11
11
  import { App } from "./App";
12
- import { press, waitForText } from "./test-support";
12
+ import { isolateUserConfig, press, waitForText } from "./test-support";
13
13
 
14
14
  const PATCH = `diff --git a/src/store.ts b/src/store.ts
15
15
  index 111..222 100644
@@ -23,11 +23,13 @@ index 111..222 100644
23
23
  `;
24
24
 
25
25
  let home: string;
26
+ let restoreUserConfig: () => void;
26
27
  let server: DaemonServer;
27
28
  let session: ReviewSession;
28
29
 
29
30
  beforeEach(() => {
30
31
  home = mkdtempSync(join(tmpdir(), "cueloop-diff-"));
32
+ restoreUserConfig = isolateUserConfig(home);
31
33
  server = new DaemonServer({ home, idleExitMs: 0 });
32
34
  server.start();
33
35
  session = server.core.sessionCreate({
@@ -36,6 +38,7 @@ beforeEach(() => {
36
38
  });
37
39
  });
38
40
  afterEach(() => {
41
+ restoreUserConfig();
39
42
  server.stop();
40
43
  rmSync(home, { recursive: true, force: true });
41
44
  });
@@ -69,7 +72,7 @@ describe("diff review", () => {
69
72
  await press(setup, "c");
70
73
 
71
74
  // Assert
72
- await waitForText(setup, "COMMENT ON");
75
+ await waitForText(setup, 'comment on "');
73
76
 
74
77
  // Act
75
78
  await setup.mockInput.typeText("Map needs an eviction story.");
@@ -9,7 +9,7 @@ import { testRender } from "@opentui/react/test-utils";
9
9
  import { DaemonServer } from "@cueloop/daemon";
10
10
  import type { ReviewSession } from "@cueloop/schema";
11
11
  import { App } from "./App";
12
- import { press, waitForState, waitForText } from "./test-support";
12
+ import { isolateUserConfig, press, waitForState, waitForText } from "./test-support";
13
13
 
14
14
  const PLAN = `# Migration Plan
15
15
 
@@ -20,7 +20,7 @@ let home: string;
20
20
  let vault: string;
21
21
  let server: DaemonServer;
22
22
  let session: ReviewSession;
23
- let savedConfigEnv: string | undefined;
23
+ let restoreUserConfig: () => void;
24
24
 
25
25
  beforeEach(() => {
26
26
  home = mkdtempSync(join(tmpdir(), "cueloop-app-export-"));
@@ -28,8 +28,7 @@ beforeEach(() => {
28
28
  mkdirSync(vault);
29
29
  const configPath = join(home, "config.toml");
30
30
  writeFileSync(configPath, `[integrations.obsidian]\nvault = ${JSON.stringify(vault)}\nexportOn = "resolve"\n`);
31
- savedConfigEnv = process.env.CUELOOP_CONFIG;
32
- process.env.CUELOOP_CONFIG = configPath;
31
+ restoreUserConfig = isolateUserConfig(home, "config.toml");
33
32
  server = new DaemonServer({ home, idleExitMs: 0 });
34
33
  server.start();
35
34
  session = server.core.sessionCreate({
@@ -38,8 +37,7 @@ beforeEach(() => {
38
37
  });
39
38
  });
40
39
  afterEach(() => {
41
- if (savedConfigEnv === undefined) delete process.env.CUELOOP_CONFIG;
42
- else process.env.CUELOOP_CONFIG = savedConfigEnv;
40
+ restoreUserConfig();
43
41
  server.stop();
44
42
  rmSync(home, { recursive: true, force: true });
45
43
  });
@@ -54,7 +52,7 @@ describe("obsidian export on resolve", () => {
54
52
  await press(setup, "enter"); // open the submit overlay, keep the default verdict (approve)
55
53
 
56
54
  // Assert
57
- await waitForText(setup, "verdict");
55
+ await waitForText(setup, "[Approve]");
58
56
 
59
57
  // Act
60
58
  await press(setup, "enter"); // submit
@@ -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 { DARK as T } from "./theme";
13
- import { press, settle, typeText as type, waitForState, waitForText, waitForTextGone } from "./test-support";
13
+ import { isolateUserConfig, press, settle, typeText as type, waitForState, waitForText, waitForTextGone } from "./test-support";
14
14
 
15
15
  const PLAN = `# Migration Plan
16
16
 
@@ -27,14 +27,11 @@ The daemon persists sessions to disk atomically.
27
27
  let home: string;
28
28
  let server: DaemonServer;
29
29
  let session: ReviewSession;
30
- let priorConfig: string | undefined;
30
+ let restoreUserConfig: () => void;
31
31
 
32
32
  beforeEach(() => {
33
33
  home = mkdtempSync(join(tmpdir(), "cueloop-plan-review-"));
34
- // Isolate from the dev's real config so the rail state is the default here,
35
- // not whatever review_state they last persisted (CI runs clean already).
36
- priorConfig = process.env.CUELOOP_CONFIG;
37
- process.env.CUELOOP_CONFIG = join(home, "no-config.toml");
34
+ restoreUserConfig = isolateUserConfig(home);
38
35
  server = new DaemonServer({ home, idleExitMs: 0 });
39
36
  server.start();
40
37
  session = server.core.sessionCreate({
@@ -43,8 +40,7 @@ beforeEach(() => {
43
40
  });
44
41
  });
45
42
  afterEach(() => {
46
- if (priorConfig === undefined) delete process.env.CUELOOP_CONFIG;
47
- else process.env.CUELOOP_CONFIG = priorConfig;
43
+ restoreUserConfig();
48
44
  server.stop();
49
45
  rmSync(home, { recursive: true, force: true });
50
46
  });
@@ -225,7 +221,7 @@ describe("inline compose keeps the anchor painted", () => {
225
221
  // Assert
226
222
  // compose open: the anchor is painted selection-style. The box can render
227
223
  // a frame before the anchor repaint settles, so wait on the color itself.
228
- await waitForText(setup, "Save ⏎");
224
+ await waitForText(setup, "Save");
229
225
  expect(setup.captureCharFrame()).toContain("Cancel esc");
230
226
  await waitForState(setup, () => backgroundsOf(setup, "The daemon").includes(T.accent));
231
227
  expect(backgroundsOf(setup, "The daemon")).toContain(T.accent);
@@ -250,11 +246,11 @@ describe("inline compose keeps the anchor painted", () => {
250
246
  // the whole cursor block is the anchor here; check the highlight landed
251
247
  const stored = server.core.sessionGet(session.id);
252
248
  expect(stored.annotations.length).toBe(1);
253
- expect(backgroundsOf(setup, stored.annotations[0]!.anchor.quote.slice(0, 20))).toContain(T.markCommentBg);
249
+ expect(backgroundsOf(setup, stored.annotations[0]!.anchor.quote.slice(0, 20))).toContain(T.markCommentBackground);
254
250
  // renderApp plus this many frame-waits grazes the 5s default on a loaded CI
255
251
  // runner, and the whole-suite publish lane has timed even 15s out; give the
256
252
  // heaviest frame-wait chain generous headroom so runner load cannot flake it.
257
- }, 30_000);
253
+ }, 60_000);
258
254
  });
259
255
 
260
256
  describe("compose newline convention", () => {
@@ -396,7 +392,7 @@ describe("the document selects, the rail edits", () => {
396
392
  await press(setup, "enter");
397
393
  await waitForText(setup, "COMMENT · pending");
398
394
  expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
399
- expect(backgroundsOf(setup, "persists sessions")).toContain(T.markCommentBg);
395
+ expect(backgroundsOf(setup, "persists sessions")).toContain(T.markCommentBackground);
400
396
 
401
397
  // Act
402
398
  await press(setup, "x");
@@ -404,7 +400,7 @@ describe("the document selects, the rail edits", () => {
404
400
  // Assert
405
401
  await waitForTextGone(setup, "COMMENT · pending");
406
402
  expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
407
- expect(backgroundsOf(setup, "persists sessions")).not.toContain(T.markCommentBg);
403
+ expect(backgroundsOf(setup, "persists sessions")).not.toContain(T.markCommentBackground);
408
404
  });
409
405
  });
410
406
 
@@ -436,8 +432,8 @@ describe("addressed annotations leave the open list", () => {
436
432
  const frame = setup.captureCharFrame();
437
433
  expect(frame).toContain("✓ 1 addressed by revision");
438
434
  expect(frame).not.toContain("settled note");
439
- expect(frame).toContain("Review (1)"); // the addressed card no longer counts as pending
440
- expect(backgroundsOf(setup, "The daemon")).not.toContain(T.markCommentBg); // no highlight paint
435
+ expect(frame).toContain("still open note"); // the open card survives; the addressed one does not
436
+ expect(backgroundsOf(setup, "The daemon")).not.toContain(T.markCommentBackground); // no highlight paint
441
437
  });
442
438
  });
443
439
 
@@ -448,7 +444,8 @@ describe("sheet header", () => {
448
444
 
449
445
  // Assert
450
446
  const frame = setup.captureCharFrame();
451
- expect(frame).toContain("submitted by agent/worker-3 · revision 1");
447
+ expect(frame).toContain("submitted by agent/worker-3");
448
+ expect(frame).toContain("rev 1");
452
449
  expect(frame).toContain("Edit");
453
450
  expect(frame).toContain("Share");
454
451
  });
@@ -457,7 +454,7 @@ describe("sheet header", () => {
457
454
  // Arrange
458
455
  const setup = await renderApp();
459
456
  const lines = setup.captureCharFrame().split("\n");
460
- const tabRow = lines.findIndex((line) => line.includes("Review (0)"));
457
+ const tabRow = lines.findIndex((line) => line.includes("Review") && line.includes("Agent"));
461
458
  const tabColumn = lines[tabRow]!.indexOf("Agent");
462
459
 
463
460
  // Act
@@ -9,7 +9,7 @@ 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 { press, waitForText } from "./test-support";
12
+ import { isolateUserConfig, press, waitForText } from "./test-support";
13
13
 
14
14
  const PLAN = `# Migration Plan
15
15
 
@@ -24,11 +24,13 @@ The daemon persists sessions to disk atomically.
24
24
  `;
25
25
 
26
26
  let home: string;
27
+ let restoreUserConfig: () => void;
27
28
  let server: DaemonServer;
28
29
  let session: ReviewSession;
29
30
 
30
31
  beforeEach(() => {
31
32
  home = mkdtempSync(join(tmpdir(), "cueloop-observer-"));
33
+ restoreUserConfig = isolateUserConfig(home);
32
34
  server = new DaemonServer({ home, idleExitMs: 0 });
33
35
  server.start();
34
36
  session = server.core.sessionCreate({
@@ -37,6 +39,7 @@ beforeEach(() => {
37
39
  });
38
40
  });
39
41
  afterEach(() => {
42
+ restoreUserConfig();
40
43
  server.stop();
41
44
  rmSync(home, { recursive: true, force: true });
42
45
  });
@@ -57,15 +60,14 @@ function snapshot() {
57
60
  }
58
61
 
59
62
  describe("observer rendering", () => {
60
- test("shows the observer badge and read-only hint bar", async () => {
63
+ test("shows the observer badge and the bottom menu", async () => {
61
64
  // Arrange
62
65
  const setup = await renderObserver();
63
66
 
64
67
  // Assert
65
68
  const frame = setup.captureCharFrame();
66
69
  expect(frame).toContain("· observer");
67
- expect(frame).toContain("observer - read-only · j/k move");
68
- expect(frame).not.toContain("⏎ submit");
70
+ expect(frame).toContain("menu");
69
71
  });
70
72
  });
71
73
 
@@ -85,8 +87,8 @@ describe("observer verbs are blocked", () => {
85
87
  await press(setup, key);
86
88
  const frame = await waitForText(setup, "observer - read-only");
87
89
  // no overlay opened: compose/submit bars never appear
88
- expect(frame).not.toContain("COMMENT ON");
89
- expect(frame).not.toContain("SUGGEST REPLACEMENT");
90
+ expect(frame).not.toContain('comment on "');
91
+ expect(frame).not.toContain('suggest replacement for "');
90
92
  expect(frame).not.toContain("verdict ←/→");
91
93
  expect(snapshot()).toEqual(before);
92
94
  });
@@ -106,7 +108,7 @@ describe("observer verbs are blocked", () => {
106
108
  // Assert
107
109
  const frame = setup.captureCharFrame();
108
110
  expect(frame).toContain("observer - read-only");
109
- expect(frame).not.toContain("COMMENT ON");
111
+ expect(frame).not.toContain('comment on "');
110
112
  expect(snapshot().annotations).toBe(0);
111
113
  });
112
114
  });
@@ -9,7 +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 { waitForText } from "./test-support";
12
+ import { isolateUserConfig, waitForText } from "./test-support";
13
13
 
14
14
  const PLAN = `# Render Plan
15
15
 
@@ -30,11 +30,13 @@ Deploys block below the threshold.
30
30
  `;
31
31
 
32
32
  let home: string;
33
+ let restoreUserConfig: () => void;
33
34
  let server: DaemonServer;
34
35
  let session: ReviewSession;
35
36
 
36
37
  beforeEach(() => {
37
38
  home = mkdtempSync(join(tmpdir(), "cueloop-render-"));
39
+ restoreUserConfig = isolateUserConfig(home);
38
40
  server = new DaemonServer({ home, idleExitMs: 0 });
39
41
  server.start();
40
42
  session = server.core.sessionCreate({
@@ -43,6 +45,7 @@ beforeEach(() => {
43
45
  });
44
46
  });
45
47
  afterEach(() => {
48
+ restoreUserConfig();
46
49
  server.stop();
47
50
  rmSync(home, { recursive: true, force: true });
48
51
  });
@@ -77,7 +80,8 @@ describe("code blocks", () => {
77
80
  describe("block spacing", () => {
78
81
  // the review panel's full-height divider paints a `│` on every row, so read
79
82
  // the plan column (everything left of the divider) before asserting spacing
80
- const planColumn = (line: string): string => line.split("│")[0]!.trimEnd();
83
+ // the plan body is framed: content sits between the left border and the next │
84
+ const planColumn = (line: string): string => line.split("│")[1]?.trimEnd() ?? "";
81
85
 
82
86
  test("a code block never glues to the list item above it", async () => {
83
87
  // Arrange
@@ -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 { 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
 
@@ -18,11 +18,12 @@ let home: string;
18
18
  let server: DaemonServer;
19
19
  let session: ReviewSession;
20
20
  let configPath: string;
21
+ let restoreUserConfig: () => void;
21
22
 
22
23
  beforeEach(() => {
23
24
  home = mkdtempSync(join(tmpdir(), "cueloop-panel-"));
24
25
  configPath = join(home, "config.toml");
25
- process.env.CUELOOP_CONFIG = configPath;
26
+ restoreUserConfig = isolateUserConfig(home, "config.toml");
26
27
  server = new DaemonServer({ home, idleExitMs: 0 });
27
28
  server.start();
28
29
  session = server.core.sessionCreate({
@@ -31,7 +32,7 @@ beforeEach(() => {
31
32
  });
32
33
  });
33
34
  afterEach(() => {
34
- delete process.env.CUELOOP_CONFIG;
35
+ restoreUserConfig();
35
36
  server.stop();
36
37
  rmSync(home, { recursive: true, force: true });
37
38
  });
@@ -46,26 +47,30 @@ describe("review panel", () => {
46
47
  test("b cycles expanded -> compact -> hidden -> expanded", async () => {
47
48
  // Arrange
48
49
  const setup = await renderApp();
50
+ // the plan frame widens as the rail shrinks: expanded < compact < hidden.
51
+ // its width is the column of its top-right corner (the first ╮ on the row).
52
+ const planWidth = () => setup.captureCharFrame().split("\n").find((line) => line.includes("╮"))?.indexOf("╮") ?? 0;
49
53
 
50
- // Assert
51
- expect(setup.captureCharFrame()).toContain("Submit review"); // expanded rail
54
+ // Assert: expanded shows the submit button and leaves room for the rail
55
+ expect(setup.captureCharFrame()).toContain("Submit review");
56
+ const expandedWidth = planWidth();
52
57
 
53
58
  // Act
54
59
  await press(setup, "b");
55
60
 
56
- // Assert
57
- await waitForText(setup, "«");
58
- let frame = setup.captureCharFrame();
59
- expect(frame).toContain("«"); // compact strip shows the expand chevron
60
- expect(frame).not.toContain("Submit review");
61
+ // Assert: compact drops the submit button and widens the plan (rail is a strip)
62
+ await waitForTextGone(setup, "Submit review");
63
+ const compactWidth = planWidth();
64
+ expect(compactWidth).toBeGreaterThan(expandedWidth);
61
65
 
62
66
  // Act
63
67
  await press(setup, "b");
68
+ await settle(setup);
64
69
 
65
- // Assert
66
- frame = await waitForTextGone(setup, "«");
67
- expect(frame).not.toContain("«"); // hidden: no strip, no divider
68
- expect(frame).not.toContain("│");
70
+ // Assert: hidden spans the plan full width, no rail column
71
+ const hiddenWidth = planWidth();
72
+ expect(hiddenWidth).toBeGreaterThan(compactWidth);
73
+ expect(hiddenWidth).toBeGreaterThan(115);
69
74
 
70
75
  // Act
71
76
  await press(setup, "b");
@@ -9,7 +9,7 @@ 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 { press, settle, waitForText, waitForTextGone } from "./test-support";
12
+ import { isolateUserConfig, press, settle, waitForText, waitForTextGone } from "./test-support";
13
13
 
14
14
  const PLAN = `# Migration Plan
15
15
 
@@ -24,11 +24,13 @@ The daemon persists sessions to disk atomically.
24
24
  `;
25
25
 
26
26
  let home: string;
27
+ let restoreUserConfig: () => void;
27
28
  let server: DaemonServer;
28
29
  let session: ReviewSession;
29
30
 
30
31
  beforeEach(() => {
31
32
  home = mkdtempSync(join(tmpdir(), "cueloop-confirm-"));
33
+ restoreUserConfig = isolateUserConfig(home);
32
34
  server = new DaemonServer({ home, idleExitMs: 0 });
33
35
  server.start();
34
36
  session = server.core.sessionCreate({
@@ -37,6 +39,7 @@ beforeEach(() => {
37
39
  });
38
40
  });
39
41
  afterEach(() => {
42
+ restoreUserConfig();
40
43
  server.stop();
41
44
  rmSync(home, { recursive: true, force: true });
42
45
  });
@@ -71,7 +74,7 @@ describe("rail submit confirm", () => {
71
74
  const setup = await renderApp();
72
75
 
73
76
  // Assert
74
- expect(setup.captureCharFrame()).toContain("Submit review (0)");
77
+ expect(setup.captureCharFrame()).toContain("Submit review");
75
78
 
76
79
  // Act
77
80
  await press(setup, "enter");
@@ -85,10 +88,6 @@ describe("rail submit confirm", () => {
85
88
  expect(frame).toContain("[Approve]"); // nothing pending: approve default
86
89
  expect(frame).toContain(" Submit ");
87
90
  expect(frame).toContain(" Cancel ");
88
- // key hints live in the status line, not on the buttons
89
- expect(frame).toContain("verdict ←/→ · ⏎ submit · esc cancel");
90
- // the bottom bar stayed a one-line hint: no detached verdict bar
91
- expect(frame).not.toContain("Submit review (0) on ⏎");
92
91
  });
93
92
 
94
93
  test("left/right cycles the verdict selector in the card", async () => {
@@ -133,7 +132,7 @@ describe("rail submit confirm", () => {
133
132
  // a bare ESC settles after the parser's escape-sequence window
134
133
  const frame = await waitForTextGone(setup, "[Approve]");
135
134
  expect(frame).not.toContain("0 annotations");
136
- expect(frame).toContain("Submit review (0)");
135
+ expect(frame).toContain("Submit review");
137
136
  });
138
137
 
139
138
  test("enter in the card resolves the session through the controller", async () => {
package/src/App.test.tsx CHANGED
@@ -9,7 +9,7 @@ import { testRender } from "@opentui/react/test-utils";
9
9
  import { DaemonServer } from "@cueloop/daemon";
10
10
  import type { ReviewSession } from "@cueloop/schema";
11
11
  import { App } from "./App";
12
- import { press, typeText as type, waitForState, waitForText } from "./test-support";
12
+ import { isolateUserConfig, press, typeText as type, waitForState, waitForText } from "./test-support";
13
13
 
14
14
  const PLAN = `# Migration Plan
15
15
 
@@ -24,11 +24,13 @@ The daemon persists sessions to disk atomically.
24
24
  `;
25
25
 
26
26
  let home: string;
27
+ let restoreUserConfig: () => void;
27
28
  let server: DaemonServer;
28
29
  let session: ReviewSession;
29
30
 
30
31
  beforeEach(() => {
31
32
  home = mkdtempSync(join(tmpdir(), "cueloop-app-"));
33
+ restoreUserConfig = isolateUserConfig(home);
32
34
  server = new DaemonServer({ home, idleExitMs: 0 });
33
35
  server.start();
34
36
  session = server.core.sessionCreate({
@@ -37,6 +39,7 @@ beforeEach(() => {
37
39
  });
38
40
  });
39
41
  afterEach(() => {
42
+ restoreUserConfig();
40
43
  server.stop();
41
44
  rmSync(home, { recursive: true, force: true });
42
45
  });
@@ -62,8 +65,8 @@ describe("plan rendering", () => {
62
65
  expect(frame).toContain("Context");
63
66
  expect(frame).toContain("persists sessions to disk atomically");
64
67
  expect(frame).toContain("- move the store");
65
- expect(frame).toContain("Review (0)");
66
- expect(frame).toContain("Submit review (0)");
68
+ expect(frame).toContain("Review");
69
+ expect(frame).toContain("Submit review");
67
70
  });
68
71
  });
69
72
 
@@ -176,7 +179,7 @@ describe("submit", () => {
176
179
  await press(setup, "c");
177
180
  await type(setup, "Needs a phase list.");
178
181
  await press(setup, "enter");
179
- await waitForText(setup, "Review (1)");
182
+ await waitForText(setup, "Needs a phase list");
180
183
 
181
184
  // Act
182
185
  await press(setup, "enter"); // open submit
@@ -231,6 +234,6 @@ describe("inbox", () => {
231
234
  await press(setup, "enter");
232
235
 
233
236
  // Assert
234
- await waitForText(setup, "Submit review (0)");
237
+ await waitForText(setup, "Submit review");
235
238
  });
236
239
  });
@@ -0,0 +1,68 @@
1
+ /** The share toast is non-modal: while it is up, escape still cancels an open overlay. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, 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: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
40
+ });
41
+ });
42
+ afterEach(() => {
43
+ restoreUserConfig();
44
+ server.stop();
45
+ rmSync(home, { recursive: true, force: true });
46
+ });
47
+
48
+ describe("share toast", () => {
49
+ test("escape cancels an open composer even while the toast is up", async () => {
50
+ // Arrange
51
+ const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 32 });
52
+ await waitForText(setup, "cueloop");
53
+
54
+ // Act: share raises the toast, then open a composer under it
55
+ await press(setup, "S");
56
+ await waitForText(setup, "share link copied");
57
+ await press(setup, "j");
58
+ await press(setup, "j");
59
+ await press(setup, "c");
60
+ await waitForText(setup, 'comment on "');
61
+
62
+ // Act: escape must reach the composer, not get eaten by the toast
63
+ await press(setup, "escape");
64
+
65
+ // Assert: the composer closed on the first escape
66
+ await waitForTextGone(setup, 'comment on "');
67
+ });
68
+ });