@cueloop/client 0.1.0-alpha.2 → 0.1.0-alpha.21

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 (92) hide show
  1. package/package.json +17 -11
  2. package/src/App.autoclose.test.tsx +109 -0
  3. package/src/App.diff.test.tsx +5 -18
  4. package/src/App.export.test.tsx +8 -13
  5. package/src/App.plan-review.test.tsx +293 -0
  6. package/src/App.readonly.test.tsx +6 -21
  7. package/src/App.rendering.test.tsx +104 -0
  8. package/src/App.review-panel.test.tsx +97 -0
  9. package/src/App.submit-confirm.test.tsx +165 -0
  10. package/src/App.test.tsx +20 -58
  11. package/src/App.tsx +0 -0
  12. package/src/App.walk.test.tsx +208 -0
  13. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  14. package/src/components/AnnotationCard.stories.tsx +75 -0
  15. package/src/components/AnnotationCard.tsx +189 -0
  16. package/src/components/Breadcrumb.stories.tsx +19 -0
  17. package/src/components/Breadcrumb.tsx +38 -0
  18. package/src/components/CodeBlock.stories.tsx +25 -0
  19. package/src/components/CodeBlock.tsx +68 -0
  20. package/src/components/CompletionOverlay.stories.tsx +23 -0
  21. package/src/components/CompletionOverlay.tsx +49 -0
  22. package/src/components/ComposeBar.stories.tsx +18 -0
  23. package/src/components/ComposeBar.tsx +54 -0
  24. package/src/components/ConfirmCard.stories.tsx +37 -0
  25. package/src/components/ConfirmCard.tsx +117 -0
  26. package/src/components/DiffSheet.stories.tsx +44 -0
  27. package/src/components/DiffSheet.tsx +243 -0
  28. package/src/components/InboxList.stories.tsx +18 -0
  29. package/src/components/InboxList.tsx +42 -0
  30. package/src/components/PlanSheet.stories.tsx +81 -0
  31. package/src/components/PlanSheet.tsx +309 -0
  32. package/src/components/ReviewPanel.stories.tsx +86 -0
  33. package/src/components/ReviewPanel.tsx +116 -0
  34. package/src/components/ReviewRail.stories.tsx +103 -0
  35. package/src/components/ReviewRail.tsx +171 -0
  36. package/src/components/SettingsDialog.stories.tsx +63 -0
  37. package/src/components/SettingsDialog.tsx +132 -0
  38. package/src/components/SettingsRows.stories.tsx +18 -0
  39. package/src/components/SettingsRows.tsx +92 -0
  40. package/src/components/WalkWizard.stories.tsx +89 -0
  41. package/src/components/WalkWizard.tsx +146 -0
  42. package/src/components/__snapshots__/stories.test.tsx.snap +1459 -0
  43. package/src/components/primitives/Button.stories.tsx +32 -0
  44. package/src/components/primitives/Button.tsx +44 -0
  45. package/src/components/primitives/Card.stories.tsx +25 -0
  46. package/src/components/primitives/Card.tsx +59 -0
  47. package/src/components/primitives/Dialog.stories.tsx +24 -0
  48. package/src/components/primitives/Dialog.tsx +53 -0
  49. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  50. package/src/components/primitives/StatusBar.tsx +22 -0
  51. package/src/components/primitives/Tabs.stories.tsx +29 -0
  52. package/src/components/primitives/Tabs.tsx +97 -0
  53. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  54. package/src/components/primitives/Toolbar.tsx +14 -0
  55. package/src/components/primitives/frame.test.ts +12 -0
  56. package/src/components/primitives/frame.ts +14 -0
  57. package/src/components/stories-app.tsx +95 -0
  58. package/src/components/stories.test.tsx +51 -0
  59. package/src/components/story-fixtures.ts +90 -0
  60. package/src/components/story.ts +66 -0
  61. package/src/components/syntax-highlight.ts +57 -0
  62. package/src/components/theme-context.stories.tsx +41 -0
  63. package/src/components/theme-context.tsx +30 -0
  64. package/src/components/truncate-text.ts +6 -0
  65. package/src/config.test.ts +105 -30
  66. package/src/config.ts +91 -16
  67. package/src/editor.test.ts +110 -0
  68. package/src/editor.ts +101 -13
  69. package/src/herdr.test.ts +37 -0
  70. package/src/herdr.ts +21 -0
  71. package/src/integrations.test.ts +29 -0
  72. package/src/integrations.ts +28 -0
  73. package/src/key-bindings.test.ts +120 -0
  74. package/src/key-bindings.ts +353 -0
  75. package/src/keymap.test.ts +347 -0
  76. package/src/keymap.ts +229 -0
  77. package/src/review-panel.test.ts +111 -0
  78. package/src/review-panel.ts +76 -0
  79. package/src/run.ts +9 -5
  80. package/src/serve.test.ts +6 -6
  81. package/src/serve.ts +7 -7
  82. package/src/session-controller.ts +512 -0
  83. package/src/test-support.ts +87 -0
  84. package/src/theme.ts +23 -1
  85. package/src/view-diff.test.ts +8 -8
  86. package/src/view-diff.ts +22 -22
  87. package/src/view-plan.test.ts +179 -0
  88. package/src/view-plan.ts +362 -0
  89. package/src/walk.test.ts +110 -0
  90. package/src/walk.ts +65 -0
  91. package/src/view.test.ts +0 -157
  92. package/src/view.ts +0 -0
