@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
@@ -1,4 +1,4 @@
1
- /** Post-submit completion overlay: prompt, opt-in persistence, countdown, dismissal. */
1
+ /** Post-submit completion overlay: prompt, opt-in persistence, countdown, dismissal. Countdown seconds tick on an injected ManualClock, so tests advance time instead of waiting it out. */
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
4
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -6,9 +6,11 @@ import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import React from "react";
8
8
  import { testRender } from "@opentui/react/test-utils";
9
+ import { ManualClock } from "@opentui/core/testing";
9
10
  import { DaemonServer } from "@cueloop/daemon";
10
11
  import type { ReviewSession } from "@cueloop/schema";
11
12
  import { App } from "./App";
13
+ import { isolateUserConfig, press, settle, waitForText } from "./test-support";
12
14
 
13
15
  const PLAN = "# Plan\n\nShip the thing.\n";
14
16
 
@@ -16,103 +18,127 @@ let home: string;
16
18
  let server: DaemonServer;
17
19
  let session: ReviewSession;
18
20
  let configPath: string;
21
+ let restoreUserConfig: () => void;
22
+ let clock: ManualClock;
19
23
 
20
24
  beforeEach(() => {
21
25
  home = mkdtempSync(join(tmpdir(), "cueloop-close-"));
22
26
  configPath = join(home, "config.toml");
23
- process.env.CUELOOP_CONFIG = configPath;
27
+ restoreUserConfig = isolateUserConfig(home, "config.toml");
24
28
  server = new DaemonServer({ home, idleExitMs: 0 });
25
29
  server.start();
26
30
  session = server.core.sessionCreate({
27
31
  workspace: { repoRoot: "/repo", branch: "main" },
28
32
  artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
29
33
  });
34
+ clock = new ManualClock();
30
35
  });
31
36
  afterEach(() => {
32
- delete process.env.CUELOOP_CONFIG;
37
+ restoreUserConfig();
33
38
  server.stop();
34
39
  rmSync(home, { recursive: true, force: true });
35
40
  });
36
41
 
37
42
  async function renderApp(onExit?: (code: number) => void) {
38
- const setup = await testRender(<App home={home} sessionId={session.id} onExit={onExit} />, {
39
- width: 120,
40
- height: 30,
41
- });
42
- for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
43
- await Bun.sleep(25);
44
- await setup.renderOnce();
45
- }
43
+ const setup = await testRender(
44
+ <App home={home} sessionId={session.id} onExit={onExit} clock={clock} />,
45
+ {
46
+ width: 120,
47
+ height: 30,
48
+ },
49
+ );
50
+
51
+ await waitForText(setup, "cueloop");
52
+
46
53
  return setup;
47
54
  }
48
55
 
49
56
  type Setup = Awaited<ReturnType<typeof renderApp>>;
50
- async function press(setup: Setup, k: string): Promise<void> {
51
- if (k === "enter") setup.mockInput.pressKey("RETURN");
52
- else if (k === "escape") setup.mockInput.pressKey("ESCAPE");
53
- else await setup.mockInput.typeText(k);
54
- await Bun.sleep(15);
55
- await setup.renderOnce();
56
- }
57
57
 
58
58
  async function submitApprove(setup: Setup): Promise<void> {
59
59
  await press(setup, "enter"); // open submit (approve default: no pending items)
60
60
  await press(setup, "enter"); // confirm
61
- const deadline = Date.now() + 10_000;
62
- while (Date.now() < deadline && !setup.captureCharFrame().includes("review approved")) {
63
- await Bun.sleep(25);
64
- await setup.renderOnce();
65
- }
61
+ await waitForText(setup, "review approved");
66
62
  }
67
63
 
