@cueloop/client 0.1.0-alpha.3 → 0.1.0-alpha.31

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 (105) hide show
  1. package/package.json +17 -11
  2. package/src/App.autoclose.test.tsx +133 -0
  3. package/src/App.diff.test.tsx +27 -20
  4. package/src/App.export.test.tsx +19 -28
  5. package/src/App.plan-review.test.tsx +507 -0
  6. package/src/App.readonly.test.tsx +26 -27
  7. package/src/App.rendering.test.tsx +119 -0
  8. package/src/App.review-panel.test.tsx +123 -0
  9. package/src/App.submit-confirm.test.tsx +216 -0
  10. package/src/App.test.tsx +83 -64
  11. package/src/App.tsx +0 -0
  12. package/src/App.walk.test.tsx +265 -0
  13. package/src/attribution.test.ts +31 -0
  14. package/src/attribution.ts +32 -0
  15. package/src/clipboard.test.ts +23 -0
  16. package/src/clipboard.ts +26 -0
  17. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  18. package/src/components/AnnotationCard.stories.tsx +75 -0
  19. package/src/components/AnnotationCard.tsx +208 -0
  20. package/src/components/Breadcrumb.stories.tsx +19 -0
  21. package/src/components/Breadcrumb.tsx +38 -0
  22. package/src/components/CodeBlock.stories.tsx +25 -0
  23. package/src/components/CodeBlock.tsx +68 -0
  24. package/src/components/CompletionOverlay.stories.tsx +23 -0
  25. package/src/components/CompletionOverlay.tsx +49 -0
  26. package/src/components/ComposeBar.stories.tsx +18 -0
  27. package/src/components/ComposeBar.tsx +54 -0
  28. package/src/components/ConfirmCard.stories.tsx +37 -0
  29. package/src/components/ConfirmCard.tsx +117 -0
  30. package/src/components/ConfirmDialog.stories.tsx +21 -0
  31. package/src/components/ConfirmDialog.tsx +56 -0
  32. package/src/components/DiffSheet.stories.tsx +44 -0
  33. package/src/components/DiffSheet.tsx +243 -0
  34. package/src/components/InboxList.stories.tsx +18 -0
  35. package/src/components/InboxList.tsx +55 -0
  36. package/src/components/PlanSheet.stories.tsx +81 -0
  37. package/src/components/PlanSheet.tsx +329 -0
  38. package/src/components/PromptDialog.stories.tsx +36 -0
  39. package/src/components/PromptDialog.tsx +62 -0
  40. package/src/components/ReviewPanel.stories.tsx +87 -0
  41. package/src/components/ReviewPanel.tsx +116 -0
  42. package/src/components/ReviewRail.stories.tsx +107 -0
  43. package/src/components/ReviewRail.tsx +180 -0
  44. package/src/components/SettingsDialog.stories.tsx +63 -0
  45. package/src/components/SettingsDialog.tsx +132 -0
  46. package/src/components/SettingsRows.stories.tsx +18 -0
  47. package/src/components/SettingsRows.tsx +92 -0
  48. package/src/components/WalkWizard.stories.tsx +89 -0
  49. package/src/components/WalkWizard.tsx +146 -0
  50. package/src/components/__snapshots__/stories.test.tsx.snap +1519 -0
  51. package/src/components/primitives/Button.stories.tsx +32 -0
  52. package/src/components/primitives/Button.tsx +44 -0
  53. package/src/components/primitives/Card.stories.tsx +25 -0
  54. package/src/components/primitives/Card.tsx +59 -0
  55. package/src/components/primitives/Dialog.stories.tsx +24 -0
  56. package/src/components/primitives/Dialog.tsx +54 -0
  57. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  58. package/src/components/primitives/StatusBar.tsx +22 -0
  59. package/src/components/primitives/Tabs.stories.tsx +29 -0
  60. package/src/components/primitives/Tabs.tsx +97 -0
  61. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  62. package/src/components/primitives/Toolbar.tsx +14 -0
  63. package/src/components/primitives/frame.test.ts +12 -0
  64. package/src/components/primitives/frame.ts +14 -0
  65. package/src/components/stories-app.tsx +95 -0
  66. package/src/components/stories.test.tsx +60 -0
  67. package/src/components/story-fixtures.ts +90 -0
  68. package/src/components/story.ts +66 -0
  69. package/src/components/syntax-highlight.ts +57 -0
  70. package/src/components/theme-context.stories.tsx +41 -0
  71. package/src/components/theme-context.tsx +30 -0
  72. package/src/components/truncate-text.ts +6 -0
  73. package/src/config.test.ts +196 -30
  74. package/src/config.ts +128 -16
  75. package/src/editor.test.ts +134 -0
  76. package/src/editor.ts +101 -13
  77. package/src/herdr.test.ts +41 -0
  78. package/src/herdr.ts +21 -0
  79. package/src/index.ts +2 -0
  80. package/src/integrations.test.ts +41 -0
  81. package/src/integrations.ts +28 -0
  82. package/src/intent-dispatch.test.ts +308 -0
  83. package/src/intent-dispatch.ts +295 -0
  84. package/src/key-bindings.test.ts +142 -0
  85. package/src/key-bindings.ts +363 -0
  86. package/src/keymap.test.ts +477 -0
  87. package/src/keymap.ts +272 -0
  88. package/src/review-panel.test.ts +111 -0
  89. package/src/review-panel.ts +76 -0
  90. package/src/run.ts +9 -5
  91. package/src/serve.test.ts +13 -6
  92. package/src/serve.ts +7 -7
  93. package/src/session-controller.share.test.ts +248 -0
  94. package/src/session-controller.ts +607 -0
  95. package/src/share.ts +80 -0
  96. package/src/test-support.ts +87 -0
  97. package/src/theme.ts +26 -3
  98. package/src/view-diff.test.ts +19 -8
  99. package/src/view-diff.ts +22 -22
  100. package/src/view-plan.test.ts +260 -0
  101. package/src/view-plan.ts +365 -0
  102. package/src/walk.test.ts +116 -0
  103. package/src/walk.ts +65 -0
  104. package/src/view.test.ts +0 -157
  105. package/src/view.ts +0 -0
