@cueloop/client 0.1.0-alpha.4 → 0.1.0-alpha.41

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 (138) hide show
  1. package/native/build.sh +56 -0
  2. package/native/darwin-arm64/libcueloopvt.dylib +0 -0
  3. package/native/src/shim.c +107 -0
  4. package/package.json +34 -24
  5. package/src/App.autoclose.test.tsx +137 -0
  6. package/src/App.diff.test.tsx +158 -22
  7. package/src/App.export.test.tsx +44 -37
  8. package/src/App.plan-review.test.tsx +632 -0
  9. package/src/App.readonly.test.tsx +60 -36
  10. package/src/App.rendering.test.tsx +132 -0
  11. package/src/App.review-panel.test.tsx +136 -0
  12. package/src/App.submit-confirm.test.tsx +220 -0
  13. package/src/App.test.tsx +128 -69
  14. package/src/App.toast.test.tsx +75 -0
  15. package/src/App.tsx +0 -0
  16. package/src/App.walk.test.tsx +288 -0
  17. package/src/attribution.test.ts +31 -0
  18. package/src/attribution.ts +38 -0
  19. package/src/clipboard.test.ts +23 -0
  20. package/src/clipboard.ts +30 -0
  21. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  22. package/src/components/AnnotationCard.stories.tsx +94 -0
  23. package/src/components/AnnotationCard.tsx +228 -0
  24. package/src/components/Breadcrumb.stories.tsx +19 -0
  25. package/src/components/Breadcrumb.tsx +44 -0
  26. package/src/components/CodeBlock.stories.tsx +42 -0
  27. package/src/components/CodeBlock.tsx +83 -0
  28. package/src/components/CompletionOverlay.stories.tsx +23 -0
  29. package/src/components/CompletionOverlay.tsx +59 -0
  30. package/src/components/ConfirmCard.stories.tsx +56 -0
  31. package/src/components/ConfirmCard.tsx +135 -0
  32. package/src/components/ConfirmDialog.stories.tsx +21 -0
  33. package/src/components/ConfirmDialog.tsx +64 -0
  34. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  35. package/src/components/DiffSheet.stories.tsx +78 -0
  36. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  37. package/src/components/DiffSheet.tsx +355 -0
  38. package/src/components/InboxList.stories.tsx +31 -0
  39. package/src/components/InboxList.tsx +62 -0
  40. package/src/components/KeybindsDialog.stories.tsx +31 -0
  41. package/src/components/KeybindsDialog.tsx +58 -0
  42. package/src/components/MarkerPopover.stories.tsx +58 -0
  43. package/src/components/MarkerPopover.tsx +102 -0
  44. package/src/components/MenuBar.stories.tsx +37 -0
  45. package/src/components/MenuBar.tsx +74 -0
  46. package/src/components/PlanSheet.stories.tsx +113 -0
  47. package/src/components/PlanSheet.tsx +389 -0
  48. package/src/components/PromptDialog.stories.tsx +36 -0
  49. package/src/components/PromptDialog.tsx +78 -0
  50. package/src/components/ReviewPanel.stories.tsx +86 -0
  51. package/src/components/ReviewPanel.tsx +101 -0
  52. package/src/components/ReviewRail.stories.tsx +220 -0
  53. package/src/components/ReviewRail.tsx +324 -0
  54. package/src/components/SettingsDialog.stories.tsx +111 -0
  55. package/src/components/SettingsDialog.tsx +141 -0
  56. package/src/components/SettingsRows.stories.tsx +18 -0
  57. package/src/components/SettingsRows.tsx +108 -0
  58. package/src/components/Toast.stories.tsx +12 -0
  59. package/src/components/Toast.tsx +44 -0
  60. package/src/components/WalkWizard.stories.tsx +100 -0
  61. package/src/components/WalkWizard.tsx +152 -0
  62. package/src/components/__snapshots__/stories.test.tsx.snap +1829 -0
  63. package/src/components/agent-launcher.stories.tsx +26 -0
  64. package/src/components/agent-launcher.test.tsx +21 -0
  65. package/src/components/agent-launcher.tsx +180 -0
  66. package/src/components/diff-sheet-layout.test.ts +127 -0
  67. package/src/components/diff-sheet-layout.ts +167 -0
  68. package/src/components/primitives/Button.stories.tsx +32 -0
  69. package/src/components/primitives/Button.tsx +48 -0
  70. package/src/components/primitives/Card.stories.tsx +25 -0
  71. package/src/components/primitives/Card.tsx +59 -0
  72. package/src/components/primitives/Dialog.stories.tsx +24 -0
  73. package/src/components/primitives/Dialog.tsx +64 -0
  74. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  75. package/src/components/primitives/StatusBar.tsx +22 -0
  76. package/src/components/primitives/Tabs.stories.tsx +29 -0
  77. package/src/components/primitives/Tabs.tsx +98 -0
  78. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  79. package/src/components/primitives/Toolbar.tsx +14 -0
  80. package/src/components/primitives/frame.test.ts +12 -0
  81. package/src/components/primitives/frame.ts +14 -0
  82. package/src/components/quick-actions-editor.stories.tsx +54 -0
  83. package/src/components/quick-actions-editor.tsx +136 -0
  84. package/src/components/stories-app.tsx +123 -0
  85. package/src/components/stories.test.tsx +62 -0
  86. package/src/components/story-fixtures.ts +98 -0
  87. package/src/components/story.ts +69 -0
  88. package/src/components/syntax-highlight.ts +92 -0
  89. package/src/components/terminal-pane.test.tsx +40 -0
  90. package/src/components/terminal-pane.ts +174 -0
  91. package/src/components/theme-context.stories.tsx +46 -0
  92. package/src/components/theme-context.tsx +36 -0
  93. package/src/components/truncate-text.ts +6 -0
  94. package/src/config.test.ts +430 -31
  95. package/src/config.ts +304 -26
  96. package/src/diff-hunk-curate.test.ts +315 -0
  97. package/src/diff-hunk-curate.ts +233 -0
  98. package/src/diff-intraline.test.ts +164 -0
  99. package/src/diff-intraline.ts +132 -0
  100. package/src/diff-syntax.test.ts +96 -0
  101. package/src/diff-syntax.ts +154 -0
  102. package/src/editor.test.ts +143 -0
  103. package/src/editor.ts +123 -9
  104. package/src/ghostty-terminal.test.ts +45 -0
  105. package/src/ghostty-terminal.ts +168 -0
  106. package/src/herdr.test.ts +41 -0
  107. package/src/herdr.ts +27 -0
  108. package/src/index.ts +10 -0
  109. package/src/integrations.test.ts +41 -0
  110. package/src/integrations.ts +30 -0
  111. package/src/intent-dispatch.test.ts +492 -0
  112. package/src/intent-dispatch.ts +428 -0
  113. package/src/key-bindings.test.ts +152 -0
  114. package/src/key-bindings.ts +424 -0
  115. package/src/keymap.test.ts +622 -0
  116. package/src/keymap.ts +339 -0
  117. package/src/review-panel.test.ts +113 -0
  118. package/src/review-panel.ts +83 -0
  119. package/src/run.ts +9 -5
  120. package/src/serve.test.ts +24 -9
  121. package/src/serve.ts +7 -7
  122. package/src/session-controller.curate.test.ts +253 -0
  123. package/src/session-controller.share.test.ts +309 -0
  124. package/src/session-controller.ts +900 -0
  125. package/src/share.ts +113 -0
  126. package/src/test-support.test.ts +55 -0
  127. package/src/test-support.ts +187 -0
  128. package/src/theme-presets.ts +168 -0
  129. package/src/theme.ts +36 -15
  130. package/src/version.ts +5 -0
  131. package/src/view-diff.test.ts +19 -8
  132. package/src/view-diff.ts +34 -23
  133. package/src/view-plan.test.ts +294 -0
  134. package/src/view-plan.ts +408 -0
  135. package/src/walk.test.ts +121 -0
  136. package/src/walk.ts +70 -0
  137. package/src/view.test.ts +0 -157
  138. package/src/view.ts +0 -0