@@ -0,0 +1,104 @@
1
+ /** Plan rendering: 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
+ import { waitForText } from "./test-support";
13
+
14
+ const PLAN = `# Render Plan
15
+
16
+ ## Sensor
17
+
18
+ - wire the duck over MQTT
19
+
20
+ \`\`\`tsx
21
+ export function Gate({ full }: { full: number }) {
22
+ const blocked = full < 0.6;
23
+ return <text>{blocked ? "blocked" : "clear"}</text>;
24
+ }
25
+ \`\`\`
26
+
27
+ ## Gate
28
+
29
+ Deploys block below the threshold.
30
+ `;
31
+
32
+ let home: string;
33
+ let server: DaemonServer;
34
+ let session: ReviewSession;
35
+
36
+ beforeEach(() => {
37
+ home = mkdtempSync(join(tmpdir(), "cueloop-render-"));
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: "Render Plan" } },
43
+ });
44
+ });
45
+ afterEach(() => {
46
+ server.stop();
47
+ rmSync(home, { recursive: true, force: true });
48
+ });
49
+
50
+ async function frame(): Promise<string> {
51
+ const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 36 });
52
+ await waitForText(setup, "Render Plan");
53
+ // async highlights settle within the visual-idle wait
54
+ await setup.waitForVisualIdle();
55
+ return setup.captureCharFrame();
56
+ }
57
+
58
+ describe("code blocks", () => {
59
+ test("lines render verbatim: indentation preserved, no word-wrap", async () => {
60
+ const rendered = await frame();
61
+ expect(rendered).toContain(" const blocked = full < 0.6;");
62
+ expect(rendered).toContain(' return <text>{blocked ? "blocked" : "clear"}</text>;');
63
+ });
64
+
65
+ test("the block carries its language tag", async () => {
66
+ const rendered = await frame();
67
+ expect(rendered).toContain("tsx");
68
+ });
69
+ });
70
+
71
+ describe("block spacing", () => {
72
+ // the review panel's full-height divider paints a `│` on every row, so read
73
+ // the plan column (everything left of the divider) before asserting spacing
74
+ const planColumn = (line: string): string => line.split("│")[0]!.trimEnd();
75
+
76
+ test("a code block never glues to the list item above it", async () => {
77
+ const lines = (await frame()).split("\n").map(planColumn);
78
+ const listItemLineIndex = lines.findIndex((line) => line.includes("wire the duck over MQTT"));
79
+ const codeHeader = lines.findIndex((line) => line.trim().startsWith("tsx"));
80
+ expect(listItemLineIndex).toBeGreaterThan(-1);
81
+ expect(codeHeader).toBeGreaterThan(listItemLineIndex);
82
+ expect(lines.slice(listItemLineIndex + 1, codeHeader).some((line) => line === "")).toBe(true);
83
+ });
84
+
85
+ test("a heading never sits directly on the previous block", async () => {
86
+ const lines = (await frame()).split("\n").map(planColumn);
87
+ const gate = lines.findIndex((line) => line.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
+ await waitForText(setup, "one");
100
+ const lines = setup.captureCharFrame().split("\n").map((line) => line.trimEnd());
101
+ const one = lines.findIndex((line) => line.includes("- one"));
102
+ expect(lines[one + 1]).toContain("- two");
103
+ });
104
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The review panel end to end: the b key cycles expanded -> compact -> hidden
3
+ * -> expanded on the live App, and the collapse mode persists to the user
4
+ * config so it survives a restart. Rendering details are locked by the
5
+ * ReviewPanel stories; this suite proves the App wiring and the persistence.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import React from "react";
13
+ import { testRender } from "@opentui/react/test-utils";
14
+ import { DaemonServer } from "@cueloop/daemon";
15
+ import type { ReviewSession } from "@cueloop/schema";
16
+ import { App } from "./App";
17
+ import { loadConfig } from "./config";
18
+ import { press, waitForText } from "./test-support";
19
+
20
+ const PLAN = "# Plan\n\nShip the thing.\n";
21
+
22
+ let home: string;
23
+ let server: DaemonServer;
24
+ let session: ReviewSession;
25
+ let configPath: string;
26
+
27
+ beforeEach(() => {
28
+ home = mkdtempSync(join(tmpdir(), "cueloop-panel-"));
29
+ configPath = join(home, "config.toml");
30
+ process.env.CUELOOP_CONFIG = configPath;
31
+ server = new DaemonServer({ home, idleExitMs: 0 });
32
+ server.start();
33
+ session = server.core.sessionCreate({
34
+ workspace: { repoRoot: "/repo", branch: "main" },
35
+ artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
36
+ });
37
+ });
38
+ afterEach(() => {
39
+ delete process.env.CUELOOP_CONFIG;
40
+ server.stop();
41
+ rmSync(home, { recursive: true, force: true });
42
+ });
43
+
44
+ async function renderApp() {
45
+ const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 30 });
46
+ await waitForText(setup, "Review");
47
+ return setup;
48
+ }
49
+
50
+ describe("review panel", () => {
51
+ test("b cycles expanded -> compact -> hidden -> expanded", async () => {
52
+ const setup = await renderApp();
53
+ expect(setup.captureCharFrame()).toContain("Submit review"); // expanded rail
54
+
55
+ await press(setup, "b");
56
+ let frame = setup.captureCharFrame();
57
+ expect(frame).toContain("«"); // compact strip shows the expand chevron
58
+ expect(frame).not.toContain("Submit review");
59
+
60
+ await press(setup, "b");
61
+ frame = setup.captureCharFrame();
62
+ expect(frame).not.toContain("«"); // hidden: no strip, no divider
63
+ expect(frame).not.toContain("│");
64
+
65
+ await press(setup, "b");
66
+ expect(setup.captureCharFrame()).toContain("Submit review"); // back to expanded
67
+ });
68
+
69
+ test("the collapse mode persists to the user config", async () => {
70
+ const setup = await renderApp();
71
+ await press(setup, "b"); // -> compact
72
+ expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("compact");
73
+ });
74
+
75
+ test("] widens the rail and persists the new width", async () => {
76
+ const setup = await renderApp();
77
+ const startWidth = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
78
+ await press(setup, "]");
79
+ const widened = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
80
+ expect(widened).toBeGreaterThan(startWidth);
81
+ });
82
+
83
+ test("submitting from a hidden panel force-opens the rail so the confirm card is reachable", async () => {
84
+ const setup = await renderApp();
85
+ await press(setup, "b"); // -> compact
86
+ await press(setup, "b"); // -> hidden
87
+ expect(setup.captureCharFrame()).not.toContain("Submit review"); // rail is gone
88
+
89
+ await press(setup, "enter"); // submit while hidden must not trap in an invisible modal
90
+ const frame = setup.captureCharFrame();
91
+ expect(frame).toContain("submit review"); // the confirm card is visible again
92
+ expect(frame).toContain("[Approve]");
93
+
94
+ // the reveal is live-only: the saved panel preference stays as the user left it
95
+ expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("hidden");
96
+ });
97
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The rail submit confirm (tier 2): pressing submit expands the rail's
3
+ * Submit button into a bordered confirm card - counts, verdict selector,
4
+ * summary input, word-buttons. Char-frame assertions over the real App
5
+ * and an in-process daemon, like App.test.tsx.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import { mkdtempSync, rmSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import React from "react";
13
+ import { testRender } from "@opentui/react/test-utils";
14
+ import { DaemonServer } from "@cueloop/daemon";
15
+ import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
16
+ import { App } from "./App";
17
+ import { press, settle, waitForText, waitForTextGone } from "./test-support";
18
+
19
+ const PLAN = `# Migration Plan
20
+
21
+ ## Context
22
+
23
+ The daemon persists sessions to disk atomically.
24
+
25
+ ## Steps
26
+
27
+ - move the store
28
+ - add recovery
29
+ `;
30
+
31
+ let home: string;
32
+ let server: DaemonServer;
33
+ let session: ReviewSession;
34
+
35
+ beforeEach(() => {
36
+ home = mkdtempSync(join(tmpdir(), "cueloop-confirm-"));
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: "Migration Plan", planPath: "plan.md" } },
42
+ });
43
+ });
44
+ afterEach(() => {
45
+ server.stop();
46
+ rmSync(home, { recursive: true, force: true });
47
+ });
48
+
49
+ async function renderApp(options: { readOnly?: boolean } = {}) {
50
+ const setup = await testRender(<App home={home} sessionId={session.id} readOnly={options.readOnly ?? false} />, {
51
+ width: 120,
52
+ height: 32,
53
+ });
54
+ await waitForText(setup, "cueloop");
55
+ return setup;
56
+ }
57
+
58
+ type Setup = Awaited<ReturnType<typeof renderApp>>;
59
+
60
+ /** Seed annotations directly through the daemon core (all on one block). */
61
+ function seedAnnotations(count: number): void {
62
+ const blocks = parseBlocks(PLAN);
63
+ for (let index = 1; index <= count; index++) {
64
+ server.core.sessionAnnotate(session.id, {
65
+ id: `a_confirm_${index}`,
66
+ kind: "comment",
67
+ anchor: makeAnchor(blocks, 2, 0, 10),
68
+ body: `note ${String(index).padStart(2, "0")}`,
69
+ });
70
+ }
71
+ }
72
+
73
+ describe("rail submit confirm", () => {
74
+ test("submit expands the Submit button into the confirm card", async () => {
75
+ const setup = await renderApp();
76
+ expect(setup.captureCharFrame()).toContain("Submit review (0)");
77
+ await press(setup, "enter");
78
+ const frame = setup.captureCharFrame();
79
+ // the card: title, honest counts, verdict selector, word-buttons
80
+ expect(frame).toContain("submit review");
81
+ expect(frame).toContain("0 annotations · 0 blocking");
82
+ expect(frame).toContain("[Approve]"); // nothing pending: approve default
83
+ expect(frame).toContain(" Submit ");
84
+ expect(frame).toContain(" Cancel ");
85
+ // key hints live in the status line, not on the buttons
86
+ expect(frame).toContain("verdict ←/→ · ⏎ submit · esc cancel");
87
+ // the bottom bar stayed a one-line hint: no detached verdict bar
88
+ expect(frame).not.toContain("Submit review (0) on ⏎");
89
+ });
90
+
91
+ test("left/right cycles the verdict selector in the card", async () => {
92
+ const setup = await renderApp();
93
+ await press(setup, "enter");
94
+ expect(setup.captureCharFrame()).toContain("[Approve]");
95
+ await press(setup, "right");
96
+ expect(setup.captureCharFrame()).toContain("[Changes]");
97
+ await press(setup, "right");
98
+ expect(setup.captureCharFrame()).toContain("[Comment]");
99
+ await press(setup, "left");
100
+ expect(setup.captureCharFrame()).toContain("[Changes]");
101
+ });
102
+
103
+ test("esc cancels the card and restores the plain Submit button", async () => {
104
+ const setup = await renderApp();
105
+ await press(setup, "enter");
106
+ expect(setup.captureCharFrame()).toContain("[Approve]");
107
+ await press(setup, "escape");
108
+ // a bare ESC settles after the parser's escape-sequence window
109
+ const frame = await waitForTextGone(setup, "[Approve]");
110
+ expect(frame).not.toContain("0 annotations");
111
+ expect(frame).toContain("Submit review (0)");
112
+ });
113
+
114
+ test("enter in the card resolves the session through the controller", async () => {
115
+ seedAnnotations(1);
116
+ const setup = await renderApp();
117
+ await press(setup, "enter");
118
+ const frame = setup.captureCharFrame();
119
+ expect(frame).toContain("1 annotations · 0 blocking");
120
+ expect(frame).toContain("[Changes]"); // pending items: request changes default
121
+ await setup.mockInput.typeText("Tighten the steps.");
122
+ await press(setup, "enter");
123
+ // the completion flow after submit is unchanged
124
+ await waitForText(setup, "feedback sent");
125
+ const stored = server.core.sessionGet(session.id);
126
+ expect(stored.status).toBe("resolved");
127
+ expect(stored.verdict!.kind).toBe("request_changes");
128
+ });
129
+
130
+ test("the annotation stack stays scrollable while the card is open", async () => {
131
+ seedAnnotations(12);
132
+ const setup = await renderApp();
133
+ // walk annotation focus to the last card: the rail scrolls it into view
134
+ for (let index = 0; index < 12; index++) await press(setup, "n");
135
+ const scrolled = setup.captureCharFrame();
136
+ expect(scrolled).toContain("note 12");
137
+ expect(scrolled).not.toContain("note 01");
138
+ await press(setup, "enter");
139
+ const opened = setup.captureCharFrame();
140
+ // the card is pinned outside the scrollbox; the stack above stays
141
+ // scrolled away from the top when the card takes its space
142
+ expect(opened).toContain("12 annotations · 0 blocking");
143
+ expect(opened).not.toContain("note 01");
144
+ // the stack still scrolls with the card open: wheel over the rail
145
+ // brings the last card back into view while the confirm card stays put
146
+ for (let turn = 0; turn < 12; turn++) await setup.mockMouse.scroll(100, 10, "down");
147
+ await settle(setup);
148
+ const scrolledWithCard = setup.captureCharFrame();
149
+ expect(scrolledWithCard).toContain("note 12");
150
+ expect(scrolledWithCard).toContain("12 annotations · 0 blocking");
151
+ expect(scrolledWithCard).not.toContain("note 01");
152
+ });
153
+
154
+ test("read-only observers never see the confirm card", async () => {
155
+ seedAnnotations(1);
156
+ const setup = await renderApp({ readOnly: true });
157
+ await press(setup, "enter");
158
+ const frame = setup.captureCharFrame();
159
+ expect(frame).toContain("observer - read-only");
160
+ expect(frame).not.toContain("1 annotations");
161
+ expect(frame).not.toContain("[Changes]");
162
+ expect(setup.captureCharFrame()).not.toContain("verdict ←/→");
163
+ expect(server.core.sessionGet(session.id).status).toBe("pending");
164
+ });
165
+ });
package/src/App.test.tsx CHANGED
@@ -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, typeText as type, waitForState, waitForText } from "./test-support";
16
17
 