68
64
  describe("completion overlay", () => {
69
- test("submit with auto-close off shows the prompt; ⏎ exits", async () => {
65
+ test("submit with auto-close off counts down from 5; ⏎ exits now", async () => {
66
+ // Arrange
70
67
  let exited = -1;
71
68
  const setup = await renderApp((code) => (exited = code));
69
+
70
+ // Act
72
71
  await submitApprove(setup);
73
- const frame = setup.captureCharFrame();
74
- expect(frame).toContain("✓ review approved");
75
- expect(frame).toContain("a always close after submit");
72
+
73
+ // Assert
74
+ const frame = await waitForText(setup, "closing in 5s");
75
+
76
+ expect(frame).toContain("review approved");
77
+ expect(frame).toContain("close [return]");
78
+ expect(frame).toContain("return to plan [esc]");
79
+
80
+ // Act
76
81
  await press(setup, "enter");
82
+
83
+ // Assert
77
84
  expect(exited).toBe(0);
78
85
  });
79
86
 
80
- test("a opts in: persists the config and starts the countdown", async () => {
87
+ test("a remembers the countdown as the persisted default", async () => {
88
+ // Arrange
81
89
  const setup = await renderApp();
90
+
82
91
  await submitApprove(setup);
92
+ await waitForText(setup, "closing in 5s");
93
+
94
+ // Act
83
95
  await press(setup, "a");
84
- expect(setup.captureCharFrame()).toContain("closing in 3s");
96
+
97
+ // Assert
85
98
  expect(existsSync(configPath)).toBe(true);
86
- expect(readFileSync(configPath, "utf8")).toContain("auto_close = 3");
99
+ expect(readFileSync(configPath, "utf8")).toContain("auto_close = 5");
100
+
101
+ // Act
102
+ // the countdown keeps running; each second is one manual-clock tick
103
+ clock.advance(1000);
104
+
105
+ // Assert
106
+ await waitForText(setup, "closing in 4s");
87
107
  });
88
108
 
89
109
  test("esc dismisses to the resolved read-only view", async () => {
110
+ // Arrange
90
111
  const setup = await renderApp();
112
+
91
113
  await submitApprove(setup);
92
- await press(setup, "escape");
114
+ await waitForText(setup, "closing in 5s");
115
+
116
+ // Act
93
117
  // a bare ESC sits in the input parser's escape-sequence disambiguation
94
- // window before it is delivered - poll for the dismissal
95
- const deadline = Date.now() + 5_000;
96
- while (Date.now() < deadline && !setup.captureCharFrame().includes("resolved: approve")) {
97
- await Bun.sleep(50);
98
- await setup.renderOnce();
99
- }
100
- const frame = setup.captureCharFrame();
101
- expect(frame).toContain("resolved: approve");
102
- expect(frame).not.toContain("always close after submit");
118
+ // window before it is delivered - the frame wait absorbs it
119
+ await press(setup, "escape");
120
+
121
+ // Assert
122
+ const frame = await waitForText(setup, "resolved: approve");
123
+
124
+ expect(frame).not.toContain("closing in");
103
125
  });
104
126
 
105
127
  test("configured auto_close counts down and exits without interaction", async () => {
128
+ // Arrange
106
129
  writeFileSync(configPath, "[ui]\nauto_close = 1\n");
107
130
  let exited = -1;
108
131
  const setup = await renderApp((code) => (exited = code));
132
+
109
133
  await submitApprove(setup);
110
- expect(setup.captureCharFrame()).toContain("closing in 1s");
111
- const deadline = Date.now() + 10_000;
112
- while (Date.now() < deadline && exited === -1) {
113
- await Bun.sleep(100);
114
- await setup.renderOnce();
115
- }
134
+ await waitForText(setup, "closing in 1s");
135
+ expect(exited).toBe(-1);
136
+
137
+ // Act
138
+ clock.advance(1000);
139
+ await settle(setup);
140
+
141
+ // Assert
116
142
  expect(exited).toBe(0);
117
- }, 20_000);
143
+ });
118
144
  });
@@ -9,6 +9,7 @@ import { testRender } from "@opentui/react/test-utils";
9
9
  import { DaemonServer } from "@cueloop/daemon";
10
10
  import type { ReviewSession } from "@cueloop/schema";
11
11
  import { App } from "./App";
12
+ import { isolateUserConfig, press, waitForText } from "./test-support";
12
13
 
13
14
  const PATCH = `diff --git a/src/store.ts b/src/store.ts
14
15
  index 111..222 100644
@@ -22,11 +23,13 @@ index 111..222 100644
22
23
  `;
23
24
 
24
25
  let home: string;
26
+ let restoreUserConfig: () => void;
25
27
  let server: DaemonServer;
26
28
  let session: ReviewSession;
27
29
 
28
30
  beforeEach(() => {
29
31
  home = mkdtempSync(join(tmpdir(), "cueloop-diff-"));
32
+ restoreUserConfig = isolateUserConfig(home);
30
33
  server = new DaemonServer({ home, idleExitMs: 0 });
31
34
  server.start();
32
35
  session = server.core.sessionCreate({
@@ -35,32 +38,30 @@ beforeEach(() => {
35
38
  });
36
39
  });
37
40
  afterEach(() => {
41
+ restoreUserConfig();
38
42
  server.stop();
39
43
  rmSync(home, { recursive: true, force: true });
40
44
  });
41
45
 
42
46
  async function renderApp() {
43
- const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 30 });
44
- for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
45
- await Bun.sleep(25);
46
- await setup.renderOnce();
47
- }
48
- await setup.renderOnce();
49
- return setup;
50
- }
47
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
48
+ width: 120,
49
+ height: 30,
50
+ });
51
51
 
