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

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.
package/package.json CHANGED
@@ -1,21 +1,22 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-alpha.11",
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": "workspace:*",
11
- "@cueloop/extension-api": "workspace:*",
12
- "@cueloop/integration-obsidian": "workspace:*",
13
- "@cueloop/schema": "workspace:*",
14
- "@opentui/core": "^0.4.3",
15
- "@opentui/react": "^0.4.3",
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",
14
+ "@opentui/core": "^0.5.1",
15
+ "@opentui/react": "^0.5.1",
16
+ "@opentui/ssh": "0.5.1",
16
17
  "@pierre/diffs": "^1.3.5",
17
18
  "react": "^19.2.0",
18
- "@opentui/ssh": "0.4.5"
19
+ "shiki": "^4.4.2"
19
20
  },
20
21
  "devDependencies": {
21
22
  "@types/react": "^19.2.0",
@@ -36,5 +37,10 @@
36
37
  "type": "git",
37
38
  "url": "git+https://github.com/mmurakaru/cueloop.git",
38
39
  "directory": "packages/client"
39
- }
40
+ },
41
+ "homepage": "https://github.com/mmurakaru/cueloop#readme",
42
+ "bugs": {
43
+ "url": "https://github.com/mmurakaru/cueloop/issues"
44
+ },
45
+ "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail"
40
46
  }