17
18
  const PLAN = `# Migration Plan
18
19
 
@@ -49,35 +50,11 @@ async function renderApp(sessionId?: string) {
49
50
  width: 120,
50
51
  height: 32,
51
52
  });
52
- // let the async daemon connect + first fetch land
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();
53
+ // the async daemon connect + first fetch land within the frame wait
54
+ await waitForText(setup, "cueloop");
58
55
  return setup;
59
56
  }
60
57
 
61
- type Setup = Awaited<ReturnType<typeof renderApp>>;
62
-
63
- /** Drive one key press: letters type as text; named keys use KeyCodes ids. */
64
- async function press(setup: Setup, k: string): Promise<void> {
65
- if (k === "enter") setup.mockInput.pressKey("RETURN");
66
- else if (k === "escape") setup.mockInput.pressKey("ESCAPE");
67
- else if (k === "backspace") setup.mockInput.pressKey("BACKSPACE");
68
- else if (k === "left") setup.mockInput.pressKey("ARROW_LEFT");
69
- else if (k === "right") setup.mockInput.pressKey("ARROW_RIGHT");
70
- else await type(setup, k);
71
- await Bun.sleep(15);
72
- await setup.renderOnce();
73
- }
74
-
75
- async function type(setup: Setup, text: string): Promise<void> {
76
- await setup.mockInput.typeText(text);
77
- await Bun.sleep(15);
78
- await setup.renderOnce();
79
- }
80
-
81
58
  describe("plan rendering", () => {
82
59
  test("renders the plan with headings, list markers, and the rail", async () => {
83
60
  const setup = await renderApp();
@@ -86,7 +63,7 @@ describe("plan rendering", () => {
86
63
  expect(frame).toContain("Context");
87
64
  expect(frame).toContain("persists sessions to disk atomically");
88
65
  expect(frame).toContain("- move the store");
89
- expect(frame).toContain("REVIEW (0)");
66
+ expect(frame).toContain("Review (0)");
90
67
  expect(frame).toContain("Submit review (0)");
91
68
  });
92
69
  });
@@ -98,7 +75,7 @@ describe("keyboard grammar", () => {
98
75
  await press(setup, "j");
99
76
  await setup.renderOnce();
100
77
  const lines = setup.captureCharFrame().split("\n");
101
- const cursorLine = lines.find((l) => l.includes("▎"))!;
78
+ const cursorLine = lines.find((line) => line.includes("▎"))!;
102
79
  expect(cursorLine).toContain("persists sessions");
103
80
  });
104
81
 
@@ -108,15 +85,13 @@ describe("keyboard grammar", () => {
108
85
  await press(setup, "j");
109
86
  await press(setup, "c");
110
87
  await setup.renderOnce();
111
- expect(setup.captureCharFrame()).toContain("COMMENT ON");
88
+ expect(setup.captureCharFrame()).toContain('comment on "The daemon');
112
89
  await type(setup, "Define atomically.");
113
90
  await press(setup, "enter");
114
- await Bun.sleep(80);
115
- await setup.renderOnce();
91
+ await waitForText(setup, "COMMENT");
116
92
  const stored = server.core.sessionGet(session.id);
117
93
  expect(stored.annotations.length).toBe(1);
118
94
  expect(stored.annotations[0]!.body).toBe("Define atomically.");
119
- expect(setup.captureCharFrame()).toContain("COMMENT");
120
95
  });
121
96
 
122
97
  test("span mode: v + l selects words, c anchors the exact span", async () => {
@@ -128,7 +103,7 @@ describe("keyboard grammar", () => {
128
103
  await press(setup, "c");
129
104
  await type(setup, "Which daemon?");
130
105
  await press(setup, "enter");
131
- await Bun.sleep(80);
106
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
132
107
  const stored = server.core.sessionGet(session.id);
133
108
  expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
134
109
  });
@@ -138,14 +113,10 @@ describe("keyboard grammar", () => {
138
113
  // move to "- move the store" (h1, h2, p, h2 = 4 steps in)
139
114
  for (let i = 0; i < 4; i++) await press(setup, "j");
140
115
  await press(setup, "x");
141
- await Bun.sleep(80);
142
- await setup.renderOnce();
116
+ await waitForText(setup, "[cut]");
143
117
  expect(server.core.sessionGet(session.id).workingCopy).not.toContain("move the store");
144
- expect(setup.captureCharFrame()).toContain("[cut]");
145
118
  await press(setup, "x");
146
- await Bun.sleep(80);
147
- await setup.renderOnce();
148
- expect(server.core.sessionGet(session.id).workingCopy).toBeUndefined();
119
+ await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
149
120
  });
150
121
 
151
122
  test("e runs $EDITOR on the working copy and tracks the diff", async () => {
@@ -156,10 +127,8 @@ describe("keyboard grammar", () => {
156
127
  try {
157
128
  const setup = await renderApp();
158
129
  await press(setup, "e");
159
- await Bun.sleep(150);
160
- await setup.renderOnce();
130
+ await waitForText(setup, "[edited]");
161
131
  expect(server.core.sessionGet(session.id).workingCopy).toContain("very atomically");
162
- expect(setup.captureCharFrame()).toContain("[edited]");
163
132
  } finally {
164
133
  delete process.env.CUELOOP_EDITOR;
165
134
  }
@@ -167,35 +136,33 @@ describe("keyboard grammar", () => {
167
136
  });
168
137
 
169
138
  describe("submit", () => {
170
- test("⏎ opens the verdict bar; verdict + summary resolve the session", async () => {
139
+ test("⏎ opens the rail confirm card; verdict + summary resolve the session", async () => {
171
140
  const setup = await renderApp();
172
141
  await press(setup, "j");
173
142
  await press(setup, "j");
174
143
  await press(setup, "c");
175
144
  await type(setup, "Needs a phase list.");
176
145
  await press(setup, "enter");
177
- await Bun.sleep(80);
146
+ await waitForText(setup, "Review (1)");
178
147
  await press(setup, "enter"); // open submit
179
- await setup.renderOnce();
180
- expect(setup.captureCharFrame()).toContain("[Request changes]");
148
+ expect(setup.captureCharFrame()).toContain("[Changes]");
181
149
  await type(setup, "Expand the steps.");
182
150
  await press(setup, "enter");
183
- await Bun.sleep(120);
184
- await setup.renderOnce();
151
+ await waitForText(setup, "feedback sent");
185
152
  const stored = server.core.sessionGet(session.id);
186
153
  expect(stored.status).toBe("resolved");
187
154
  expect(stored.verdict!.kind).toBe("request_changes");
188
155
  expect(stored.verdict!.feedback).toContain("Needs a phase list.");
189
- expect(setup.captureCharFrame()).toContain("resolved: request changes");
156
+ // submit hands the reviewer back to the agent via the completion overlay
157
+ expect(setup.captureCharFrame()).toContain("feedback sent");
190
158
  });
191
159
 
192
160
  test("approve via ←/→ verdict cycling", async () => {
193
161
  const setup = await renderApp();
194
162
  await press(setup, "enter");
195
- await setup.renderOnce();
196
163
  expect(setup.captureCharFrame()).toContain("[Approve]"); // no pending items → approve default
197
164
  await press(setup, "enter");
198
- await Bun.sleep(120);
165
+ await waitForState(setup, () => server.core.sessionGet(session.id).verdict !== undefined);
199
166
  expect(server.core.sessionGet(session.id).verdict!.kind).toBe("approve");
200
167
  });
201
168
  });
@@ -203,15 +170,10 @@ describe("submit", () => {
203
170
  describe("inbox", () => {
204
171
  test("inbox mode renders and opens a session", async () => {
205
172
  const setup = await testRender(<App home={home} />, { width: 120, height: 32 });
206
- for (let i = 0; i < 40 && !setup.captureCharFrame().includes("inbox"); i++) {
207
- await Bun.sleep(25);
208
- await setup.renderOnce();
209
- }
173
+ await waitForText(setup, "inbox");
210
174
  expect(setup.captureCharFrame()).toContain("inbox (1 pending)");
211
175
  expect(setup.captureCharFrame()).toContain("Migration Plan");
212
176
  await press(setup, "enter");
213
- await Bun.sleep(80);
214
- await setup.renderOnce();
215
- expect(setup.captureCharFrame()).toContain("Submit review (0)");
177
+ await waitForText(setup, "Submit review (0)");
216
178
  });
217
179
  });
package/src/App.tsx CHANGED
Binary file