@cueloop/client 0.1.0-alpha.11 → 0.1.0-alpha.12

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 (66) hide show
  1. package/package.json +7 -7
  2. package/src/App.autoclose.test.tsx +24 -35
  3. package/src/App.diff.test.tsx +5 -18
  4. package/src/App.export.test.tsx +8 -21
  5. package/src/App.plan-review.test.tsx +21 -52
  6. package/src/App.readonly.test.tsx +3 -18
  7. package/src/App.rendering.test.tsx +5 -13
  8. package/src/App.submit-confirm.test.tsx +7 -25
  9. package/src/App.test.tsx +13 -52
  10. package/src/App.tsx +197 -910
  11. package/src/components/AnnotationCard.stories.tsx +75 -0
  12. package/src/components/AnnotationCard.tsx +136 -0
  13. package/src/components/Breadcrumb.stories.tsx +19 -0
  14. package/src/components/Breadcrumb.tsx +38 -0
  15. package/src/components/CodeBlock.stories.tsx +25 -0
  16. package/src/components/CodeBlock.tsx +68 -0
  17. package/src/components/CompletionOverlay.stories.tsx +23 -0
  18. package/src/components/CompletionOverlay.tsx +50 -0
  19. package/src/components/ComposeBar.stories.tsx +18 -0
  20. package/src/components/ComposeBar.tsx +54 -0
  21. package/src/components/ConfirmCard.stories.tsx +22 -0
  22. package/src/components/ConfirmCard.tsx +130 -0
  23. package/src/components/DiffSheet.stories.tsx +44 -0
  24. package/src/components/DiffSheet.tsx +243 -0
  25. package/src/components/InboxList.stories.tsx +18 -0
  26. package/src/components/InboxList.tsx +42 -0
  27. package/src/components/PlanSheet.stories.tsx +81 -0
  28. package/src/components/PlanSheet.tsx +309 -0
  29. package/src/components/ReviewRail.stories.tsx +103 -0
  30. package/src/components/ReviewRail.tsx +158 -0
  31. package/src/components/SettingsDialog.stories.tsx +63 -0
  32. package/src/components/SettingsDialog.tsx +132 -0
  33. package/src/components/SettingsRows.stories.tsx +18 -0
  34. package/src/components/SettingsRows.tsx +92 -0
  35. package/src/components/__snapshots__/stories.test.tsx.snap +1189 -0
  36. package/src/components/format.ts +6 -0
  37. package/src/components/primitives/Button.stories.tsx +32 -0
  38. package/src/components/primitives/Button.tsx +44 -0
  39. package/src/components/primitives/Card.stories.tsx +25 -0
  40. package/src/components/primitives/Card.tsx +58 -0
  41. package/src/components/primitives/Dialog.stories.tsx +24 -0
  42. package/src/components/primitives/Dialog.tsx +52 -0
  43. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  44. package/src/components/primitives/StatusBar.tsx +22 -0
  45. package/src/components/primitives/Tabs.stories.tsx +29 -0
  46. package/src/components/primitives/Tabs.tsx +97 -0
  47. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  48. package/src/components/primitives/Toolbar.tsx +14 -0
  49. package/src/components/stories-app.tsx +94 -0
  50. package/src/components/stories.test.tsx +51 -0
  51. package/src/components/story-fixtures.ts +90 -0
  52. package/src/components/story.ts +66 -0
  53. package/src/components/syntax-highlight.ts +57 -0
  54. package/src/components/theme-context.stories.tsx +41 -0
  55. package/src/components/theme-context.tsx +30 -0
  56. package/src/key-bindings.test.ts +103 -0
  57. package/src/key-bindings.ts +332 -0
  58. package/src/keymap.ts +8 -2
  59. package/src/run.ts +3 -1
  60. package/src/session-controller.ts +28 -7
  61. package/src/test-support.ts +87 -0
  62. package/src/theme.ts +3 -0
  63. package/src/view.test.ts +35 -13
  64. package/src/view.ts +41 -64
  65. package/src/syntax.test.ts +0 -41
  66. package/src/syntax.ts +0 -88