@@ -0,0 +1,118 @@
1
+ /** Post-submit completion overlay: prompt, opt-in persistence, countdown, dismissal. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } 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
+ import { App } from "./App";
12
+
13
+ const PLAN = "# Plan\n\nShip the thing.\n";
14
+
15
+ let home: string;
16
+ let server: DaemonServer;
17
+ let session: ReviewSession;
18
+ let configPath: string;
19
+
20
+ beforeEach(() => {
21
+ home = mkdtempSync(join(tmpdir(), "cueloop-close-"));
22
+ configPath = join(home, "config.toml");
23
+ process.env.CUELOOP_CONFIG = configPath;
24
+ server = new DaemonServer({ home, idleExitMs: 0 });
25
+ server.start();
26
+ session = server.core.sessionCreate({
27
+ workspace: { repoRoot: "/repo", branch: "main" },
28
+ artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
29
+ });
30
+ });
31
+ afterEach(() => {
32
+ delete process.env.CUELOOP_CONFIG;
33
+ server.stop();
34
+ rmSync(home, { recursive: true, force: true });
35
+ });
36
+
37
+ 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
+ }
46
+ return setup;
47
+ }
48
+
49
+ 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
+
58
+ async function submitApprove(setup: Setup): Promise<void> {
59
+ await press(setup, "enter"); // open submit (approve default: no pending items)
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
+ }
66
+ }
67
+
68
+ describe("completion overlay", () => {
69
+ test("submit with auto-close off shows the prompt; ⏎ exits", async () => {
70
+ let exited = -1;
71
+ const setup = await renderApp((code) => (exited = code));
72
+ await submitApprove(setup);
73
+ const frame = setup.captureCharFrame();
74
+ expect(frame).toContain("✓ review approved");
75
+ expect(frame).toContain("a always close after submit");
76
+ await press(setup, "enter");
77
+ expect(exited).toBe(0);
78
+ });
79
+
80
+ test("a opts in: persists the config and starts the countdown", async () => {
81
+ const setup = await renderApp();
82
+ await submitApprove(setup);
83
+ await press(setup, "a");
84
+ expect(setup.captureCharFrame()).toContain("closing in 3s");
85
+ expect(existsSync(configPath)).toBe(true);
86
+ expect(readFileSync(configPath, "utf8")).toContain("auto_close = 3");
87
+ });
88
+
89
+ test("esc dismisses to the resolved read-only view", async () => {
90
+ const setup = await renderApp();
91
+ await submitApprove(setup);
92
+ await press(setup, "escape");
93
+ // 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");
103
+ });
104
+
105
+ test("configured auto_close counts down and exits without interaction", async () => {
106
+ writeFileSync(configPath, "[ui]\nauto_close = 1\n");
107
+ let exited = -1;
108
+ const setup = await renderApp((code) => (exited = code));
109
+ 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
+ }
116
+ expect(exited).toBe(0);
117
+ }, 20_000);
118
+ });
@@ -60,12 +60,20 @@ describe("obsidian export on resolve", () => {
60
60
  await setup.renderOnce();
61
61
  expect(setup.captureCharFrame()).toContain("verdict");
62
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
65
  const dir = join(vault, "cueloop");
64
- for (let i = 0; i < 40 && !existsSync(dir); i++) {
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")) {
65
74
  await Bun.sleep(25);
66
75
  await setup.renderOnce();
67
76
  }
68
- await setup.renderOnce();
69
77
 
70
78
  const notes = readdirSync(dir);
71
79
  expect(notes.length).toBe(1);
@@ -0,0 +1,259 @@
1
+ /**
2
+ * The plan review surface v2 (#71): native selection feeds the annotation
3
+ * quote, the inline compose box keeps its anchor painted, the rail edits what
4
+ * the document selects, and edit-exit reconciliation orphans annotations
5
+ * whose passage was removed. Char-frame + styled-span assertions over the
6
+ * real App and a real in-process daemon.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
10
+ import { mkdtempSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import React from "react";
14
+ import { testRender } from "@opentui/react/test-utils";
15
+ import { DaemonServer } from "@cueloop/daemon";
16
+ import type { ReviewSession } from "@cueloop/schema";
17
+ import { App } from "./App";
18
+ import { DARK as T } from "./theme";
19
+
20
+ const PLAN = `# Migration Plan
21
+
22
+ ## Context
23
+
24
+ The daemon persists sessions to disk atomically.
25
+
26
+ ## Steps
27
+
28
+ - move the store
29
+ - add recovery
30
+ `;
31
+
32
+ let home: string;
33
+ let server: DaemonServer;
34
+ let session: ReviewSession;
35
+
36
+ beforeEach(() => {
37
+ home = mkdtempSync(join(tmpdir(), "cueloop-plan-review-"));
38
+ server = new DaemonServer({ home, idleExitMs: 0 });
39
+ server.start();
40
+ session = server.core.sessionCreate({
41
+ workspace: { repoRoot: "/repo", branch: "main" },
42
+ artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md", agent: "agent/worker-3" } },
43
+ });
44
+ });
45
+ afterEach(() => {
46
+ server.stop();
47
+ rmSync(home, { recursive: true, force: true });
48
+ });
49
+
50
+ async function renderApp() {
51
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
52
+ width: 120,
53
+ height: 32,
54
+ });
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();
60
+ return setup;
61
+ }
62
+
63
+ type Setup = Awaited<ReturnType<typeof renderApp>>;
64
+
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
+ /**
83
+ * Background colors (hex) of every styled span containing the needle - the
84
+ * document highlight and the rail's quote excerpt can both match.
85
+ */
86
+ function backgroundsOf(setup: Setup, needle: string): string[] {
87
+ const backgrounds: string[] = [];
88
+ for (const line of setup.captureSpans().lines) {
89
+ for (const span of line.spans) {
90
+ if (!span.text.includes(needle)) continue;
91
+ const [red, green, blue] = span.bg.toInts();
92
+ backgrounds.push("#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""));
93
+ }
94
+ }
95
+ return backgrounds;
96
+ }
97
+
98
+ /** Move the cursor to "The daemon persists sessions to disk atomically." */
99
+ async function toContextParagraph(setup: Setup): Promise<void> {
100
+ await press(setup, "j");
101
+ await press(setup, "j");
102
+ }
103
+
104
+ describe("selection feeds the annotation quote", () => {
105
+ test("v anchors, l extends, c composes with the char-precise quote", async () => {
106
+ const setup = await renderApp();
107
+ await toContextParagraph(setup);
108
+ await press(setup, "v");
109
+ await press(setup, "l"); // "The daemon"
110
+ await press(setup, "c");
111
+ expect(setup.captureCharFrame()).toContain('comment on "The daemon"');
112
+ await type(setup, "Which daemon?");
113
+ await press(setup, "enter");
114
+ await Bun.sleep(80);
115
+ await setup.renderOnce();
116
+ const stored = server.core.sessionGet(session.id);
117
+ expect(stored.annotations.length).toBe(1);
118
+ expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
119
+ });
120
+
121
+ test("a mouse drag over the text becomes the compose quote", async () => {
122
+ const setup = await renderApp();
123
+ const lines = setup.captureCharFrame().split("\n");
124
+ const row = lines.findIndex((line) => line.includes("persists sessions"));
125
+ const startColumn = lines[row]!.indexOf("persists");
126
+ expect(row).toBeGreaterThan(-1);
127
+ // the drag's end cell is exclusive: release one past the last character
128
+ await setup.mockMouse.drag(startColumn, row, startColumn + "persists sessions".length, row);
129
+ await setup.renderOnce();
130
+ await press(setup, "c");
131
+ expect(setup.captureCharFrame()).toContain('comment on "persists sessions"');
132
+ await type(setup, "sessions plural?");
133
+ await press(setup, "enter");
134
+ await Bun.sleep(80);
135
+ const stored = server.core.sessionGet(session.id);
136
+ expect(stored.annotations[0]!.anchor.quote).toBe("persists sessions");
137
+ });
138
+ });
139
+
140
+ describe("inline compose keeps the anchor painted", () => {
141
+ test("the selection stays painted while composing; cancel un-paints; save converts to the comment highlight", async () => {
142
+ const setup = await renderApp();
143
+ await toContextParagraph(setup);
144
+ await press(setup, "v");
145
+ await press(setup, "l");
146
+ await press(setup, "c");
147
+ // compose open: the anchor is painted selection-style
148
+ expect(setup.captureCharFrame()).toContain("Save ⏎");
149
+ expect(setup.captureCharFrame()).toContain("Cancel esc");
150
+ expect(backgroundsOf(setup, "The daemon")).toContain(T.accent);
151
+ // cancel un-paints
152
+ await press(setup, "escape");
153
+ expect(setup.captureCharFrame()).not.toContain('comment on "');
154
+ expect(backgroundsOf(setup, "The daemon")).not.toContain(T.accent);
155
+ // save converts the paint to the kind-colored annotation highlight
156
+ await press(setup, "c");
157
+ await type(setup, "Which daemon?");
158
+ await press(setup, "enter");
159
+ await Bun.sleep(80);
160
+ await setup.renderOnce();
161
+ // the whole cursor block is the anchor here; check the highlight landed
162
+ const stored = server.core.sessionGet(session.id);
163
+ expect(stored.annotations.length).toBe(1);
164
+ expect(backgroundsOf(setup, stored.annotations[0]!.anchor.quote.slice(0, 20))).toContain(T.markCommentBg);
165
+ });
166
+ });
167
+
168
+ describe("the document selects, the rail edits", () => {
169
+ test("e edits the selected card in place; enter saves through the controller", async () => {
170
+ const setup = await renderApp();
171
+ await toContextParagraph(setup);
172
+ await press(setup, "c");
173
+ await type(setup, "Needs a citation.");
174
+ await press(setup, "enter");
175
+ await Bun.sleep(80);
176
+ await setup.renderOnce();
177
+ expect(setup.captureCharFrame()).toContain("COMMENT · pending");
178
+ // the saved card is selected; e turns its body into an input in place
179
+ await press(setup, "e");
180
+ await type(setup, " And a link.");
181
+ 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.");
187
+ });
188
+
189
+ test("x deletes the selected card and un-paints the document highlight", async () => {
190
+ const setup = await renderApp();
191
+ await toContextParagraph(setup);
192
+ await press(setup, "c");
193
+ await type(setup, "Delete me.");
194
+ await press(setup, "enter");
195
+ await Bun.sleep(80);
196
+ await setup.renderOnce();
197
+ expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
198
+ expect(backgroundsOf(setup, "persists sessions")).toContain(T.markCommentBg);
199
+ await press(setup, "x");
200
+ await Bun.sleep(80);
201
+ await setup.renderOnce();
202
+ expect(server.core.sessionGet(session.id).annotations.length).toBe(0);
203
+ expect(setup.captureCharFrame()).not.toContain("COMMENT · pending");
204
+ expect(backgroundsOf(setup, "persists sessions")).not.toContain(T.markCommentBg);
205
+ });
206
+ });
207
+
208
+ describe("sheet header", () => {
209
+ test("shows the submitting agent, the revision, and the Edit word-button", async () => {
210
+ const setup = await renderApp();
211
+ const frame = setup.captureCharFrame();
212
+ expect(frame).toContain("submitted by agent/worker-3 · revision 1");
213
+ expect(frame).toContain("Edit");
214
+ });
215
+
216
+ test("the Agent tab shows the submitter, status, and revision", async () => {
217
+ const setup = await renderApp();
218
+ const lines = setup.captureCharFrame().split("\n");
219
+ const tabRow = lines.findIndex((line) => line.includes("Review (0)"));
220
+ const tabColumn = lines[tabRow]!.indexOf("Agent");
221
+ await setup.mockMouse.click(tabColumn + 1, tabRow);
222
+ await setup.renderOnce();
223
+ const frame = setup.captureCharFrame();
224
+ expect(frame).toContain("agent/worker-3");
225
+ expect(frame).toContain("status: pending");
226
+ expect(frame).toContain("revision 1");
227
+ });
228
+ });
229
+
230
+ describe("edit-exit reconciliation", () => {
231
+ test("an edit that removes an anchored passage orphans the annotation and shows the banner", async () => {
232
+ const script = join(home, "fake-editor.sh");
233
+ await Bun.write(script, `#!/bin/sh\nsed -i '' '/^The daemon persists/d' "$1"\n`);
234
+ Bun.spawnSync(["chmod", "+x", script]);
235
+ process.env.CUELOOP_EDITOR = script;
236
+ try {
237
+ const setup = await renderApp();
238
+ await toContextParagraph(setup);
239
+ await press(setup, "c");
240
+ await type(setup, "Anchor me to the doomed passage.");
241
+ await press(setup, "enter");
242
+ await Bun.sleep(80);
243
+ await setup.renderOnce();
244
+ expect(setup.captureCharFrame()).toContain("· pending");
245
+ // deselect the card so e reaches the editor hand-off, then edit
246
+ await press(setup, "escape");
247
+ 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");
253
+ // the annotation is NOT deleted: the feedback serializer handles orphans
254
+ expect(server.core.sessionGet(session.id).annotations.length).toBe(1);
255
+ } finally {
256
+ delete process.env.CUELOOP_EDITOR;
257
+ }
258
+ });
259
+ });
@@ -0,0 +1,108 @@
1
+ /** Plan rendering (#67): code blocks verbatim + contained, spacing never collapses. */
2
+
3
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
4
+ import { mkdtempSync, rmSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join } from "node:path";
7
+ import React from "react";
8
+ import { testRender } from "@opentui/react/test-utils";
9
+ import { DaemonServer } from "@cueloop/daemon";
10
+ import type { ReviewSession } from "@cueloop/schema";
11
+ import { App } from "./App";
12
+
13
+ const PLAN = `# Render Plan
14
+
15
+ ## Sensor
16
+
17
+ - wire the duck over MQTT
18
+
19
+ \`\`\`tsx
20
+ export function Gate({ full }: { full: number }) {
21
+ const blocked = full < 0.6;
22
+ return <text>{blocked ? "blocked" : "clear"}</text>;
23
+ }
24
+ \`\`\`
25
+
26
+ ## Gate
27
+
28
+ Deploys block below the threshold.
29
+ `;
30
+
31
+ let home: string;
32
+ let server: DaemonServer;
33
+ let session: ReviewSession;
34
+
35
+ beforeEach(() => {
36
+ home = mkdtempSync(join(tmpdir(), "cueloop-render-"));
37
+ server = new DaemonServer({ home, idleExitMs: 0 });
38
+ server.start();
39
+ session = server.core.sessionCreate({
40
+ workspace: { repoRoot: "/repo", branch: "main" },
41
+ artifact: { type: "plan", content: PLAN, meta: { title: "Render Plan" } },
42
+ });
43
+ });
44
+ afterEach(() => {
45
+ server.stop();
46
+ rmSync(home, { recursive: true, force: true });
47
+ });
48
+
49
+ async function frame(): Promise<string> {
50
+ 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();
59
+ return setup.captureCharFrame();
60
+ }
61
+
62
+ describe("code blocks", () => {
63
+ test("lines render verbatim: indentation preserved, no word-wrap", async () => {
64
+ const f = await frame();
65
+ expect(f).toContain(" const blocked = full < 0.6;");
66
+ expect(f).toContain(' return <text>{blocked ? "blocked" : "clear"}</text>;');
67
+ });
68
+
69
+ test("the block carries its language tag", async () => {
70
+ const f = await frame();
71
+ expect(f).toContain("tsx");
72
+ });
73
+ });
74
+
75
+ describe("block spacing", () => {
76
+ test("a code block never glues to the list item above it", async () => {
77
+ const lines = (await frame()).split("\n").map((l) => l.trimEnd());
78
+ const li = lines.findIndex((l) => l.includes("wire the duck over MQTT"));
79
+ const codeHeader = lines.findIndex((l) => l.trim().startsWith("tsx"));
80
+ expect(li).toBeGreaterThan(-1);
81
+ expect(codeHeader).toBeGreaterThan(li);
82
+ expect(lines.slice(li + 1, codeHeader).some((l) => l === "")).toBe(true);
83
+ });
84
+
85
+ test("a heading never sits directly on the previous block", async () => {
86
+ const lines = (await frame()).split("\n").map((l) => l.trimEnd());
87
+ const gate = lines.findIndex((l) => l.trim() === "Gate");
88
+ expect(gate).toBeGreaterThan(0);
89
+ expect(lines[gate - 1]).toBe("");
90
+ });
91
+
92
+ test("consecutive list items stay tight", async () => {
93
+ const listPlan = "# T\n\n- one\n- two\n";
94
+ const s2 = server.core.sessionCreate({
95
+ workspace: { repoRoot: "/repo", branch: "main" },
96
+ artifact: { type: "plan", content: listPlan, meta: {} },
97
+ });
98
+ 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
+ }
104
+ const lines = setup.captureCharFrame().split("\n").map((l) => l.trimEnd());
105
+ const one = lines.findIndex((l) => l.includes("- one"));
106
+ expect(lines[one + 1]).toContain("- two");
107
+ });
108
+ });