@@ -1,8 +1,4 @@
1
- /**
2
- * Observer mode (tier 2): the App with readOnly renders and navigates like the
3
- * controller's, but every mutating verb answers "observer - read-only" and
4
- * leaves daemon state untouched.
5
- */
1
+ /** Observer mode (tier 2): the App with readOnly renders and navigates like the controller's, but every mutating verb answers "observer - read-only" and leaves daemon state untouched. */
6
2
 
7
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
4
  import { mkdtempSync, rmSync } from "node:fs";
@@ -13,6 +9,8 @@ import { testRender } from "@opentui/react/test-utils";
13
9
  import { DaemonServer } from "@cueloop/daemon";
14
10
  import { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
15
11
  import { App } from "./App";
12
+ import { DARK } from "./theme";
13
+ import { isolateUserConfig, press, waitForState, waitForText } from "./test-support";
16
14
 
17
15
  const PLAN = `# Migration Plan
18
16
 
@@ -27,19 +25,26 @@ The daemon persists sessions to disk atomically.
27
25
  `;
28
26
 
29
27
  let home: string;
28
+ let restoreUserConfig: () => void;
30
29
  let server: DaemonServer;
31
30
  let session: ReviewSession;
32
31
 
33
32
  beforeEach(() => {
34
33
  home = mkdtempSync(join(tmpdir(), "cueloop-observer-"));
34
+ restoreUserConfig = isolateUserConfig(home);
35
35
  server = new DaemonServer({ home, idleExitMs: 0 });
36
36
  server.start();
37
37
  session = server.core.sessionCreate({
38
38
  workspace: { repoRoot: "/repo", branch: "main" },
39
- artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
39
+ artifact: {
40
+ type: "plan",
41
+ content: PLAN,
42
+ meta: { title: "Migration Plan", planPath: "plan.md" },
43
+ },
40
44
  });
41
45
  });
42
46
  afterEach(() => {
47
+ restoreUserConfig();
43
48
  server.stop();
44
49
  rmSync(home, { recursive: true, force: true });
45
50
  });
@@ -49,43 +54,35 @@ async function renderObserver() {
49
54
  width: 120,
50
55
  height: 32,
51
56
  });
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();
57
+ await waitForText(setup, "cueloop");
57
58
  return setup;