@@ -0,0 +1,119 @@
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
+ // Arrange
61
+ const rendered = await frame();
62
+
63
+ // Assert
64
+ expect(rendered).toContain(" const blocked = full < 0.6;");
65
+ expect(rendered).toContain(' return <text>{blocked ? "blocked" : "clear"}</text>;');
66
+ });
67
+
68
+ test("the block carries its language tag", async () => {
69
+ // Arrange
70
+ const rendered = await frame();
71
+
72
+ // Assert
73
+ expect(rendered).toContain("tsx");
74
+ });
75
+ });
76
+
77
+ describe("block spacing", () => {
78
+ // the review panel's full-height divider paints a `│` on every row, so read
79
+ // the plan column (everything left of the divider) before asserting spacing
80
+ const planColumn = (line: string): string => line.split("│")[0]!.trimEnd();
81
+
82
+ test("a code block never glues to the list item above it", async () => {
83
+ // Arrange
84
+ const lines = (await frame()).split("\n").map(planColumn);
85
+ const listItemLineIndex = lines.findIndex((line) => line.includes("wire the duck over MQTT"));
86
+ const codeHeader = lines.findIndex((line) => line.trim().startsWith("tsx"));
87
+
88
+ // Assert
89
+ expect(listItemLineIndex).toBeGreaterThan(-1);
90
+ expect(codeHeader).toBeGreaterThan(listItemLineIndex);
91
+ expect(lines.slice(listItemLineIndex + 1, codeHeader).some((line) => line === "")).toBe(true);
92
+ });
93
+
94
+ test("a heading never sits directly on the previous block", async () => {
95
+ // Arrange
96
+ const lines = (await frame()).split("\n").map(planColumn);
97
+ const gate = lines.findIndex((line) => line.trim() === "Gate");
98
+
99
+ // Assert
100
+ expect(gate).toBeGreaterThan(0);
101
+ expect(lines[gate - 1]).toBe("");
102
+ });
103
+
104
+ test("consecutive list items stay tight", async () => {
105
+ // Arrange
106
+ const listPlan = "# T\n\n- one\n- two\n";
107
+ const s2 = server.core.sessionCreate({
108
+ workspace: { repoRoot: "/repo", branch: "main" },
109
+ artifact: { type: "plan", content: listPlan, meta: {} },
110
+ });
111
+ const setup = await testRender(<App home={home} sessionId={s2.id} />, { width: 120, height: 30 });
112
+ await waitForText(setup, "one");
113
+
114
+ // Assert
115
+ const lines = setup.captureCharFrame().split("\n").map((line) => line.trimEnd());
116
+ const one = lines.findIndex((line) => line.includes("- one"));
117
+ expect(lines[one + 1]).toContain("- two");
118
+ });
119
+ });
@@ -0,0 +1,123 @@
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, readFileSync, 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 { press, 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
+
22
+ beforeEach(() => {
23
+ home = mkdtempSync(join(tmpdir(), "cueloop-panel-"));
24
+ configPath = join(home, "config.toml");
25
+ process.env.CUELOOP_CONFIG = configPath;
26
+ server = new DaemonServer({ home, idleExitMs: 0 });
27
+ server.start();
28
+ session = server.core.sessionCreate({
29
+ workspace: { repoRoot: "/repo", branch: "main" },
30
+ artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
31
+ });
32
+ });
33
+ afterEach(() => {
34
+ delete process.env.CUELOOP_CONFIG;
35
+ server.stop();
36
+ rmSync(home, { recursive: true, force: true });
37
+ });
38
+
39
+ async function renderApp() {
40
+ const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 30 });
41
+ await waitForText(setup, "Review");
42
+ return setup;
43
+ }
44
+
45
+ describe("review panel", () => {
46
+ test("b cycles expanded -> compact -> hidden -> expanded", async () => {
47
+ // Arrange
48
+ const setup = await renderApp();
49
+
50
+ // Assert
51
+ expect(setup.captureCharFrame()).toContain("Submit review"); // expanded rail
52
+
53
+ // Act
54
+ await press(setup, "b");
55
+
56
+ // Assert
57
+ await waitForText(setup, "«");
58
+ let frame = setup.captureCharFrame();
59
+ expect(frame).toContain("«"); // compact strip shows the expand chevron
60
+ expect(frame).not.toContain("Submit review");
61
+
62
+ // Act
63
+ await press(setup, "b");
64
+
65
+ // Assert
66
+ frame = await waitForTextGone(setup, "«");
67
+ expect(frame).not.toContain("«"); // hidden: no strip, no divider
68
+ expect(frame).not.toContain("│");
69
+
70
+ // Act
71
+ await press(setup, "b");
72
+
73
+ // Assert
74
+ await waitForText(setup, "Submit review"); // back to expanded
75
+ });
76
+
77
+ test("the collapse mode persists to the user config", async () => {
78
+ // Arrange
79
+ const setup = await renderApp();
80
+
81
+ // Act
82
+ await press(setup, "b"); // -> compact
83
+
84
+ // Assert
85
+ expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("compact");
86
+ });
87
+
88
+ test("] widens the rail and persists the new width", async () => {
89
+ // Arrange
90
+ const setup = await renderApp();
91
+ const startWidth = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
92
+
93
+ // Act
94
+ await press(setup, "]");
95
+
96
+ // Assert
97
+ const widened = loadConfig({ userConfigPath: configPath }).ui.reviewWidth;
98
+ expect(widened).toBeGreaterThan(startWidth);
99
+ });
100
+
101
+ test("submitting from a hidden panel force-opens the rail so the confirm card is reachable", async () => {
102
+ // Arrange
103
+ const setup = await renderApp();
104
+ await press(setup, "b"); // -> compact
105
+ await press(setup, "b"); // -> hidden
106
+
107
+ // Assert
108
+ await waitForTextGone(setup, "Submit review"); // rail is gone
109
+ expect(setup.captureCharFrame()).not.toContain("Submit review");
110
+
111
+ // Act
112
+ await press(setup, "enter"); // submit while hidden must not trap in an invisible modal
113
+
114
+ // Assert
115
+ await waitForText(setup, "submit review");
116
+ const frame = setup.captureCharFrame();
117
+ expect(frame).toContain("submit review"); // the confirm card is visible again
118
+ expect(frame).toContain("[Approve]");
119
+
120
+ // the reveal is live-only: the saved panel preference stays as the user left it
121
+ expect(loadConfig({ userConfigPath: configPath }).ui.reviewState).toBe("hidden");
122
+ });
123
+ });
@@ -0,0 +1,216 @@
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 { 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 server: DaemonServer;
28
+ let session: ReviewSession;
29
+
30
+ beforeEach(() => {
31
+ home = mkdtempSync(join(tmpdir(), "cueloop-confirm-"));
32
+ server = new DaemonServer({ home, idleExitMs: 0 });
33
+ server.start();
34
+ session = server.core.sessionCreate({
35
+ workspace: { repoRoot: "/repo", branch: "main" },
36
+ artifact: { type: "plan", content: PLAN, meta: { title: "Migration Plan", planPath: "plan.md" } },
37
+ });
38
+ });
39
+ afterEach(() => {
40
+ server.stop();
41
+ rmSync(home, { recursive: true, force: true });
42
+ });
43
+
44
+ async function renderApp(options: { readOnly?: boolean } = {}) {
45
+ const setup = await testRender(<App home={home} sessionId={session.id} readOnly={options.readOnly ?? false} />, {
46
+ width: 120,
47
+ height: 32,
48
+ });
49
+ await waitForText(setup, "cueloop");
50
+ return setup;
51
+ }
52
+
53
+ type Setup = Awaited<ReturnType<typeof renderApp>>;
54
+
55
+ /** Seed annotations directly through the daemon core (all on one block). */
56
+ function seedAnnotations(count: number): void {
57
+ const blocks = parseBlocks(PLAN);
58
+ for (let index = 1; index <= count; index++) {
59
+ server.core.sessionAnnotate(session.id, {
60
+ id: `a_confirm_${index}`,
61
+ kind: "comment",
62
+ anchor: makeAnchor(blocks, 2, 0, 10),
63
+ body: `note ${String(index).padStart(2, "0")}`,
64
+ });
65
+ }
66
+ }
67
+
68
+ describe("rail submit confirm", () => {
69
+ test("submit expands the Submit button into the confirm card", async () => {
70
+ // Arrange
71
+ const setup = await renderApp();
72
+
73
+ // Assert
74
+ expect(setup.captureCharFrame()).toContain("Submit review (0)");
75
+
76
+ // Act
77
+ await press(setup, "enter");
78
+
79
+ // Assert
80
+ await waitForText(setup, "submit review");
81
+ const frame = setup.captureCharFrame();
82
+ // the card: title, honest counts, verdict selector, word-buttons
83
+ expect(frame).toContain("submit review");
84
+ expect(frame).toContain("0 annotations · 0 blocking");
85
+ expect(frame).toContain("[Approve]"); // nothing pending: approve default
86
+ expect(frame).toContain(" Submit ");
87
+ expect(frame).toContain(" Cancel ");
88
+ // key hints live in the status line, not on the buttons
89
+ expect(frame).toContain("verdict ←/→ · ⏎ submit · esc cancel");
90
+ // the bottom bar stayed a one-line hint: no detached verdict bar
91
+ expect(frame).not.toContain("Submit review (0) on ⏎");
92
+ });
93
+
94
+ test("left/right cycles the verdict selector in the card", async () => {
95
+ // Arrange
96
+ const setup = await renderApp();
97
+ await press(setup, "enter");
98
+
99
+ // Assert
100
+ await waitForText(setup, "[Approve]");
101
+
102
+ // Act
103
+ await press(setup, "right");
104
+
105
+ // Assert
106
+ await waitForText(setup, "[Changes]");
107
+
108
+ // Act
109
+ await press(setup, "right");
110
+
111
+ // Assert
112
+ await waitForText(setup, "[Comment]");
113
+
114
+ // Act
115
+ await press(setup, "left");
116
+
117
+ // Assert
118
+ await waitForText(setup, "[Changes]");
119
+ });
120
+
121
+ test("esc cancels the card and restores the plain Submit button", async () => {
122
+ // Arrange
123
+ const setup = await renderApp();
124
+ await press(setup, "enter");
125
+
126
+ // Assert
127
+ await waitForText(setup, "[Approve]");
128
+
129
+ // Act
130
+ await press(setup, "escape");
131
+
132
+ // Assert
133
+ // a bare ESC settles after the parser's escape-sequence window
134
+ const frame = await waitForTextGone(setup, "[Approve]");
135
+ expect(frame).not.toContain("0 annotations");
136
+ expect(frame).toContain("Submit review (0)");
137
+ });
138
+
139
+ test("enter in the card resolves the session through the controller", async () => {
140
+ // Arrange
141
+ seedAnnotations(1);
142
+ const setup = await renderApp();
143
+
144
+ // Act
145
+ await press(setup, "enter");
146
+
147
+ // Assert
148
+ await waitForText(setup, "1 annotations · 0 blocking");
149
+ const frame = setup.captureCharFrame();
150
+ expect(frame).toContain("1 annotations · 0 blocking");
151
+ expect(frame).toContain("[Changes]"); // pending items: request changes default
152
+
153
+ // Act
154
+ await setup.mockInput.typeText("Tighten the steps.");
155
+ await press(setup, "enter");
156
+
157
+ // Assert
158
+ // the completion flow after submit is unchanged
159
+ await waitForText(setup, "feedback sent");
160
+ const stored = server.core.sessionGet(session.id);
161
+ expect(stored.status).toBe("resolved");
162
+ expect(stored.verdict!.kind).toBe("request_changes");
163
+ });
164
+
165
+ test("the annotation stack stays scrollable while the card is open", async () => {
166
+ // Arrange
167
+ seedAnnotations(12);
168
+ const setup = await renderApp();
169
+
170
+ // Act
171
+ // walk annotation focus to the last card: the rail scrolls it into view
172
+ for (let index = 0; index < 12; index++) await press(setup, "n");
173
+
174
+ // Assert
175
+ await waitForText(setup, "note 12");
176
+ expect(setup.captureCharFrame()).not.toContain("note 01");
177
+
178
+ // Act
179
+ await press(setup, "enter");
180
+
181
+ // Assert
182
+ await waitForText(setup, "12 annotations · 0 blocking");
183
+ // the card is pinned outside the scrollbox; the stack stays scrolled off the top
184
+ expect(setup.captureCharFrame()).not.toContain("note 01");
185
+
186
+ // Act
187
+ // the stack still scrolls with the card open: wheel over the rail
188
+ // brings the last card back into view while the confirm card stays put
189
+ for (let turn = 0; turn < 12; turn++) await setup.mockMouse.scroll(100, 10, "down");
190
+ await settle(setup);
191
+
192
+ // Assert
193
+ await waitForText(setup, "note 12");
194
+ const scrolledWithCard = setup.captureCharFrame();
195
+ expect(scrolledWithCard).toContain("12 annotations · 0 blocking");
196
+ expect(scrolledWithCard).not.toContain("note 01");
197
+ });
198
+
199
+ test("read-only observers never see the confirm card", async () => {
200
+ // Arrange
201
+ seedAnnotations(1);
202
+ const setup = await renderApp({ readOnly: true });
203
+
204
+ // Act
205
+ await press(setup, "enter");
206
+
207
+ // Assert
208
+ await waitForText(setup, "observer - read-only");
209
+ const frame = setup.captureCharFrame();
210
+ expect(frame).toContain("observer - read-only");
211
+ expect(frame).not.toContain("1 annotations");
212
+ expect(frame).not.toContain("[Changes]");
213
+ expect(setup.captureCharFrame()).not.toContain("verdict ←/→");
214
+ expect(server.core.sessionGet(session.id).status).toBe("pending");
215
+ });
216
+ });