package/package.json CHANGED
@@ -1,22 +1,22 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.11",
3
+ "version": "0.1.0-alpha.12",
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.11",
11
- "@cueloop/extension-api": "0.1.0-alpha.11",
12
- "@cueloop/integration-obsidian": "0.1.0-alpha.11",
13
- "@cueloop/schema": "0.1.0-alpha.11",
10
+ "@cueloop/daemon": "0.1.0-alpha.12",
11
+ "@cueloop/extension-api": "0.1.0-alpha.12",
12
+ "@cueloop/integration-obsidian": "0.1.0-alpha.12",
13
+ "@cueloop/schema": "0.1.0-alpha.12",
14
14
  "@opentui/core": "^0.5.1",
15
+ "@opentui/keymap": "0.5.1",
15
16
  "@opentui/react": "^0.5.1",
16
17
  "@opentui/ssh": "0.5.1",
17
18
  "@pierre/diffs": "^1.3.5",
18
- "react": "^19.2.0",
19
- "shiki": "^4.4.2"
19
+ "react": "^19.2.0"
20
20
  },
21
21
  "devDependencies": {
22
22
  "@types/react": "^19.2.0",
@@ -1,4 +1,8 @@
1
- /** Post-submit completion overlay: prompt, opt-in persistence, countdown, dismissal. */
1
+ /**
2
+ * Post-submit completion overlay: prompt, opt-in persistence, countdown,
3
+ * dismissal. Countdown seconds tick on an injected ManualClock, so the
4
+ * tests advance time instead of waiting it out.
5
+ */
2
6
 
3
7
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
8
  import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -6,9 +10,11 @@ import { tmpdir } from "node:os";
6
10
  import { join } from "node:path";
7
11
  import React from "react";
8
12
  import { testRender } from "@opentui/react/test-utils";
13
+ import { ManualClock } from "@opentui/core/testing";
9
14
  import { DaemonServer } from "@cueloop/daemon";
10
15
  import type { ReviewSession } from "@cueloop/schema";
11
16
  import { App } from "./App";
17
+ import { press, settle, waitForText } from "./test-support";
12
18
 
13
19
  const PLAN = "# Plan\n\nShip the thing.\n";
14
20
 
@@ -16,6 +22,7 @@ let home: string;
16
22
  let server: DaemonServer;
17
23
  let session: ReviewSession;
18
24
  let configPath: string;
25
+ let clock: ManualClock;
19
26
 
20
27
  beforeEach(() => {
21
28
  home = mkdtempSync(join(tmpdir(), "cueloop-close-"));
@@ -27,6 +34,7 @@ beforeEach(() => {
27
34
  workspace: { repoRoot: "/repo", branch: "main" },
28
35
  artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
29
36
  });
37
+ clock = new ManualClock();
30
38
  });
31
39
  afterEach(() => {
32
40
  delete process.env.CUELOOP_CONFIG;
@@ -35,34 +43,20 @@ afterEach(() => {
35
43
  });
36
44
 
37
45
  async function renderApp(onExit?: (code: number) => void) {
38
- const setup = await testRender(<App home={home} sessionId={session.id} onExit={onExit} />, {
46
+ const setup = await testRender(<App home={home} sessionId={session.id} onExit={onExit} clock={clock} />, {
39
47
  width: 120,
40
48
  height: 30,
41
49
  });
42
- for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
43
- await Bun.sleep(25);
44
- await setup.renderOnce();
45
- }
50
+ await waitForText(setup, "cueloop");
46
51
  return setup;
47
52
  }
48
53
 
49
54
  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
55
 
58
56
  async function submitApprove(setup: Setup): Promise<void> {
59
57
  await press(setup, "enter"); // open submit (approve default: no pending items)
60
58
  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
- }
59
+ await waitForText(setup, "review approved");
66
60
  }
67
61
 
68
62
  describe("completion overlay", () => {
@@ -81,24 +75,21 @@ describe("completion overlay", () => {
81
75
  const setup = await renderApp();
82
76
  await submitApprove(setup);
83
77
  await press(setup, "a");
84
- expect(setup.captureCharFrame()).toContain("closing in 3s");
78
+ await waitForText(setup, "closing in 3s");
85
79
  expect(existsSync(configPath)).toBe(true);
86
80
  expect(readFileSync(configPath, "utf8")).toContain("auto_close = 3");
81
+ // each countdown second is one manual-clock tick, not wall time
82
+ clock.advance(1000);
83
+ await waitForText(setup, "closing in 2s");
87
84
  });
88
85
 
89
86
  test("esc dismisses to the resolved read-only view", async () => {
90
87
  const setup = await renderApp();
91
88
  await submitApprove(setup);
92
- await press(setup, "escape");
93
89
  // 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");
90
+ // window before it is delivered - the frame wait absorbs it
91
+ await press(setup, "escape");
92
+ const frame = await waitForText(setup, "resolved: approve");
102
93
  expect(frame).not.toContain("always close after submit");
103
94
  });
104
95
 
@@ -107,12 +98,10 @@ describe("completion overlay", () => {
107
98
  let exited = -1;
108
99
  const setup = await renderApp((code) => (exited = code));
109
100
  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
- }
101
+ await waitForText(setup, "closing in 1s");
102
+ expect(exited).toBe(-1);
103
+ clock.advance(1000);
104
+ await settle(setup);
116
105
  expect(exited).toBe(0);
117
- }, 20_000);
106
+ });
118
107
  });