58
59
  }
59
60
 
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
61
  /** Session state that any mutating verb would change. */
70
62
  function snapshot() {
71
- const s = server.core.sessionGet(session.id);
72
- return { annotations: s.annotations.length, workingCopy: s.workingCopy, status: s.status };
63
+ const stored = server.core.sessionGet(session.id);
64
+ return {
65
+ annotations: stored.annotations.length,
66
+ workingCopy: stored.workingCopy,
67
+ status: stored.status,
68
+ };
73
69
  }
74
70
 
75
71
  describe("observer rendering", () => {
76
- test("shows the observer badge and read-only hint bar", async () => {
72
+ test("shows the observer badge and the bottom menu", async () => {
73
+ // Arrange
77
74
  const setup = await renderObserver();
75
+
76
+ // Assert
78
77
  const frame = setup.captureCharFrame();
79
78
  expect(frame).toContain("· observer");
80
- expect(frame).toContain("observer - read-only · j/k move");
81
- expect(frame).not.toContain("⏎ submit");
79
+ expect(frame).toContain("menu");
82
80
  });
83
81
  });
84
82
 
85
83
  describe("observer verbs are blocked", () => {
86
84
  for (const [key, verb] of [
87
85
  ["c", "comment"],
88
- ["s", "suggest"],
89
86
  ["x", "cut"],
90
87
  ["e", "edit"],
91
88
  ["enter", "submit"],
@@ -96,44 +93,51 @@ describe("observer verbs are blocked", () => {
96
93
  await press(setup, "j");
97
94
  await press(setup, "j");
98
95
  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");
96
+ const frame = await waitForText(setup, "observer - read-only");
103
97
  // no overlay opened: compose/submit bars never appear
104
- expect(frame).not.toContain("COMMENT ON");
105
- expect(frame).not.toContain("SUGGEST REPLACEMENT");
98
+ expect(frame).not.toContain('comment on "');
106
99
  expect(frame).not.toContain("verdict ←/→");
107
100
  expect(snapshot()).toEqual(before);
108
101
  });
109
102
  }
110
103
 
111
- test("span mode c/s is blocked too", async () => {
104
+ test("span mode c is blocked too", async () => {
105
+ // Arrange
112
106
  const setup = await renderObserver();
107
+
108
+ // Act
113
109
  await press(setup, "j");
114
110
  await press(setup, "j");
115
111
  await press(setup, "v");
116
112
  await press(setup, "c");
117
113
  await setup.renderOnce();
114
+
115
+ // Assert
118
116
  const frame = setup.captureCharFrame();
119
117
  expect(frame).toContain("observer - read-only");
120
- expect(frame).not.toContain("COMMENT ON");
118
+ expect(frame).not.toContain('comment on "');
121
119
  expect(snapshot().annotations).toBe(0);
122
120
  });
123
121
  });
124
122
 
125
123
  describe("observer navigation still works", () => {
126
124
  test("j/k moves the cursor between blocks", async () => {
125
+ // Arrange
127
126
  const setup = await renderObserver();
127
+
128
+ // Act
128
129
  await press(setup, "j");
129
130
  await press(setup, "j");
130
131
  await setup.renderOnce();
132
+
133
+ // Assert
131
134
  const lines = setup.captureCharFrame().split("\n");
132
- const cursorLine = lines.find((l) => l.includes("▎"))!;
135
+ const cursorLine = lines.find((line) => line.includes("▎"))!;
133
136
  expect(cursorLine).toContain("persists sessions");
134
137
  });
135
138
 
136
139
  test("n focuses annotations made by the controller", async () => {
140
+ // Arrange
137
141
  server.core.sessionAnnotate(session.id, {
138
142
  id: "a_obs1",
139
143
  kind: "comment",
@@ -141,8 +145,28 @@ describe("observer navigation still works", () => {
141
145
  body: "From the controller.",
142
146
  });
143
147
  const setup = await renderObserver();
148
+
149
+ // Act
144
150
  await press(setup, "n");
145
151
  await setup.renderOnce();
146
- expect(setup.captureCharFrame()).toContain("▸ COMMENT");
152
+
153
+ // Assert - focus shows as the card's bright kind-colored border (transparent
154
+ // fill); a selected card is the only accent-colored border in the frame
155
+ await waitForText(setup, "COMMENT · me");
156
+ await waitForState(setup, () => hasBorderColor(setup, DARK.accent));
147
157
  });
148
158
  });
159
+
160
+ /** Whether any border character (box-drawing) is painted in the given foreground hex. */
161
+ function hasBorderColor(setup: Awaited<ReturnType<typeof renderObserver>>, hex: string): boolean {
162
+ for (const line of setup.captureSpans().lines) {
163
+ for (const span of line.spans) {
164
+ if (!/[╭╮╰╯│─┌┐└┘]/.test(span.text)) continue;
165
+ const [red, green, blue] = span.fg.toInts();
166
+ const rendered =
167
+ "#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join("");
168
+ if (rendered === hex) return true;
169
+ }
170
+ }
171
+ return false;
172
+ }
@@ -0,0 +1,132 @@
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 { isolateUserConfig, 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 restoreUserConfig: () => void;
34
+ let server: DaemonServer;
35
+ let session: ReviewSession;
36
+
37
+ beforeEach(() => {
38
+ home = mkdtempSync(join(tmpdir(), "cueloop-render-"));
39
+ restoreUserConfig = isolateUserConfig(home);
40
+ server = new DaemonServer({ home, idleExitMs: 0 });
41
+ server.start();
42
+ session = server.core.sessionCreate({
43
+ workspace: { repoRoot: "/repo", branch: "main" },
44
+ artifact: { type: "plan", content: PLAN, meta: { title: "Render Plan" } },
45
+ });
46
+ });
47
+ afterEach(() => {
48
+ restoreUserConfig();
49
+ server.stop();
50
+ rmSync(home, { recursive: true, force: true });
51
+ });
52
+
53
+ async function frame(): Promise<string> {
54
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
55
+ width: 120,
56
+ height: 36,
57
+ });
58
+ await waitForText(setup, "Render Plan");
59
+ // async highlights settle within the visual-idle wait
60
+ await setup.waitForVisualIdle();
61
+ return setup.captureCharFrame();
62
+ }
63
+
64
+ describe("code blocks", () => {
65
+ test("lines render verbatim: indentation preserved, no word-wrap", async () => {
66
+ // Arrange
67
+ const rendered = await frame();
68
+
69
+ // Assert
70
+ expect(rendered).toContain(" const blocked = full < 0.6;");
71
+ expect(rendered).toContain(' return <text>{blocked ? "blocked" : "clear"}</text>;');
72
+ });
73
+
74
+ test("the block carries its language tag", async () => {
75
+ // Arrange
76
+ const rendered = await frame();
77
+
78
+ // Assert
79
+ expect(rendered).toContain("tsx");
80
+ });
81
+ });
82
+
83
+ describe("block spacing", () => {
84
+ // the review panel's full-height divider paints a `│` on every row, so read
85
+ // the plan column (everything left of the divider) before asserting spacing
86
+ // the plan body is framed: content sits between the left border and the next │
87
+ const planColumn = (line: string): string => line.split("│")[1]?.trimEnd() ?? "";
88
+
89
+ test("a code block never glues to the list item above it", async () => {
90
+ // Arrange
91
+ const lines = (await frame()).split("\n").map(planColumn);
92
+ const listItemLineIndex = lines.findIndex((line) => line.includes("wire the duck over MQTT"));
93
+ const codeHeader = lines.findIndex((line) => line.trim().startsWith("tsx"));
94
+
95
+ // Assert
96
+ expect(listItemLineIndex).toBeGreaterThan(-1);
97
+ expect(codeHeader).toBeGreaterThan(listItemLineIndex);
98
+ expect(lines.slice(listItemLineIndex + 1, codeHeader).some((line) => line === "")).toBe(true);
99
+ });
100
+
101
+ test("a heading never sits directly on the previous block", async () => {
102
+ // Arrange
103
+ const lines = (await frame()).split("\n").map(planColumn);
104
+ const gate = lines.findIndex((line) => line.trim() === "Gate");
105
+
106
+ // Assert
107
+ expect(gate).toBeGreaterThan(0);
108
+ expect(lines[gate - 1]).toBe("");
109
+ });
110
+
111
+ test("consecutive list items stay tight", async () => {
112
+ // Arrange
113
+ const listPlan = "# T\n\n- one\n- two\n";
114
+ const s2 = server.core.sessionCreate({
115
+ workspace: { repoRoot: "/repo", branch: "main" },
116
+ artifact: { type: "plan", content: listPlan, meta: {} },
117
+ });
118
+ const setup = await testRender(<App home={home} sessionId={s2.id} />, {
119
+ width: 120,
120
+ height: 30,
121
+ });
122
+ await waitForText(setup, "one");
123
+
124
+ // Assert
125
+ const lines = setup
126
+ .captureCharFrame()
127
+ .split("\n")
128
+ .map((line) => line.trimEnd());
129
+ const one = lines.findIndex((line) => line.includes("- one"));
130
+ expect(lines[one + 1]).toContain("- two");
131
+ });
132
+ });
@@ -0,0 +1,136 @@
1
+ /** The review panel end to end: the b key cycles expanded -> compact -> hidden -> expanded on the live App, and the collapse mode persists to the user config so it survives a restart. Rendering details are locked by the ReviewPanel stories; this suite proves the App wiring and the persistence. */
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 { loadConfig } from "./config";
13
+ import { isolateUserConfig, press, settle, waitForText, waitForTextGone } from "./test-support";
14
+
15
+ const PLAN = "# Plan\n\nShip the thing.\n";
16
+
17
+ let home: string;
18
+ let server: DaemonServer;
19
+ let session: ReviewSession;
20
+ let configPath: string;
21
+ let restoreUserConfig: () => void;
22
+
23
+ beforeEach(() => {
24
+ home = mkdtempSync(join(tmpdir(), "cueloop-panel-"));
25
+ configPath = join(home, "config.toml");
26
+ restoreUserConfig = isolateUserConfig(home, "config.toml");
27
+ server = new DaemonServer({ home, idleExitMs: 0 });
28
+ server.start();
29
+ session = server.core.sessionCreate({
30
+ workspace: { repoRoot: "/repo", branch: "main" },
31
+ artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
32
+ });
33
+ });
34
+ afterEach(() => {
35
+ restoreUserConfig();
36
+ server.stop();
37
+ rmSync(home, { recursive: true, force: true });
38
+ });
39
+
40
+ async function renderApp() {
41
+ const setup = await testRender(<App home={home} sessionId={session.id} />, {
42
+ width: 120,
43
+ height: 30,
44
+ });
45
+ await waitForText(setup, "Review");
46
+ return setup;
47
+ }
48
+
49
+ describe("review panel", () => {
50
+ test("b cycles expanded -> compact -> hidden -> expanded", async () => {
51
+ // Arrange
52
+ const setup = await renderApp();
53
+ // the plan frame widens as the rail shrinks: expanded < compact < hidden.
54
+ // its width is the column of its top-right corner (the first ╮ on the row).
55
+ const planWidth = () =>
56
+ setup
57
+ .captureCharFrame()
58
+ .split("\n")
59
+ .find((line) => line.includes("╮"))
60
+ ?.indexOf("╮") ?? 0;
61
+
62
+ // Assert: expanded shows the submit button and leaves room for the rail
63
+ expect(setup.captureCharFrame()).toContain("Submit review");
64
+ const expandedWidth = planWidth();
65
+
66
+ // Act
67
+ await press(setup, "b");
68
+
69
+ // Assert: compact drops the submit button and widens the plan (rail is a strip)
70
+ await waitForTextGone(setup, "Submit review");
71
+ const compactWidth = planWidth();
72
+ expect(compactWidth).toBeGreaterThan(expandedWidth);
73
+
74
+ // Act
75
+ await press(setup, "b");
76
+ await settle(setup);
77
+
78
+ // Assert: hidden spans the plan full width, no rail column
79
+ const hiddenWidth = planWidth();
80
+ expect(hiddenWidth).toBeGreaterThan(compactWidth);
81
+ expect(hiddenWidth).toBeGreaterThan(115);
82
+
83
+ // Act
84
+ await press(setup, "b");
85
+
86
+ // Assert
87
+ await waitForText(setup, "Submit review"); // back to expanded
88
+ });
89
+
90
+ test("the collapse mode persists to the user config", async () => {
91
+ // Arrange
92
+ const setup = await renderApp();
93
+
94
+ // Act
95
+ await press(setup, "b"); // -> compact
96
+
97
+ // Assert
98
+ expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("compact");
99
+ });
100
+
101
+ test("] widens the rail and persists the new width", async () => {
102
+ // Arrange
103
+ const setup = await renderApp();
104
+ const startWidth = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
105
+
106
+ // Act
107
+ await press(setup, "]");
108
+
109
+ // Assert
110
+ const widened = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
111
+ expect(widened).toBeGreaterThan(startWidth);
112
+ });
113
+
114
+ test("submitting from a hidden panel force-opens the rail so the confirm card is reachable", async () => {
115
+ // Arrange
116
+ const setup = await renderApp();
117
+ await press(setup, "b"); // -> compact
118
+ await press(setup, "b"); // -> hidden
119
+
120
+ // Assert
121
+ await waitForTextGone(setup, "Submit review"); // rail is gone
122
+ expect(setup.captureCharFrame()).not.toContain("Submit review");
123
+
124
+ // Act
125
+ await press(setup, "enter"); // submit while hidden must not trap in an invisible modal
126
+
127
+ // Assert
128
+ await waitForText(setup, "submit review");
129
+ const frame = setup.captureCharFrame();
130
+ expect(frame).toContain("submit review"); // the confirm card is visible again
131
+ expect(frame).toContain("[Approve]");
132
+
133
+ // the reveal is live-only: the saved panel preference stays as the user left it
134
+ expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("hidden");
135
+ });
136
+ });
@@ -0,0 +1,220 @@
1
+ /** The rail submit confirm (tier 2): pressing submit expands the rail's Submit button into a bordered confirm card - counts, verdict selector, summary input, word-buttons. Char-frame assertions over the real App and an in-process daemon, like App.test.tsx. */
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 { makeAnchor, parseBlocks, type ReviewSession } from "@cueloop/schema";
11
+ import { App } from "./App";
12
+ import { isolateUserConfig, press, settle, waitForText, waitForTextGone } from "./test-support";
13
+
14
+ const PLAN = `# Migration Plan
15
+
16
+ ## Context
17
+
18
+ The daemon persists sessions to disk atomically.
19
+
20
+ ## Steps
21
+
22
+ - move the store
23
+ - add recovery
24
+ `;
25
+
26
+ let home: string;
27
+ let restoreUserConfig: () => void;
28
+ let server: DaemonServer;
29
+ let session: ReviewSession;
30
+
31
+ beforeEach(() => {
32
+ home = mkdtempSync(join(tmpdir(), "cueloop-confirm-"));
33
+ restoreUserConfig = isolateUserConfig(home);
34
+ server = new DaemonServer({ home, idleExitMs: 0 });
35
+ server.start();
36
+ session = server.core.sessionCreate({
37
+ workspace: { repoRoot: "/repo", branch: "main" },
38
+ artifact: {
39
+ type: "plan",
40
+ content: PLAN,
41
+ meta: { title: "Migration Plan", planPath: "plan.md" },
42
+ },
43
+ });
44
+ });
45
+ afterEach(() => {
46
+ restoreUserConfig();
47
+ server.stop();
48
+ rmSync(home, { recursive: true, force: true });
49
+ });
50
+
51
+ async function renderApp(options: { readOnly?: boolean } = {}) {
52
+ const setup = await testRender(
53
+ <App home={home} sessionId={session.id} readOnly={options.readOnly ?? false} />,
54
+ {
55
+ width: 120,
56
+ height: 32,
57
+ },
58
+ );
59
+ await waitForText(setup, "cueloop");
60
+ return setup;
61
+ }
62
+
63
+ /** Seed annotations directly through the daemon core (all on one block). */
64
+ function seedAnnotations(count: number): void {
65
+ const blocks = parseBlocks(PLAN);
66
+ for (let index = 1; index <= count; index++) {
67
+ server.core.sessionAnnotate(session.id, {
68
+ id: `a_confirm_${index}`,
69
+ kind: "comment",
70
+ anchor: makeAnchor(blocks, 2, 0, 10),
71
+ body: `note ${String(index).padStart(2, "0")}`,
72
+ });
73
+ }
74
+ }
75
+
76
+ describe("rail submit confirm", () => {
77
+ test("submit expands the Submit button into the confirm card", async () => {
78
+ // Arrange
79
+ const setup = await renderApp();
80
+
81
+ // Assert
82
+ expect(setup.captureCharFrame()).toContain("Submit review");
83
+
84
+ // Act
85
+ await press(setup, "enter");
86
+
87
+ // Assert
88
+ await waitForText(setup, "submit review");
89
+ const frame = setup.captureCharFrame();
90
+ // the card: title, honest counts, verdict selector, word-buttons
91
+ expect(frame).toContain("submit review");
92
+ expect(frame).toContain("0 annotations · 0 blocking");
93
+ expect(frame).toContain("[Approve]"); // nothing pending: approve default
94
+ expect(frame).toContain(" Submit ");
95
+ expect(frame).toContain(" Cancel ");
96
+ });
97
+
98
+ test("left/right cycles the verdict selector in the card", async () => {
99
+ // Arrange
100
+ const setup = await renderApp();
101
+ await press(setup, "enter");
102
+
103
+ // Assert
104
+ await waitForText(setup, "[Approve]");
105
+
106
+ // Act
107
+ await press(setup, "right");
108
+
109
+ // Assert
110
+ await waitForText(setup, "[Changes]");
111
+
112
+ // Act
113
+ await press(setup, "right");
114
+
115
+ // Assert
116
+ await waitForText(setup, "[Comment]");
117
+
118
+ // Act
119
+ await press(setup, "left");
120
+
121
+ // Assert
122
+ await waitForText(setup, "[Changes]");
123
+ });
124
+
125
+ test("esc cancels the card and restores the plain Submit button", async () => {
126
+ // Arrange
127
+ const setup = await renderApp();
128
+ await press(setup, "enter");
129
+
130
+ // Assert
131
+ await waitForText(setup, "[Approve]");
132
+
133
+ // Act
134
+ await press(setup, "escape");
135
+
136
+ // Assert
137
+ // a bare ESC settles after the parser's escape-sequence window
138
+ const frame = await waitForTextGone(setup, "[Approve]");
139
+ expect(frame).not.toContain("0 annotations");
140
+ expect(frame).toContain("Submit review");
141
+ });
142
+
143
+ test("enter in the card resolves the session through the controller", async () => {
144
+ // Arrange
145
+ seedAnnotations(1);
146
+ const setup = await renderApp();
147
+
148
+ // Act
149
+ await press(setup, "enter");
150
+
151
+ // Assert
152
+ await waitForText(setup, "1 annotations · 0 blocking");
153
+ const frame = setup.captureCharFrame();
154
+ expect(frame).toContain("1 annotations · 0 blocking");
155
+ expect(frame).toContain("[Changes]"); // pending items: request changes default
156
+
157
+ // Act
158
+ await setup.mockInput.typeText("Tighten the steps.");
159
+ await press(setup, "enter");
160
+
161
+ // Assert
162
+ // the completion flow after submit is unchanged
163
+ await waitForText(setup, "feedback sent");
164
+ const stored = server.core.sessionGet(session.id);
165
+ expect(stored.status).toBe("resolved");
166
+ expect(stored.verdict!.kind).toBe("request_changes");
167
+ });
168
+
169
+ test("the annotation stack stays scrollable while the card is open", async () => {
170
+ // Arrange
171
+ seedAnnotations(12);
172
+ const setup = await renderApp();
173
+
174
+ // Act
175
+ // walk annotation focus to the last card: the rail scrolls it into view
176
+ for (let index = 0; index < 12; index++) await press(setup, "n");
177
+
178
+ // Assert
179
+ await waitForText(setup, "note 12");
180
+ expect(setup.captureCharFrame()).not.toContain("note 01");
181
+
182
+ // Act
183
+ await press(setup, "enter");
184
+
185
+ // Assert
186
+ await waitForText(setup, "12 annotations · 0 blocking");
187
+ // the card is pinned outside the scrollbox; the stack stays scrolled off the top
188
+ expect(setup.captureCharFrame()).not.toContain("note 01");
189
+
190
+ // Act
191
+ // the stack still scrolls with the card open: wheel over the rail
192
+ // brings the last card back into view while the confirm card stays put
193
+ for (let turn = 0; turn < 12; turn++) await setup.mockMouse.scroll(100, 10, "down");
194
+ await settle(setup);
195
+
196
+ // Assert
197
+ await waitForText(setup, "note 12");
198
+ const scrolledWithCard = setup.captureCharFrame();
199
+ expect(scrolledWithCard).toContain("12 annotations · 0 blocking");
200
+ expect(scrolledWithCard).not.toContain("note 01");
201
+ });
202
+
203
+ test("read-only observers never see the confirm card", async () => {
204
+ // Arrange
205
+ seedAnnotations(1);
206
+ const setup = await renderApp({ readOnly: true });
207
+
208
+ // Act
209
+ await press(setup, "enter");
210
+
211
+ // Assert
212
+ await waitForText(setup, "observer - read-only");
213
+ const frame = setup.captureCharFrame();
214
+ expect(frame).toContain("observer - read-only");
215
+ expect(frame).not.toContain("1 annotations");
216
+ expect(frame).not.toContain("[Changes]");
217
+ expect(setup.captureCharFrame()).not.toContain("verdict ←/→");
218
+ expect(server.core.sessionGet(session.id).status).toBe("pending");
219
+ });
220
+ });