52
- type Setup = Awaited<ReturnType<typeof renderApp>>;
53
- async function press(setup: Setup, k: string): Promise<void> {
54
- if (k === "enter") setup.mockInput.pressKey("RETURN");
55
- else await setup.mockInput.typeText(k);
56
- await Bun.sleep(15);
57
- await setup.renderOnce();
52
+ await waitForText(setup, "cueloop");
53
+
54
+ return setup;
58
55
  }
59
56
 
60
57
  describe("diff review", () => {
61
58
  test("renders file header, hunks, and signed lines", async () => {
59
+ // Arrange
62
60
  const setup = await renderApp();
61
+
62
+ // Assert
63
63
  const frame = setup.captureCharFrame();
64
+
64
65
  expect(frame).toContain("■ src/store.ts");
65
66
  expect(frame).toContain("@@ -1,4 +1,4 @@");
66
67
  expect(frame).toContain("- private items = [];");
@@ -68,32 +69,178 @@ describe("diff review", () => {
68
69
  });
69
70
 
70
71
  test("line comment: quote-anchored, lands in feedback with the code line", async () => {
72
+ // Arrange
71
73
  const setup = await renderApp();
74
+
75
+ // Act
72
76
  // rows: file(0), hunk(1), ctx(2), del(3), add(4)
73
77
  for (let i = 0; i < 4; i++) await press(setup, "j");
74
78
  await press(setup, "c");
75
- expect(setup.captureCharFrame()).toContain("COMMENT ON");
79
+
80
+ // Assert
81
+ await waitForText(setup, 'comment on "');
82
+
83
+ // Act
76
84
  await setup.mockInput.typeText("Map needs an eviction story.");
77
85
  await press(setup, "enter");
78
- await Bun.sleep(80);
79
- await setup.renderOnce();
86
+
87
+ // Assert
88
+ // inline annotation card rendered under the line
89
+ await waitForText(setup, "◆ Map needs an eviction story.");
80
90
  const stored = server.core.sessionGet(session.id);
91
+
81
92
  expect(stored.annotations.length).toBe(1);
82
93
  expect(stored.annotations[0]!.anchor.quote).toContain("new Map()");
83
- // inline annotation card rendered under the line
84
- expect(setup.captureCharFrame()).toContain("◆ Map needs an eviction story.");
85
94
 
95
+ // Act
86
96
  await press(setup, "enter"); // submit
87
97
  await press(setup, "enter"); // confirm request_changes
88
- await Bun.sleep(100);
98
+
99
+ // Assert
100
+ await waitForText(setup, "feedback sent");
89
101
  const resolved = server.core.sessionGet(session.id);
102
+
90
103
  expect(resolved.verdict!.feedback).toContain("new Map()");
91
104
  expect(resolved.verdict!.feedback).toContain("Map needs an eviction story.");
92
105
  });
93
106
 
94
- test("plan verbs are guarded in diff sessions", async () => {
107
+ test("curation needs full file contents; a legacy diff answers", async () => {
108
+ // Arrange - the session carries no artifact.files
95
109
  const setup = await renderApp();
110
+
111
+ // Act
112
+ await press(setup, "x");
113
+
114
+ // Assert
115
+ await waitForText(setup, "hunk curation needs full file contents");
116
+ });
117
+
118
+ test("rejecting the only change empties the curated working copy", async () => {
119
+ // Arrange - a diff session that carries full file contents
120
+ const withFiles = server.core.sessionCreate({
121
+ workspace: { repoRoot: "/repo", branch: "main" },
122
+ artifact: {
123
+ type: "diff",
124
+ content: PATCH,
125
+ meta: { title: "working tree" },
126
+ files: [
127
+ {
128
+ path: "src/store.ts",
129
+ oldContents: "export class Store {\n private items = [];\n}\n",
130
+ newContents: "export class Store {\n private items = new Map();\n}\n",
131
+ status: "modified",
132
+ },
133
+ ],
134
+ },
135
+ });
136
+ const setup = await testRender(<App home={home} sessionId={withFiles.id} />, {
137
+ width: 120,
138
+ height: 30,
139
+ });
140
+
141
+ await waitForText(setup, "cueloop");
142
+
143
+ // Act - move to the added line and reject its change
144
+ // rows: file(0), hunk(1), ctx(2), del(3), add(4)
145
+ for (let i = 0; i < 4; i++) await press(setup, "j");
146
+ await press(setup, "x");
147
+
148
+ // Assert - the single change is gone, so the curated working copy is empty
149
+ await waitForText(setup, "change rejected");
150
+ const stored = server.core.sessionGet(withFiles.id);
151
+
152
+ expect(stored.workingCopy).toBe("");
153
+ });
154
+
155
+ test("a rejected change lists in the rail and undo (u) restores it", async () => {
156
+ // Arrange - a diff session that carries full file contents
157
+ const withFiles = server.core.sessionCreate({
158
+ workspace: { repoRoot: "/repo", branch: "main" },
159
+ artifact: {
160
+ type: "diff",
161
+ content: PATCH,
162
+ meta: { title: "working tree" },
163
+ files: [
164
+ {
165
+ path: "src/store.ts",
166
+ oldContents: "export class Store {\n private items = [];\n}\n",
167
+ newContents: "export class Store {\n private items = new Map();\n}\n",
168
+ status: "modified",
169
+ },
170
+ ],
171
+ },
172
+ });
173
+ const setup = await testRender(<App home={home} sessionId={withFiles.id} />, {
174
+ width: 120,
175
+ height: 30,
176
+ });
177
+
178
+ await waitForText(setup, "cueloop");
179
+
180
+ // Act - reject the change under the added line
181
+ // rows: file(0), hunk(1), ctx(2), del(3), add(4)
182
+ for (let i = 0; i < 4; i++) await press(setup, "j");
183
+ await press(setup, "x");
184
+
185
+ // Assert - the rail shows a removal card titled REJECT · me for the change
186
+ await waitForText(setup, "REJECT · me");
187
+ expect(server.core.sessionGet(withFiles.id).workingCopy).toBe("");
188
+
189
+ // Act - undo restores it (no selection, so the last item)
190
+ await press(setup, "u");
191
+
192
+ // Assert - the change returns to the curated diff (working copy cleared)
193
+ await waitForText(setup, "removal restored");
194
+ expect(server.core.sessionGet(withFiles.id).workingCopy).toBeUndefined();
195
+ });
196
+
197
+ test("a selected removal card shows an undo button that restores it on click", async () => {
198
+ // Arrange - a diff session that carries full file contents
199
+ const withFiles = server.core.sessionCreate({
200
+ workspace: { repoRoot: "/repo", branch: "main" },
201
+ artifact: {
202
+ type: "diff",
203
+ content: PATCH,
204
+ meta: { title: "working tree" },
205
+ files: [
206
+ {
207
+ path: "src/store.ts",
208
+ oldContents: "export class Store {\n private items = [];\n}\n",
209
+ newContents: "export class Store {\n private items = new Map();\n}\n",
210
+ status: "modified",
211
+ },
212
+ ],
213
+ },
214
+ });
215
+ const setup = await testRender(<App home={home} sessionId={withFiles.id} />, {
216
+ width: 120,
217
+ height: 30,
218
+ });
219
+
220
+ await waitForText(setup, "cueloop");
221
+
222
+ // Act - reject the change; the removal card lands unselected (no undo button)
223
+ for (let i = 0; i < 4; i++) await press(setup, "j");
96
224
  await press(setup, "x");
97
- expect(setup.captureCharFrame()).toContain("plan-only verb");
225
+ await waitForText(setup, "REJECT · me");
226
+ expect(setup.captureCharFrame()).not.toContain("undo");
227
+
228
+ // Act - click the card to select it; the undo button appears
229
+ const clickText = async (needle: string): Promise<void> => {
230
+ const lines = setup.captureCharFrame().split("\n");
231
+ const row = lines.findIndex((line) => line.includes(needle));
232
+
233
+ await setup.mockMouse.click(lines[row]!.indexOf(needle) + 1, row);
234
+ };
235
+
236
+ await clickText("REJECT · me");
237
+ await waitForText(setup, "undo");
238
+
239
+ // Act - click the undo button
240
+ await clickText("undo");
241
+
242
+ // Assert - the change returns to the curated diff (working copy cleared)
243
+ await waitForText(setup, "removal restored");
244
+ expect(server.core.sessionGet(withFiles.id).workingCopy).toBeUndefined();
98
245
  });
99
246
  });
@@ -1,11 +1,15 @@
1
- /**
2
- * Tier-2 wiring test for the notes-vault export: with [integrations.obsidian]
3
- * configured to export on resolve, submitting a review writes the note into
4
- * the vault and surfaces the path in the status line.
5
- */
1
+ /** Tier-2 wiring for the notes-vault export: with [integrations.obsidian] set to export on resolve, submitting a review writes the note into the vault and surfaces the path in the status line. */
6
2
 
7
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import {
5
+ existsSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ readFileSync,
9
+ readdirSync,
10
+ rmSync,
11
+ writeFileSync,
12
+ } from "node:fs";
9
13
  import { tmpdir } from "node:os";
10
14
  import { join } from "node:path";
11
15
  import React from "react";
@@ -13,6 +17,7 @@ import { testRender } from "@opentui/react/test-utils";
13
17
  import { DaemonServer } from "@cueloop/daemon";
14
18
  import type { ReviewSession } from "@cueloop/schema";
15
19
  import { App } from "./App";
20
+ import { isolateUserConfig, press, waitForState, waitForText } from "./test-support";
16
21
 
17
22
  const PLAN = `# Migration Plan
18
23
 
@@ -23,62 +28,69 @@ let home: string;
23
28
  let vault: string;
24
29
  let server: DaemonServer;
25
30
  let session: ReviewSession;
26
- let savedConfigEnv: string | undefined;
31
+ let restoreUserConfig: () => void;
27
32
 
28
33
  beforeEach(() => {
29
34
  home = mkdtempSync(join(tmpdir(), "cueloop-app-export-"));
30
35
  vault = join(home, "vault");
31
36
  mkdirSync(vault);
32
37
  const configPath = join(home, "config.toml");
33
- writeFileSync(configPath, `[integrations.obsidian]\nvault = ${JSON.stringify(vault)}\nexportOn = "resolve"\n`);
34
- savedConfigEnv = process.env.CUELOOP_CONFIG;
35
- process.env.CUELOOP_CONFIG = configPath;
38
+
39
+ writeFileSync(
40
+ configPath,
41
+ `[integrations.obsidian]\nvault = ${JSON.stringify(vault)}\nexportOn = "resolve"\n`,
42
+ );
43
+ restoreUserConfig = isolateUserConfig(home, "config.toml");
36
44
  server = new DaemonServer({ home, idleExitMs: 0 });
37
45
  server.start();
38
46
  session = server.core.sessionCreate({
39
47
  workspace: { repoRoot: "/repo", branch: "main" },
40
- artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
48
+ artifact: {
49
+ type: "plan",
50
+ content: PLAN,
51
+ meta: { title: "Migration Plan", planPath: "plan.md" },
52
+ },
41
53
  });
42
54
  });
43
55
  afterEach(() => {
44
- if (savedConfigEnv === undefined) delete process.env.CUELOOP_CONFIG;
45
- else process.env.CUELOOP_CONFIG = savedConfigEnv;
56
+ restoreUserConfig();
46
57
  server.stop();
47
58
  rmSync(home, { recursive: true, force: true });
48
59
  });
49
60
 
50
61
  describe("obsidian export on resolve", () => {
51
62
  test("submitting a review writes the plan into the vault and shows the path", async () => {
52
- const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 32 });
53
- for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
54
- await Bun.sleep(25);
55
- await setup.renderOnce();
56
- }
57
- // open the submit overlay, keep the default verdict (approve), submit
58
- setup.mockInput.pressKey("RETURN");
59
- await Bun.sleep(15);
60
- await setup.renderOnce();
61
- expect(setup.captureCharFrame()).toContain("verdict");
62
- setup.mockInput.pressKey("RETURN");
63
- // wall-clock deadlines: the export is an async round-trip after resolve,
64
- // and an iteration count times out on a loaded runner
63
+ // Arrange
64
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
65
+ width: 120,
66
+ height: 32,
67
+ });
68
+
69
+ await waitForText(setup, "cueloop");
70
+
71
+ // Act
72
+ await press(setup, "enter"); // open the submit overlay, keep the default verdict (approve)
73
+
74
+ // Assert
75
+ await waitForText(setup, "[Approve]");
76
+
77
+ // Act
78
+ await press(setup, "enter"); // submit
79
+
80
+ // Assert
81
+ // the export is an async round-trip after resolve; the status line lands
82
+ // on a later render than the file write
65
83
  const dir = join(vault, "cueloop");
66
- const fileDeadline = Date.now() + 30_000;
67
- while (Date.now() < fileDeadline && !existsSync(dir)) {
68
- await Bun.sleep(25);
69
- await setup.renderOnce();
70
- }
71
- // the status line lands on a later render than the file write
72
- const statusDeadline = Date.now() + 30_000;
73
- while (Date.now() < statusDeadline && !setup.captureCharFrame().includes("exported to")) {
74
- await Bun.sleep(25);
75
- await setup.renderOnce();
76
- }
84
+
85
+ await waitForState(setup, () => existsSync(dir));
86
+ await waitForText(setup, "exported to");
77
87
 
78
88
  const notes = readdirSync(dir);
89
+
79
90
  expect(notes.length).toBe(1);
80
91
  expect(notes[0]).toMatch(/^\d{4}-\d{2}-\d{2} - Migration Plan\.md$/);
81
92
  const written = readFileSync(join(dir, notes[0]!), "utf8");
93
+
82
94
  expect(written).toContain("source: cueloop");
83
95
  expect(written).toContain(`session: ${session.id}`);
84
96
  expect(written).toContain("verdict: approve");