@cueloop/client 0.1.0-alpha.76 → 0.1.0-alpha.78

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 (82) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +3 -3
  3. package/src/App.diff.test.tsx +14 -2
  4. package/src/App.export.test.tsx +1 -2
  5. package/src/App.identity.test.tsx +78 -0
  6. package/src/App.plan-review.test.tsx +16 -0
  7. package/src/App.share-choice.test.tsx +105 -0
  8. package/src/App.submit-confirm.test.tsx +34 -5
  9. package/src/App.test.tsx +2 -2
  10. package/src/App.toast.test.tsx +3 -1
  11. package/src/App.tsx +180 -5
  12. package/src/App.walk.test.tsx +2 -2
  13. package/src/__snapshots__/view-plan.regression.test.ts.snap +1 -1
  14. package/src/ansi-screen.test.ts +28 -0
  15. package/src/ansi-screen.ts +72 -0
  16. package/src/app-screens.tsx +20 -3
  17. package/src/app-view-model.ts +4 -0
  18. package/src/components/AnnotatableFileView.tsx +10 -2
  19. package/src/components/AnnotationCards.tsx +40 -6
  20. package/src/components/AnsiScreen.stories.tsx +12 -0
  21. package/src/components/AnsiScreen.tsx +28 -0
  22. package/src/components/CompletionOverlay.stories.tsx +15 -1
  23. package/src/components/CompletionOverlay.tsx +22 -5
  24. package/src/components/ConfirmCard.stories.tsx +2 -0
  25. package/src/components/ConfirmCard.tsx +45 -18
  26. package/src/components/ConfirmDialog.tsx +11 -12
  27. package/src/components/DiffContentView.tsx +9 -5
  28. package/src/components/FileContentsView.tsx +1 -1
  29. package/src/components/GridTabContent.tsx +2 -0
  30. package/src/components/ManageAccessDialog.stories.tsx +36 -0
  31. package/src/components/ManageAccessDialog.test.tsx +94 -0
  32. package/src/components/ManageAccessDialog.tsx +122 -0
  33. package/src/components/PromptDialog.test.tsx +2 -2
  34. package/src/components/PromptDialog.tsx +9 -10
  35. package/src/components/ShareChoiceDialog.stories.tsx +34 -0
  36. package/src/components/ShareChoiceDialog.test.tsx +103 -0
  37. package/src/components/ShareChoiceDialog.tsx +80 -0
  38. package/src/components/SlashComposer.stories.tsx +37 -0
  39. package/src/components/SlashComposer.tsx +87 -0
  40. package/src/components/ThreadTree.tsx +11 -6
  41. package/src/components/ThreadView.selection.test.tsx +88 -1
  42. package/src/components/ThreadView.slash.test.tsx +0 -1
  43. package/src/components/ThreadView.tsx +71 -8
  44. package/src/components/Toast.tsx +0 -5
  45. package/src/components/WalkWizard.stories.tsx +2 -2
  46. package/src/components/WalkWizard.tsx +10 -12
  47. package/src/components/__snapshots__/stories.test.tsx.snap +277 -61
  48. package/src/components/primitives/Card.tsx +3 -0
  49. package/src/components/primitives/DialogActions.stories.tsx +20 -0
  50. package/src/components/primitives/DialogActions.tsx +39 -0
  51. package/src/components/session-tree.ts +7 -6
  52. package/src/components/stories-app.tsx +13 -4
  53. package/src/components/stories.test.tsx +8 -1
  54. package/src/components/story-tree.test.ts +32 -24
  55. package/src/components/story.ts +32 -15
  56. package/src/config-identity.test.ts +78 -0
  57. package/src/config.ts +44 -1
  58. package/src/diff-intraline.test.ts +75 -2
  59. package/src/diff-intraline.ts +95 -26
  60. package/src/github-identity.test.ts +58 -0
  61. package/src/github-identity.ts +41 -0
  62. package/src/intent-dispatch.test.ts +17 -2
  63. package/src/intent-dispatch.ts +7 -1
  64. package/src/keymap.test.ts +2 -2
  65. package/src/keymap.ts +1 -1
  66. package/src/pasted-image.test.ts +67 -0
  67. package/src/pasted-image.ts +60 -0
  68. package/src/run.ts +31 -19
  69. package/src/serve.ts +1 -1
  70. package/src/share.ts +11 -3
  71. package/src/slash-palette.test.ts +17 -0
  72. package/src/slash-palette.ts +11 -0
  73. package/src/thread-controller.curate.test.ts +1 -0
  74. package/src/thread-controller.derivation.test.ts +62 -0
  75. package/src/thread-controller.live-diff.test.ts +1 -0
  76. package/src/thread-controller.optimistic.test.ts +69 -0
  77. package/src/thread-controller.share.test.ts +36 -2
  78. package/src/thread-controller.switch-cache.test.ts +146 -0
  79. package/src/thread-controller.tree.test.ts +1 -0
  80. package/src/thread-controller.ts +171 -24
  81. package/src/use-annotation-surface.tsx +53 -29
  82. package/src/use-settings-dialog.tsx +24 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cueloop/client",