@@ -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 { 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
@@ -41,22 +42,10 @@ afterEach(() => {
41
42
 
42
43
  async function renderApp() {
43
44
  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();
45
+ await waitForText(setup, "cueloop");
49
46
  return setup;
50
47
  }
51
48
 
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();
58
- }
59
-
60
49
  describe("diff review", () => {
61
50
  test("renders file header, hunks, and signed lines", async () => {
62
51
  const setup = await renderApp();
@@ -75,17 +64,15 @@ describe("diff review", () => {
75
64
  expect(setup.captureCharFrame()).toContain("COMMENT ON");
76
65
  await setup.mockInput.typeText("Map needs an eviction story.");
77
66
  await press(setup, "enter");
78
- await Bun.sleep(80);
79
- await setup.renderOnce();
67
+ // inline annotation card rendered under the line
68
+ await waitForText(setup, "◆ Map needs an eviction story.");
80
69
  const stored = server.core.sessionGet(session.id);
81
70
  expect(stored.annotations.length).toBe(1);
82
71
  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
72
 
86
73
  await press(setup, "enter"); // submit
87
74
  await press(setup, "enter"); // confirm request_changes
88
- await Bun.sleep(100);
75
+ await waitForText(setup, "✎ feedback sent");
89
76
  const resolved = server.core.sessionGet(session.id);
90
77
  expect(resolved.verdict!.feedback).toContain("new Map()");
91
78
  expect(resolved.verdict!.feedback).toContain("Map needs an eviction story.");
@@ -13,6 +13,7 @@ import { testRender } from "@opentui/react/test-utils";
13
13
  import { DaemonServer } from "@cueloop/daemon";
14
14
  import type { ReviewSession } from "@cueloop/schema";
15
15
  import { App } from "./App";
16
+ import { press, waitForState, waitForText } from "./test-support";
16
17
 
17
18
  const PLAN = `# Migration Plan
18
19
 
@@ -50,30 +51,16 @@ afterEach(() => {
50
51
  describe("obsidian export on resolve", () => {
51
52
  test("submitting a review writes the plan into the vault and shows the path", async () => {
52
53
  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
- }
54
+ await waitForText(setup, "cueloop");
57
55
  // open the submit overlay, keep the default verdict (approve), submit
58
- setup.mockInput.pressKey("RETURN");
59
- await Bun.sleep(15);
60
- await setup.renderOnce();
56
+ await press(setup, "enter");
61
57
  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
58
+ await press(setup, "enter");
59
+ // the export is an async round-trip after resolve; the status line lands
60
+ // on a later render than the file write
65
61
  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
- }
62
+ await waitForState(setup, () => existsSync(dir));
63
+ await waitForText(setup, "exported to");
77
64
 
78
65
  const notes = readdirSync(dir);
79
66
  expect(notes.length).toBe(1);
@@ -16,6 +16,7 @@ import { DaemonServer } from "@cueloop/daemon";
16
16
  import type { ReviewSession } from "@cueloop/schema";
17
17
  import { App } from "./App";
18
18
  import { DARK as T } from "./theme";
19
+ import { press, settle, typeText as type, waitForState, waitForText, waitForTextGone } from "./test-support";
19
20
 
20
21
  const PLAN = `# Migration Plan
21
22
 
@@ -52,33 +53,12 @@ async function renderApp() {
52
53
  width: 120,
53
54
  height: 32,
54
55
  });
55
- for (let attempt = 0; attempt < 40 && !setup.captureCharFrame().includes("cueloop"); attempt++) {
56
- await Bun.sleep(25);
57
- await setup.renderOnce();
58
- }
59
- await setup.renderOnce();
56
+ await waitForText(setup, "cueloop");
60
57
  return setup;
61
58
  }
62
59
 
63
60
  type Setup = Awaited<ReturnType<typeof renderApp>>;
64
61
 
65
- async function press(setup: Setup, k: string): Promise<void> {
66
- if (k === "enter") setup.mockInput.pressKey("RETURN");
67
- else if (k === "escape") {
68
- setup.mockInput.pressKey("ESCAPE");
69
- // a bare ESC byte is an ambiguous sequence prefix; the parser settles late
70
- await Bun.sleep(120);
71
- } else await type(setup, k);
72
- await Bun.sleep(15);
73
- await setup.renderOnce();
74
- }
75
-
76
- async function type(setup: Setup, text: string): Promise<void> {
77
- await setup.mockInput.typeText(text);
78
- await Bun.sleep(15);
79
- await setup.renderOnce();
80
- }
81
-
82
62
  /**
83
63
  * Background colors (hex) of every styled span containing the needle - the
84
64
  * document highlight and the rail's quote excerpt can both match.
@@ -111,10 +91,8 @@ describe("selection feeds the annotation quote", () => {
111
91
  expect(setup.captureCharFrame()).toContain('comment on "The daemon"');
112
92
  await type(setup, "Which daemon?");
113
93
  await press(setup, "enter");
114
- await Bun.sleep(80);
115
- await setup.renderOnce();
94
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
116
95
  const stored = server.core.sessionGet(session.id);
117
- expect(stored.annotations.length).toBe(1);
118
96
  expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
119
97
  });
120
98
 
@@ -126,12 +104,12 @@ describe("selection feeds the annotation quote", () => {
126
104
  expect(row).toBeGreaterThan(-1);
127
105
  // the drag's end cell is exclusive: release one past the last character
128
106
  await setup.mockMouse.drag(startColumn, row, startColumn + "persists sessions".length, row);
129
- await setup.renderOnce();
107
+ await settle(setup);
130
108
  await press(setup, "c");
131
109
  expect(setup.captureCharFrame()).toContain('comment on "persists sessions"');
132
110
  await type(setup, "sessions plural?");
133
111
  await press(setup, "enter");
134
- await Bun.sleep(80);
112
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
135
113
  const stored = server.core.sessionGet(session.id);
136
114
  expect(stored.annotations[0]!.anchor.quote).toBe("persists sessions");
137
115
  });
@@ -148,16 +126,16 @@ describe("inline compose keeps the anchor painted", () => {
148
126
  expect(setup.captureCharFrame()).toContain("Save ⏎");
149
127
  expect(setup.captureCharFrame()).toContain("Cancel esc");
150
128
  expect(backgroundsOf(setup, "The daemon")).toContain(T.accent);
151
- // cancel un-paints
129
+ // cancel un-paints (a bare ESC settles after the parser's escape window)
152
130
  await press(setup, "escape");
153
- expect(setup.captureCharFrame()).not.toContain('comment on "');
131
+ await waitForTextGone(setup, 'comment on "');
154
132
  expect(backgroundsOf(setup, "The daemon")).not.toContain(T.accent);
155
133
  // save converts the paint to the kind-colored annotation highlight
156
134
  await press(setup, "c");
157
135
  await type(setup, "Which daemon?");
158
136
  await press(setup, "enter");
159
- await Bun.sleep(80);
160
- await setup.renderOnce();
137
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
138
+ await settle(setup);
161
139
  // the whole cursor block is the anchor here; check the highlight landed
162
140
  const stored = server.core.sessionGet(session.id);
163
141
  expect(stored.annotations.length).toBe(1);
@@ -172,18 +150,15 @@ describe("the document selects, the rail edits", () => {
172
150
  await press(setup, "c");
173
151
  await type(setup, "Needs a citation.");
174
152
  await press(setup, "enter");
175
- await Bun.sleep(80);
176
- await setup.renderOnce();
177
- expect(setup.captureCharFrame()).toContain("COMMENT · pending");
153
+ await waitForText(setup, "COMMENT · pending");
178
154
  // the saved card is selected; e turns its body into an input in place
179
155
  await press(setup, "e");
180
156
  await type(setup, " And a link.");
181
157
  await press(setup, "enter");
182
- await Bun.sleep(80);
183
- await setup.renderOnce();
184
- const stored = server.core.sessionGet(session.id);
185
- expect(stored.annotations.length).toBe(1);
186
- expect(stored.annotations[0]!.body).toBe("Needs a citation. And a link.");
158
+ await waitForState(
159
+ setup,
160
+ () => server.core.sessionGet(session.id).annotations[0]!.body === "Needs a citation. And a link.",
161
+ );
187
162
  });
188
163
 
189
164
  test("x deletes the selected card and un-paints the document highlight", async () => {
@@ -192,15 +167,12 @@ describe("the document selects, the rail edits", () => {
192
167
  await press(setup, "c");
193
168
  await type(setup, "Delete me.");
194
169
  await press(setup, "enter");
195
- await Bun.sleep(80);
196
- await setup.renderOnce();
170
+ await waitForText(setup, "COMMENT · pending");
197
171
  expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
198
172
  expect(backgroundsOf(setup, "persists sessions")).toContain(T.markCommentBg);
199
173
  await press(setup, "x");
200
- await Bun.sleep(80);
201
- await setup.renderOnce();
174
+ await waitForTextGone(setup, "COMMENT · pending");
202
175
  expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
203
- expect(setup.captureCharFrame()).not.toContain("COMMENT · pending");
204
176
  expect(backgroundsOf(setup, "persists sessions")).not.toContain(T.markCommentBg);
205
177
  });
206
178
  });
@@ -239,17 +211,14 @@ describe("edit-exit reconciliation", () => {
239
211
  await press(setup, "c");
240
212
  await type(setup, "Anchor me to the doomed passage.");
241
213
  await press(setup, "enter");
242
- await Bun.sleep(80);
243
- await setup.renderOnce();
244
- expect(setup.captureCharFrame()).toContain("· pending");
214
+ await waitForText(setup, "· pending");
245
215
  // deselect the card so e reaches the editor hand-off, then edit
246
216
  await press(setup, "escape");
217
+ await waitForTextGone(setup, "▸ COMMENT");
247
218
  await press(setup, "e");
248
- await Bun.sleep(150);
249
- await setup.renderOnce();
250
- const frame = setup.captureCharFrame();
251
- expect(frame).toContain("1 annotation no longer match - the passage was removed.");
252
- expect(frame).toContain("· ORPHANED");
219
+ await waitForText(setup, "1 annotation no longer match - the passage was removed.");
220
+ // the rail card flips to ORPHANED once the working-copy refresh lands
221
+ await waitForText(setup, "· ORPHANED");
253
222
  // the annotation is NOT deleted: the feedback serializer handles orphans
254
223
  expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
255
224
  } finally {
@@ -13,6 +13,7 @@ import { testRender } from "@opentui/react/test-utils";
13
13
  import { DaemonServer } from "@cueloop/daemon";
14
14
  import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
15
15
  import { App } from "./App";
16
+ import { press, waitForText } from "./test-support";
16
17
 
17
18
  const PLAN = `# Migration Plan
18
19
 
@@ -49,23 +50,10 @@ async function renderObserver() {
49
50
  width: 120,
50
51
  height: 32,
51
52
  });
52
- for (let i = 0; i < 40 && !setup.captureCharFrame().includes("cueloop"); i++) {
53
- await Bun.sleep(25);
54
- await setup.renderOnce();
55
- }
56
- await setup.renderOnce();
53
+ await waitForText(setup, "cueloop");
57
54
  return setup;
58
55
  }
59
56
 
60
- type Setup = Awaited<ReturnType<typeof renderObserver>>;
61
-
62
- async function press(setup: Setup, k: string): Promise<void> {
63
- if (k === "enter") setup.mockInput.pressKey("RETURN");
64
- else await setup.mockInput.typeText(k);
65
- await Bun.sleep(15);
66
- await setup.renderOnce();
67
- }
68
-
69
57
  /** Session state that any mutating verb would change. */
70
58
  function snapshot() {
71
59
  const s = server.core.sessionGet(session.id);
@@ -96,10 +84,7 @@ describe("observer verbs are blocked", () => {
96
84
  await press(setup, "j");
97
85
  await press(setup, "j");
98
86
  await press(setup, key);
99
- await Bun.sleep(60);
100
- await setup.renderOnce();
101
- const frame = setup.captureCharFrame();
102
- expect(frame).toContain("observer - read-only");
87
+ const frame = await waitForText(setup, "observer - read-only");
103
88
  // no overlay opened: compose/submit bars never appear
104
89
  expect(frame).not.toContain("COMMENT ON");
105
90
  expect(frame).not.toContain("SUGGEST REPLACEMENT");
@@ -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 { waitForText } from "./test-support";
12
13
 
13
14
  const PLAN = `# Render Plan
14
15
 
@@ -48,14 +49,9 @@ afterEach(() => {
48
49
 
49
50
  async function frame(): Promise<string> {
50
51
  const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 36 });
51
- const deadline = Date.now() + 10_000;
52
- while (Date.now() < deadline && !setup.captureCharFrame().includes("Render Plan")) {
53
- await Bun.sleep(25);
54
- await setup.renderOnce();
55
- }
56
- // give the async highlighter a beat, then take the final frame
57
- await Bun.sleep(150);
58
- await setup.renderOnce();
52
+ await waitForText(setup, "Render Plan");
53
+ // async highlights settle within the visual-idle wait
54
+ await setup.waitForVisualIdle();
59
55
  return setup.captureCharFrame();
60
56
  }
61
57
 
@@ -96,11 +92,7 @@ describe("block spacing", () => {
96
92
  artifact: { type: "plan", content: listPlan, meta: {} },
97
93
  });
98
94
  const setup = await testRender(<App home={home} sessionId={s2.id} />, { width: 120, height: 30 });
99
- const deadline = Date.now() + 10_000;
100
- while (Date.now() < deadline && !setup.captureCharFrame().includes("one")) {
101
- await Bun.sleep(25);
102
- await setup.renderOnce();
103
- }
95
+ await waitForText(setup, "one");
104
96
  const lines = setup.captureCharFrame().split("\n").map((l) => l.trimEnd());
105
97
  const one = lines.findIndex((l) => l.includes("- one"));
106
98
  expect(lines[one + 1]).toContain("- two");
@@ -14,6 +14,7 @@ import { testRender } from "@opentui/react/test-utils";
14
14
  import { DaemonServer } from "@cueloop/daemon";
15
15
  import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
16
16
  import { App } from "./App";
17
+ import { press, settle, waitForText, waitForTextGone } from "./test-support";
17
18
 
18
19
  const PLAN = `# Migration Plan
19
20
 
@@ -50,29 +51,12 @@ async function renderApp(options: { readOnly?: boolean } = {}) {
50
51
  width: 120,
51
52
  height: 32,
52
53
  });
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();
54
+ await waitForText(setup, "cueloop");
58
55
  return setup;
59
56
  }
60
57
 
61
58
  type Setup = Awaited<ReturnType<typeof renderApp>>;
62
59
 
63
- async function press(setup: Setup, k: string): Promise<void> {
64
- if (k === "enter") setup.mockInput.pressKey("RETURN");
65
- else if (k === "escape") {
66
- setup.mockInput.pressKey("ESCAPE");
67
- // a bare ESC byte is an ambiguous sequence prefix; the parser settles late
68
- await Bun.sleep(120);
69
- } else if (k === "left") setup.mockInput.pressKey("ARROW_LEFT");
70
- else if (k === "right") setup.mockInput.pressKey("ARROW_RIGHT");
71
- else await setup.mockInput.typeText(k);
72
- await Bun.sleep(15);
73
- await setup.renderOnce();
74
- }
75
-
76
60
  /** Seed annotations directly through the daemon core (all on one block). */
77
61
  function seedAnnotations(count: number): void {
78
62
  const blocks = parseBlocks(PLAN);
@@ -121,8 +105,8 @@ describe("rail submit confirm", () => {
121
105
  await press(setup, "enter");
122
106
  expect(setup.captureCharFrame()).toContain("[Approve]");
123
107
  await press(setup, "escape");
124
- const frame = setup.captureCharFrame();
125
- expect(frame).not.toContain("[Approve]");
108
+ // a bare ESC settles after the parser's escape-sequence window
109
+ const frame = await waitForTextGone(setup, "[Approve]");
126
110
  expect(frame).not.toContain("0 annotations");
127
111
  expect(frame).toContain("Submit review (0)");
128
112
  });
@@ -136,13 +120,11 @@ describe("rail submit confirm", () => {
136
120
  expect(frame).toContain("[Changes]"); // pending items: request changes default
137
121
  await setup.mockInput.typeText("Tighten the steps.");
138
122
  await press(setup, "enter");
139
- await Bun.sleep(120);
140
- await setup.renderOnce();
123
+ // the completion flow after submit is unchanged
124
+ await waitForText(setup, "✎ feedback sent");
141
125
  const stored = server.core.sessionGet(session.id);
142
126
  expect(stored.status).toBe("resolved");
143
127
  expect(stored.verdict!.kind).toBe("request_changes");
144
- // the completion flow after submit is unchanged
145
- expect(setup.captureCharFrame()).toContain("✎ feedback sent");
146
128
  });
147
129
 
148
130
  test("the annotation stack stays scrollable while the card is open", async () => {
@@ -162,7 +144,7 @@ describe("rail submit confirm", () => {
162
144
  // the stack still scrolls with the card open: wheel over the rail
163
145
  // brings the last card back into view while the confirm card stays put
164
146
  for (let turn = 0; turn < 12; turn++) await setup.mockMouse.scroll(100, 10, "down");
165
- await setup.renderOnce();
147
+ await settle(setup);
166
148
  const scrolledWithCard = setup.captureCharFrame();
167
149
  expect(scrolledWithCard).toContain("note 12");
168
150
  expect(scrolledWithCard).toContain("12 annotations · 0 blocking");