3
- "version": "0.1.0-alpha.76",
3
+ "version": "0.1.0-alpha.78",
4
4
  "description": "The cueloop review UI: projection renderer, keyboard grammar, review rail",
5
5
  "homepage": "https://github.com/mmurakaru/cueloop#readme",
6
6
  "bugs": {
@@ -27,10 +27,10 @@
27
27
  "access": "public"
28
28
  },
29
29
  "dependencies": {
30
- "@cueloop/daemon": "0.1.0-alpha.76",
31
- "@cueloop/extension-api": "0.1.0-alpha.76",
32
- "@cueloop/integration-obsidian": "0.1.0-alpha.76",
33
- "@cueloop/schema": "0.1.0-alpha.76",
30
+ "@cueloop/daemon": "0.1.0-alpha.78",
31
+ "@cueloop/extension-api": "0.1.0-alpha.78",
32
+ "@cueloop/integration-obsidian": "0.1.0-alpha.78",
33
+ "@cueloop/schema": "0.1.0-alpha.78",
34
34
  "@opentui/core": "^0.5.8",
35
35
  "@opentui/keymap": "0.5.8",
36
36
  "@opentui/react": "^0.5.8",
@@ -57,7 +57,7 @@ type Setup = Awaited<ReturnType<typeof renderApp>>;
57
57
 
58
58
  async function submitApprove(setup: Setup): Promise<void> {
59
59
  await pressKey(setup, "RETURN", { meta: true }); // open submit (approve default: no pending items)
60
- await press(setup, "enter"); // confirm
60
+ await pressKey(setup, "RETURN", { meta: true }); // confirm
61
61
  await waitForText(setup, "review approved");
62
62
  }
63
63
 
@@ -74,8 +74,8 @@ describe("completion overlay", () => {
74
74
  const frame = await waitForText(setup, "closing in 5s");
75
75
 
76
76
  expect(frame).toContain("review approved");
77
- expect(frame).toContain("close [return]");
78
- expect(frame).toContain("return to plan [esc]");
77
+ expect(frame).toContain("close");
78
+ expect(frame).toContain("back to plan");
79
79
 
80
80
  // Act
81
81
  await press(setup, "enter");
@@ -13,7 +13,6 @@ import {
13
13
  clickText,
14
14
  dragText,
15
15
  isolateUserConfig,
16
- press,
17
16
  pressKey,
18
17
  renderReadyApp,
19
18
  typeText,
@@ -95,7 +94,7 @@ describe("diff review", () => {
95
94
 
96
95
  // Act - submit with the session chord (cmd+enter, no composer open), confirm request_changes
97
96
  await pressKey(setup, "RETURN", { meta: true });
98
- await press(setup, "enter");
97
+ await pressKey(setup, "RETURN", { meta: true });
99
98
 
100
99
  // Assert
101
100
  await waitForText(setup, "feedback sent");
@@ -105,6 +104,19 @@ describe("diff review", () => {
105
104
  expect(resolved.verdict!.feedback).toContain("Map needs an eviction story.");
106
105
  });
107
106
 
107
+ test("pasting an image into a diff comment drops in an [Image #n] placeholder", async () => {
108
+ // Arrange - mark the added line and open the inline comment composer
109
+ const setup = await renderApp();
110
+ await dragText(setup, "new Map()", "new Map()", "new Map()".length);
111
+ await typeText(setup, "see ");
112
+
113
+ // Act - a burst of control bytes stands in for the binary an image paste delivers
114
+ await setup.mockInput.pasteBracketedText("");
115
+
116
+ // Assert - the same placeholder the summary composer shows
117
+ await waitForText(setup, "see [Image #1]");
118
+ });
119
+
108
120
  test("a comment reopens with its mark painted on the code", async () => {
109
121
  // Arrange - a stored comment anchored to the added line's words
110
122
  server.core.sessionAnnotate(session.id, {
@@ -18,7 +18,6 @@ import type { Thread } from "@cueloop/schema";
18
18
  import { App } from "./App";
19
19
  import {
20
20
  isolateUserConfig,
21
- press,
22
21
  waitForState,
23
22
  waitForText,
24
23
  pressKey,
@@ -81,7 +80,7 @@ describe("obsidian export on resolve", () => {
81
80
  await waitForText(setup, "[Approve]");
82
81
 
83
82
  // Act
84
- await press(setup, "enter"); // submit
83
+ await pressKey(setup, "RETURN", { meta: true }); // submit
85
84
 
86
85
  // Assert
87
86
  // the export is an async round-trip after resolve; the status line lands
@@ -0,0 +1,78 @@
1
+ /** Reviewer identity surfaces: the Account settings tab and the author-name tooltip on a comment dot. */
2
+
3
+ import { afterEach, beforeEach, 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 { DaemonServer } from "@cueloop/daemon";
9
+ import { App } from "./App";
10
+ import { isolateUserConfig, locateText, renderReadyApp, waitForText } from "./test-support";
11
+
12
+ const PLAN = "# Migration Plan\n\nThe daemon persists sessions to disk atomically.\n";
13
+
14
+ let home: string;
15
+ let restoreUserConfig: () => void;
16
+ let server: DaemonServer;
17
+ let sessionId: string;
18
+
19
+ beforeEach(() => {
20
+ home = mkdtempSync(join(tmpdir(), "cueloop-identity-"));
21
+ restoreUserConfig = isolateUserConfig(home);
22
+ server = new DaemonServer({ home, idleExitMs: 0 });
23
+ server.start();
24
+ const session = server.core.sessionCreate({
25
+ workspace: { repoRoot: "/repo", branch: "main" },
26
+ artifact: {
27
+ type: "plan",
28
+ content: PLAN,
29
+ meta: { title: "Migration Plan", planPath: "plan.md" },
30
+ },
31
+ });
32
+ sessionId = session.id;
33
+ });
34
+ afterEach(() => {
35
+ restoreUserConfig();
36
+ server.stop();
37
+ rmSync(home, { recursive: true, force: true });
38
+ });
39
+
40
+ async function renderApp() {
41
+ return renderReadyApp(<App home={home} sessionId={sessionId} />, { width: 120, height: 32 });
42
+ }
43
+
44
+ test("the Account settings tab shows the display name row and the GitHub sync action", async () => {
45
+ const setup = await renderApp();
46
+
47
+ await setup.mockMouse.click(1, 0);
48
+ await waitForText(setup, "Settings");
49
+ const menu = setup.captureCharFrame().split("\n");
50
+ const settingsRow = menu.findIndex((line) => line.includes("Settings"));
51
+
52
+ await setup.mockMouse.click(menu[settingsRow]!.indexOf("Settings") + 1, settingsRow);
53
+ await waitForText(setup, "Account");
54
+ const dialog = setup.captureCharFrame().split("\n");
55
+ const accountRow = dialog.findIndex((line) => line.includes("Account"));
56
+
57
+ await setup.mockMouse.click(dialog[accountRow]!.indexOf("Account") + 1, accountRow);
58
+ await waitForText(setup, "Display name");
59
+ expect(setup.captureCharFrame()).toContain("Sync from GitHub");
60
+ });
61
+
62
+ test("hovering an own comment's dot surfaces the author tooltip", async () => {
63
+ server.core.sessionAnnotate(sessionId, {
64
+ id: "a_own",
65
+ kind: "comment",
66
+ anchor: { quote: "atomically", prefix: "sessions to disk ", suffix: "." },
67
+ body: "My note.",
68
+ });
69
+ const setup = await renderApp();
70
+
71
+ await waitForText(setup, "● My note.");
72
+ // the tooltip is not painted until the pointer is over the dot
73
+ expect(setup.captureCharFrame()).not.toContain("you");
74
+ const dot = locateText(setup, "● My note.");
75
+
76
+ await setup.mockMouse.moveTo(dot.column, dot.row);
77
+ await waitForText(setup, "you");
78
+ });
@@ -228,6 +228,22 @@ describe("the mark stays painted while composing", () => {
228
228
  await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
229
229
  await waitForState(setup, () => backgroundsOf(setup, "The daemon").includes(THREAD_MARK));
230
230
  }, 60_000);
231
+
232
+ test("backspace on an empty draft dismisses the composer back to the mark", async () => {
233
+ // Arrange
234
+ const setup = await renderApp();
235
+
236
+ // Act: mark, type one character, then delete it and backspace again on the now-empty draft
237
+ await dragText(setup, "The daemon", "daemon persists", "daemon".length);
238
+ await type(setup, "x");
239
+ await waitForText(setup, "● x");
240
+ await press(setup, "backspace");
241
+ await press(setup, "backspace");
242
+
243
+ // Assert: the composer is gone and the mark stays, ready to re-type
244
+ await waitForTextGone(setup, "● x");
245
+ expect(backgroundsOf(setup, "The daemon")).toContain(THREAD_MARK);
246
+ }, 60_000);
231
247
  });
232
248
 
233
249
  describe("compose newline convention", () => {
@@ -0,0 +1,105 @@
1
+ /** The share shortcut opens an app-level public/private choice reachable from any view. */
2
+
3
+ import { afterEach, beforeEach, describe, mock, 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 { Thread } from "@cueloop/schema";
11
+ import { App } from "./App";
12
+ import type { ShareTransport } from "./thread-controller";
13
+ import { isolateUserConfig, press, pressKey, waitForText, waitForTextGone } from "./test-support";
14
+
15
+ const shareTransport: ShareTransport = {
16
+ publish: mock(async () => ({ line: "ssh p_share01@cueloop.dev", copied: true })),
17
+ pull: mock(async () => {
18
+ throw new Error("Unexpected share pull");
19
+ }),
20
+ push: mock(async () => {}),
21
+ watch: () => () => {},
22
+ parseShareId: (line) => line.match(/^ssh (\S+)@/)?.[1],
23
+ collaboratorAnnotations: () => [],
24
+ mergeFromShare: () => ({ annotations: [] }),
25
+ };
26
+
27
+ const PLAN = `# Migration Plan\n\n## Context\n\nThe daemon persists sessions to disk atomically.\n`;
28
+
29
+ let home: string;
30
+ let restoreUserConfig: () => void;
31
+ let server: DaemonServer;
32
+ let session: Thread;
33
+
34
+ beforeEach(() => {
35
+ home = mkdtempSync(join(tmpdir(), "cueloop-share-choice-"));
36
+ restoreUserConfig = isolateUserConfig(home);
37
+ server = new DaemonServer({ home, idleExitMs: 0 });
38
+ server.start();
39
+ session = server.core.sessionCreate({
40
+ workspace: { repoRoot: "/repo", branch: "main" },
41
+ artifact: {
42
+ type: "plan",
43
+ content: PLAN,
44
+ meta: { title: "Migration Plan", planPath: "plan.md" },
45
+ },
46
+ });
47
+ });
48
+ afterEach(() => {
49
+ restoreUserConfig();
50
+ server.stop();
51
+ rmSync(home, { recursive: true, force: true });
52
+ });
53
+
54
+ describe("share choice", () => {
55
+ test("the shortcut opens the public/private choice", async () => {
56
+ // Arrange
57
+ const setup = await testRender(
58
+ <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
59
+ { width: 120, height: 32 },
60
+ );
61
+ await waitForText(setup, "cueloop");
62
+
63
+ // Act
64
+ await pressKey(setup, "s", { ctrl: true });
65
+
66
+ // Assert
67
+ await waitForText(setup, "public link");
68
+ await waitForText(setup, "private link");
69
+ });
70
+
71
+ test("the private choice opens the manage-access surface", async () => {
72
+ // Arrange
73
+ const setup = await testRender(
74
+ <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
75
+ { width: 120, height: 32 },
76
+ );
77
+ await waitForText(setup, "cueloop");
78
+
79
+ // Act: open the choice, move to private, select it
80
+ await pressKey(setup, "s", { ctrl: true });
81
+ await waitForText(setup, "private link");
82
+ await press(setup, "down");
83
+ await press(setup, "enter");
84
+
85
+ // Assert
86
+ await waitForText(setup, "Manage access");
87
+ });
88
+
89
+ test("escape cancels the choice without sharing", async () => {
90
+ // Arrange
91
+ const setup = await testRender(
92
+ <App home={home} sessionId={session.id} shareTransport={shareTransport} />,
93
+ { width: 120, height: 32 },
94
+ );
95
+ await waitForText(setup, "cueloop");
96
+
97
+ // Act
98
+ await pressKey(setup, "s", { ctrl: true });
99
+ await waitForText(setup, "public link");
100
+ await press(setup, "escape");
101
+
102
+ // Assert
103
+ await waitForTextGone(setup, "public link");
104
+ });
105
+ });
@@ -99,8 +99,8 @@ describe("send message confirm", () => {
99
99
  const frame = setup.captureCharFrame();
100
100
 
101
101
  expect(frame).toContain("[Approve]");
102
- expect(frame).toContain(" send message ");
103
- expect(frame).toContain(" Cancel ");
102
+ expect(frame).toContain(" send ");
103
+ expect(frame).toContain(" cancel ");
104
104
  });
105
105
 
106
106
  test("left/right cycles the verdict selector in the overlay", async () => {
@@ -146,7 +146,7 @@ describe("send message confirm", () => {
146
146
  // Assert - a bare ESC settles after the parser's escape-sequence window
147
147
  const frame = await waitForTextGone(setup, "[Approve]");
148
148
 
149
- expect(frame).not.toContain(" Cancel ");
149
+ expect(frame).not.toContain(" cancel ");
150
150
  });
151
151
 
152
152
  test("enter in the overlay resolves the session through the controller", async () => {
@@ -162,7 +162,7 @@ describe("send message confirm", () => {
162
162
 
163
163
  // Act
164
164
  await setup.mockInput.typeText("Tighten the steps.");
165
- await press(setup, "enter");
165
+ await pressKey(setup, "RETURN", { meta: true });
166
166
 
167
167
  // Assert - the completion flow after submit is unchanged
168
168
  await waitForText(setup, "feedback sent");
@@ -172,6 +172,35 @@ describe("send message confirm", () => {
172
172
  expect(stored.verdict!.kind).toBe("request_changes");
173
173
  });
174
174
 
175
+ test("typing / in the summary opens the skills/actions palette", async () => {
176
+ // Arrange
177
+ seedAnnotations(1);
178
+ const setup = await renderApp();
179
+ await pressKey(setup, "RETURN", { meta: true });
180
+ await waitForText(setup, "[Changes]");
181
+
182
+ // Act
183
+ await setup.mockInput.typeText("/restate");
184
+
185
+ // Assert - the same slash palette the inline composer shows
186
+ await waitForText(setup, "restate-simplified");
187
+ });
188
+
189
+ test("pasting an image into the summary drops in an [Image #n] placeholder", async () => {
190
+ // Arrange
191
+ const setup = await renderApp();
192
+ await pressKey(setup, "RETURN", { meta: true });
193
+ await waitForText(setup, "[Approve]");
194
+
195
+ // Act - a burst of control bytes stands in for the binary an image paste delivers
196
+ await setup.mockInput.typeText("look at this ");
197
+ await setup.mockInput.pasteBracketedText("");
198
+
199
+ // Assert - the raw bytes never reach the draft; a numbered placeholder does
200
+ await waitForText(setup, "[Image #1]");
201
+ expect(setup.captureCharFrame()).toContain("look at this [Image #1]");
202
+ });
203
+
175
204
  test("read-only observers cannot open the confirm overlay", async () => {
176
205
  // Arrange
177
206
  seedAnnotations(1);
@@ -185,7 +214,7 @@ describe("send message confirm", () => {
185
214
  const frame = setup.captureCharFrame();
186
215
 
187
216
  expect(frame).not.toContain("[Changes]");
188
- expect(frame).not.toContain(" Cancel ");
217
+ expect(frame).not.toContain(" cancel ");
189
218
  expect(server.core.sessionGet(session.id).status).toBe("pending");
190
219
  });
191
220
  });
package/src/App.test.tsx CHANGED
@@ -207,7 +207,7 @@ describe("submit", () => {
207
207
 
208
208
  // Act
209
209
  await type(setup, "Expand the steps.");
210
- await press(setup, "enter");
210
+ await pressKey(setup, "RETURN", { meta: true });
211
211
 
212
212
  // Assert
213
213
  await waitForText(setup, "feedback sent");
@@ -231,7 +231,7 @@ describe("submit", () => {
231
231
  await waitForText(setup, "[Approve]"); // no pending items → approve default
232
232
 
233
233
  // Act
234
- await press(setup, "enter");
234
+ await pressKey(setup, "RETURN", { meta: true });
235
235
 
236
236
  // Assert
237
237
  await waitForState(setup, () => server.core.sessionGet(session.id).verdict !== undefined);
@@ -73,8 +73,10 @@ describe("share toast", () => {
73
73
 
74
74
  await waitForText(setup, "cueloop");
75
75
 
76
- // Act: share raises the toast, then open a composer under it by typing
76
+ // Act: share opens the choice, enter picks public and raises the toast, then a composer under it
77
77
  await pressKey(setup, "s", { ctrl: true });
78
+ await waitForText(setup, "public link");
79
+ await press(setup, "enter");
78
80
  await waitForText(setup, "share link copied");
79
81
  await clickText(setup, "daemon");
80
82
  await typeText(setup, "x");