@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
package/src/App.test.tsx CHANGED
@@ -1,8 +1,4 @@
1
- /**
2
- * Virtual-terminal component tests (tier 2): the real App over a real
3
- * in-process daemon in a temp home. Char-frame assertions + mock keys -
4
- * the whole review loop drivable without a terminal.
5
- */
1
+ /** Virtual-terminal component tests (tier 2): the real App over a real in-process daemon in a temp home. Char-frame assertions + mock keys - the whole review loop drivable without a terminal. */
6
2
 
7
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
4
  import { mkdtempSync, rmSync } from "node:fs";
@@ -13,6 +9,7 @@ import { testRender } from "@opentui/react/test-utils";
13
9
  import { DaemonServer } from "@cueloop/daemon";
14
10
  import type { ReviewSession } from "@cueloop/schema";
15
11
  import { App } from "./App";
12
+ import { press, typeText as type, waitForState, waitForText } from "./test-support";
16
13
 
17
14
  const PLAN = `# Migration Plan
18
15
 
@@ -49,78 +46,72 @@ async function renderApp(sessionId?: string) {
49
46
  width: 120,
50
47
  height: 32,
51
48
  });
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();
49
+ // the async daemon connect + first fetch land within the frame wait
50
+ await waitForText(setup, "cueloop");
58
51
  return setup;
59
52
  }
60
53
 
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
54
  describe("plan rendering", () => {
82
55
  test("renders the plan with headings, list markers, and the rail", async () => {
56
+ // Arrange
83
57
  const setup = await renderApp();
58
+
59
+ // Assert
84
60
  const frame = setup.captureCharFrame();
85
61
  expect(frame).toContain("Migration Plan");
86
62
  expect(frame).toContain("Context");
87
63
  expect(frame).toContain("persists sessions to disk atomically");
88
64
  expect(frame).toContain("- move the store");
89
- expect(frame).toContain("REVIEW (0)");
65
+ expect(frame).toContain("Review (0)");
90
66
  expect(frame).toContain("Submit review (0)");
91
67
  });
92
68
  });
93
69
 
94
70
  describe("keyboard grammar", () => {
95
71
  test("j/k moves the cursor glyph between blocks", async () => {
72
+ // Arrange
96
73
  const setup = await renderApp();
74
+
75
+ // Act
97
76
  await press(setup, "j");
98
77
  await press(setup, "j");
99
78
  await setup.renderOnce();
79
+
80
+ // Assert
100
81
  const lines = setup.captureCharFrame().split("\n");
101
- const cursorLine = lines.find((l) => l.includes("▎"))!;
82
+ const cursorLine = lines.find((line) => line.includes("▎"))!;
102
83
  expect(cursorLine).toContain("persists sessions");
103
84
  });
104
85
 
105
86
  test("comment flow: c types a body, ⏎ saves to the daemon", async () => {
87
+ // Arrange
106
88
  const setup = await renderApp();
89
+
90
+ // Act
107
91
  await press(setup, "j");
108
92
  await press(setup, "j");
109
93
  await press(setup, "c");
110
94
  await setup.renderOnce();
111
- expect(setup.captureCharFrame()).toContain("COMMENT ON");
95
+
96
+ // Assert
97
+ await waitForText(setup, 'comment on "The daemon');
98
+
99
+ // Act
112
100
  await type(setup, "Define atomically.");
113
101
  await press(setup, "enter");
114
- await Bun.sleep(80);
115
- await setup.renderOnce();
102
+
103
+ // Assert
104
+ await waitForText(setup, "COMMENT");
116
105
  const stored = server.core.sessionGet(session.id);
117
106
  expect(stored.annotations.length).toBe(1);
118
107
  expect(stored.annotations[0]!.body).toBe("Define atomically.");
119
- expect(setup.captureCharFrame()).toContain("COMMENT");
120
108
  });
121
109
 
122
110
  test("span mode: v + l selects words, c anchors the exact span", async () => {
111
+ // Arrange
123
112
  const setup = await renderApp();
113
+
114
+ // Act
124
115
  await press(setup, "j");
125
116
  await press(setup, "j");
126
117
  await press(setup, "v");
@@ -128,24 +119,31 @@ describe("keyboard grammar", () => {
128
119
  await press(setup, "c");
129
120
  await type(setup, "Which daemon?");
130
121
  await press(setup, "enter");
131
- await Bun.sleep(80);
122
+
123
+ // Assert
124
+ await waitForState(setup, () => server.core.sessionGet(session.id).annotations.length === 1);
132
125
  const stored = server.core.sessionGet(session.id);
133
126
  expect(stored.annotations[0]!.anchor.quote).toBe("The daemon");
134
127
  });
135
128
 
136
129
  test("x cuts a block into the working copy; x restores it", async () => {
130
+ // Arrange
137
131
  const setup = await renderApp();
138
132
  // move to "- move the store" (h1, h2, p, h2 = 4 steps in)
139
133
  for (let i = 0; i < 4; i++) await press(setup, "j");
134
+
135
+ // Act
140
136
  await press(setup, "x");
141
- await Bun.sleep(80);
142
- await setup.renderOnce();
137
+
138
+ // Assert
139
+ await waitForText(setup, "[cut]");
143
140
  expect(server.core.sessionGet(session.id).workingCopy).not.toContain("move the store");
144
- expect(setup.captureCharFrame()).toContain("[cut]");
141
+
142
+ // Act
145
143
  await press(setup, "x");
146
- await Bun.sleep(80);
147
- await setup.renderOnce();
148
- expect(server.core.sessionGet(session.id).workingCopy).toBeUndefined();
144
+
145
+ // Assert
146
+ await waitForState(setup, () => server.core.sessionGet(session.id).workingCopy === undefined);
149
147
  });
150
148
 
151
149
  test("e runs $EDITOR on the working copy and tracks the diff", async () => {
@@ -154,12 +152,15 @@ describe("keyboard grammar", () => {
154
152
  Bun.spawnSync(["chmod", "+x", script]);
155
153
  process.env.CUELOOP_EDITOR = script;
156
154
  try {
155
+ // Arrange
157
156
  const setup = await renderApp();
157
+
158
+ // Act
158
159
  await press(setup, "e");
159
- await Bun.sleep(150);
160
- await setup.renderOnce();
160
+
161
+ // Assert
162
+ await waitForText(setup, "[edited]");
161
163
  expect(server.core.sessionGet(session.id).workingCopy).toContain("very atomically");
162
- expect(setup.captureCharFrame()).toContain("[edited]");
163
164
  } finally {
164
165
  delete process.env.CUELOOP_EDITOR;
165
166
  }
@@ -167,51 +168,69 @@ describe("keyboard grammar", () => {
167
168
  });
168
169
 
169
170
  describe("submit", () => {
170
- test("⏎ opens the verdict bar; verdict + summary resolve the session", async () => {
171
+ test("⏎ opens the rail confirm card; verdict + summary resolve the session", async () => {
172
+ // Arrange
171
173
  const setup = await renderApp();
172
174
  await press(setup, "j");
173
175
  await press(setup, "j");
174
176
  await press(setup, "c");
175
177
  await type(setup, "Needs a phase list.");
176
178
  await press(setup, "enter");
177
- await Bun.sleep(80);
179
+ await waitForText(setup, "Review (1)");
180
+
181
+ // Act
178
182
  await press(setup, "enter"); // open submit
179
- await setup.renderOnce();
180
- expect(setup.captureCharFrame()).toContain("[Request changes]");
183
+
184
+ // Assert
185
+ await waitForText(setup, "[Changes]");
186
+
187
+ // Act
181
188
  await type(setup, "Expand the steps.");
182
189
  await press(setup, "enter");
183
- await Bun.sleep(120);
184
- await setup.renderOnce();
190
+
191
+ // Assert
192
+ await waitForText(setup, "feedback sent");
185
193
  const stored = server.core.sessionGet(session.id);
186
194
  expect(stored.status).toBe("resolved");
187
195
  expect(stored.verdict!.kind).toBe("request_changes");
188
196
  expect(stored.verdict!.feedback).toContain("Needs a phase list.");
189
- expect(setup.captureCharFrame()).toContain("resolved: request changes");
197
+ // submit hands the reviewer back to the agent via the completion overlay
198
+ expect(setup.captureCharFrame()).toContain("feedback sent");
190
199
  });
191
200
 
192
201
  test("approve via ←/→ verdict cycling", async () => {
202
+ // Arrange
193
203
  const setup = await renderApp();
204
+
205
+ // Act
194
206
  await press(setup, "enter");
195
- await setup.renderOnce();
196
- expect(setup.captureCharFrame()).toContain("[Approve]"); // no pending items → approve default
207
+
208
+ // Assert
209
+ await waitForText(setup, "[Approve]"); // no pending items → approve default
210
+
211
+ // Act
197
212
  await press(setup, "enter");
198
- await Bun.sleep(120);
213
+
214
+ // Assert
215
+ await waitForState(setup, () => server.core.sessionGet(session.id).verdict !== undefined);
199
216
  expect(server.core.sessionGet(session.id).verdict!.kind).toBe("approve");
200
217
  });
201
218
  });
202
219
 
203
220
  describe("inbox", () => {
204
221
  test("inbox mode renders and opens a session", async () => {
222
+ // Arrange
205
223
  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
- }
224
+ await waitForText(setup, "inbox");
225
+
226
+ // Assert
210
227
  expect(setup.captureCharFrame()).toContain("inbox (1 pending)");
211
228
  expect(setup.captureCharFrame()).toContain("Migration Plan");
229
+
230
+ // Act
212
231
  await press(setup, "enter");
213
- await Bun.sleep(80);
214
- await setup.renderOnce();
215
- expect(setup.captureCharFrame()).toContain("Submit review (0)");
232
+
233
+ // Assert
234
+ await waitForText(setup, "Submit review (0)");
216
235
  });
217
236
  });
package/src/App.tsx CHANGED
Binary file
@@ -0,0 +1,265 @@
1
+ /** The guided walk in the virtual terminal: the wizard steps every changed file with a plain progress title, ] marks viewed and persists with the session, esc keeps progress, w resumes at the first unviewed file (across App instances via the daemon round-trip), the agent-note block renders only for files carrying a note, and the end card hands over to the submit confirm with the honest viewed count. */
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 { DARK } from "./theme";
13
+ import { press, waitForState, waitForText, waitForTextGone } from "./test-support";
14
+
15
+ const PATCH = `diff --git a/src/a.ts b/src/a.ts
16
+ index 111..222 100644
17
+ --- a/src/a.ts
18
+ +++ b/src/a.ts
19
+ @@ -1,2 +1,2 @@
20
+ export const a = 1;
21
+ -export const b = 2;
22
+ +export const b = 3;
23
+ diff --git a/src/b.ts b/src/b.ts
24
+ index 333..444 100644
25
+ --- a/src/b.ts
26
+ +++ b/src/b.ts
27
+ @@ -1,2 +1,2 @@
28
+ // header
29
+ -old line
30
+ +new line
31
+ diff --git a/src/c.ts b/src/c.ts
32
+ index 555..666 100644
33
+ --- a/src/c.ts
34
+ +++ b/src/c.ts
35
+ @@ -1,1 +1,2 @@
36
+ keep
37
+ +added tail
38
+ `;
39
+
40
+ let home: string;
41
+ let server: DaemonServer;
42
+ let session: ReviewSession;
43
+
44
+ beforeEach(() => {
45
+ home = mkdtempSync(join(tmpdir(), "cueloop-walk-"));
46
+ server = new DaemonServer({ home, idleExitMs: 0 });
47
+ server.start();
48
+ session = server.core.sessionCreate({
49
+ workspace: { repoRoot: "/repo", branch: "main" },
50
+ artifact: { type: "diff", content: PATCH, meta: { title: "working tree" } },
51
+ });
52
+ });
53
+ afterEach(() => {
54
+ server.stop();
55
+ rmSync(home, { recursive: true, force: true });
56
+ });
57
+
58
+ async function renderApp() {
59
+ const setup = await testRender(<App home={home} sessionId={session.id} />, { width: 120, height: 34 });
60
+ // Wait for session content, not just the chrome: pressing w before the diff
61
+ // loads drops the walk intent (no session, no diff view) and hangs the test.
62
+ await waitForText(setup, "src/a.ts");
63
+ return setup;
64
+ }
65
+
66
+ type Setup = Awaited<ReturnType<typeof renderApp>>;
67
+
68
+ /** Foreground colors (hex) of every styled span containing the needle. */
69
+ function foregroundsOf(setup: Setup, needle: string): string[] {
70
+ const foregrounds: string[] = [];
71
+ for (const line of setup.captureSpans().lines) {
72
+ for (const span of line.spans) {
73
+ if (!span.text.includes(needle)) continue;
74
+ const [red, green, blue] = span.fg.toInts();
75
+ foregrounds.push("#" + [red, green, blue].map((part) => part.toString(16).padStart(2, "0")).join(""));
76
+ }
77
+ }
78
+ return foregrounds;
79
+ }
80
+
81
+ describe("the guided walk", () => {
82
+ test("w walks all three files: progress title, persisted marks, end card, submit hand-over", async () => {
83
+ // Arrange
84
+ const setup = await renderApp();
85
+
86
+ // Act
87
+ await press(setup, "w");
88
+
89
+ // Assert
90
+ await waitForText(setup, "file 1 of 3 · 0 viewed");
91
+ expect(setup.captureCharFrame()).toContain("src/a.ts");
92
+ expect(setup.captureCharFrame()).toContain("+1 -1");
93
+
94
+ // Act
95
+ await press(setup, "]");
96
+
97
+ // Assert
98
+ await waitForText(setup, "file 2 of 3 · 1 viewed");
99
+ // the viewed mark rides the session record, not the client
100
+ await waitForState(setup, () => (server.core.sessionGet(session.id).viewedPaths ?? []).includes("src/a.ts"));
101
+
102
+ // Act
103
+ // [ steps back; the revisited card shows its viewed marker
104
+ await press(setup, "[");
105
+
106
+ // Assert
107
+ await waitForText(setup, "file 1 of 3 · 1 viewed");
108
+ expect(setup.captureCharFrame()).toContain("· viewed");
109
+
110
+ // Act
111
+ await press(setup, "]");
112
+
113
+ // Assert
114
+ await waitForText(setup, "file 2 of 3 · 1 viewed");
115
+
116
+ // Act
117
+ await press(setup, "]");
118
+
119
+ // Assert
120
+ await waitForText(setup, "file 3 of 3 · 2 viewed");
121
+
122
+ // Act
123
+ await press(setup, "]");
124
+
125
+ // Assert
126
+ await waitForText(setup, "walk complete");
127
+ expect(setup.captureCharFrame()).toContain("every file viewed (3/3)");
128
+ expect(server.core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
129
+
130
+ // Act
131
+ // the end card's return leaves the walk and opens the rail confirm
132
+ await press(setup, "enter");
133
+
134
+ // Assert
135
+ await waitForText(setup, "submit review");
136
+ expect(setup.captureCharFrame()).toContain("3/3 files viewed");
137
+
138
+ // Act
139
+ await press(setup, "enter");
140
+
141
+ // Assert
142
+ await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
143
+ });
144
+
145
+ test("esc keeps progress and w resumes at the first unviewed file", async () => {
146
+ // Arrange
147
+ const setup = await renderApp();
148
+ await press(setup, "w");
149
+ await waitForText(setup, "file 1 of 3 · 0 viewed");
150
+ await press(setup, "]");
151
+ await waitForText(setup, "file 2 of 3 · 1 viewed");
152
+
153
+ // Act
154
+ await press(setup, "escape");
155
+
156
+ // Assert
157
+ await waitForTextGone(setup, "file 2 of 3");
158
+ // the sheet is back at full strength: no wizard card on screen
159
+ expect(setup.captureCharFrame()).not.toContain("· 1 viewed");
160
+
161
+ // Act
162
+ await press(setup, "w");
163
+
164
+ // Assert
165
+ await waitForText(setup, "file 2 of 3 · 1 viewed");
166
+ });
167
+
168
+ test("a half-walked review resumes across App instances via the daemon", async () => {
169
+ // Arrange
170
+ const first = await renderApp();
171
+ await press(first, "w");
172
+ await waitForText(first, "file 1 of 3 · 0 viewed");
173
+ await press(first, "]");
174
+ await press(first, "]");
175
+ await waitForText(first, "file 3 of 3 · 2 viewed");
176
+ await waitForState(first, () => (server.core.sessionGet(session.id).viewedPaths ?? []).length === 2);
177
+ first.renderer.destroy();
178
+
179
+ // Act
180
+ // a fresh client reads the viewed set back from the session record
181
+ const second = await renderApp();
182
+ await press(second, "w");
183
+
184
+ // Assert
185
+ await waitForText(second, "file 3 of 3 · 2 viewed");
186
+ expect(second.captureCharFrame()).toContain("src/c.ts");
187
+ // two full App boots with daemon round-trips need more than the default
188
+ // budget, and the whole-suite publish lane has timed even 15s out; give the
189
+ // double-boot chain generous headroom so runner load cannot flake it.
190
+ }, 30_000);
191
+
192
+ test("the agent-note block renders only for files carrying a note", async () => {
193
+ // Arrange
194
+ server.core.sessionAnnotate(session.id, {
195
+ id: "note-b",
196
+ kind: "note",
197
+ anchor: { quote: "src/b.ts", prefix: "", suffix: "" },
198
+ body: "Swaps the stale line for the new constant.",
199
+ });
200
+ const setup = await renderApp();
201
+
202
+ // Act
203
+ await press(setup, "w");
204
+
205
+ // Assert
206
+ await waitForText(setup, "file 1 of 3");
207
+ expect(setup.captureCharFrame()).not.toContain("agent note");
208
+
209
+ // Act
210
+ await press(setup, "]");
211
+
212
+ // Assert
213
+ await waitForText(setup, "agent note");
214
+ expect(setup.captureCharFrame()).toContain("Swaps the stale line for the new constant.");
215
+
216
+ // Act
217
+ // a note is agent context, not reviewer feedback: it neither counts nor
218
+ // flips the default verdict, and never comes back in the feedback doc
219
+ await press(setup, "escape");
220
+ await waitForTextGone(setup, "agent note");
221
+ await press(setup, "enter");
222
+ await waitForText(setup, "0 annotations");
223
+ await press(setup, "enter");
224
+
225
+ // Assert
226
+ await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
227
+ const resolved = server.core.sessionGet(session.id);
228
+ expect(resolved.verdict!.kind).toBe("approve");
229
+ expect(resolved.verdict!.feedback).not.toContain("Swaps the stale line");
230
+ });
231
+
232
+ test("the sheet dims behind the wizard; the preview keeps the diff colors", async () => {
233
+ // Arrange
234
+ const setup = await renderApp();
235
+
236
+ // Assert
237
+ // before the walk the sheet's added line wears the insertion color
238
+ await waitForText(setup, "+new line");
239
+ expect(foregroundsOf(setup, "+new line")).toContain(DARK.insFg);
240
+
241
+ // Act
242
+ await press(setup, "w");
243
+
244
+ // Assert
245
+ await waitForText(setup, "file 1 of 3 · 0 viewed");
246
+ // dimmed: the sheet line drops to the dim token...
247
+ expect(foregroundsOf(setup, "+new line")).toEqual([DARK.textDim]);
248
+ // ...while the wizard preview keeps the insertion color
249
+ expect(foregroundsOf(setup, "+export const b = 3;")).toContain(DARK.insFg);
250
+ });
251
+
252
+ test("walking a resolved review answers read-only", async () => {
253
+ // Arrange
254
+ server.core.sessionResolve(session.id, "approve", "");
255
+ const setup = await renderApp();
256
+ await waitForText(setup, "resolved");
257
+
258
+ // Act
259
+ await press(setup, "w");
260
+
261
+ // Assert
262
+ await waitForText(setup, "review submitted - read-only");
263
+ expect(setup.captureCharFrame()).not.toContain("file 1 of 3");
264
+ });
265
+ });
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { Identity } from "@cueloop/schema";
3
+ import { ANONYMOUS_LABEL, resolveDisplayName, shortHandle } from "./attribution";
4
+
5
+ describe(shortHandle, () => {
6
+ test("shortens an ssh fingerprint to a stable handle", () => {
7
+ // Arrange / Act / Assert
8
+ expect(shortHandle("SHA256:1a2b3c4d5e6f")).toBe("1a2b3c4d");
9
+ expect(shortHandle("SHA256:1a2b3c4d5e6f")).toBe(shortHandle("SHA256:1a2b3c4d5e6f"));
10
+ });
11
+ });
12
+
13
+ describe(resolveDisplayName, () => {
14
+ const named: Identity[] = [{ id: "SHA256:abc", provider: "ssh", name: "Al" }];
15
+ const unnamed: Identity[] = [{ id: "SHA256:abc", provider: "ssh" }];
16
+
17
+ test("a local rename override wins over everything", () => {
18
+ // Arrange / Act
19
+ const label = resolveDisplayName("SHA256:abc", named, { "SHA256:abc": "Alex" });
20
+
21
+ // Assert
22
+ expect(label).toBe("Alex");
23
+ });
24
+
25
+ test("falls back to name, then anonymous, then the short handle", () => {
26
+ // Arrange / Act / Assert
27
+ expect(resolveDisplayName("SHA256:abc", named, {})).toBe("Al");
28
+ expect(resolveDisplayName("SHA256:abc", unnamed, {})).toBe(ANONYMOUS_LABEL);
29
+ expect(resolveDisplayName("SHA256:1a2b3c4d5e", [], {})).toBe("1a2b3c4d");
30
+ });
31
+ });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Attribution labels for annotations. An annotation's `author` is an identity
3
+ * id (an SSH fingerprint today). The rail resolves it against the share's
4
+ * participant registry and the planner's local rename overrides; ADR 0006.
5
+ */
6
+
7
+ import type { Identity } from "@cueloop/schema";
8
+
9
+ const FINGERPRINT_PREFIX = "SHA256:";
10
+
11
+ /** Shown for a known participant who chose not to give a name. */
12
+ export const ANONYMOUS_LABEL = "-- anonymous --";
13
+
14
+ /** A short, stable handle from a raw author id when no name is known. */
15
+ export function shortHandle(authorId: string): string {
16
+ const base = authorId.startsWith(FINGERPRINT_PREFIX) ? authorId.slice(FINGERPRINT_PREFIX.length) : authorId;
17
+ return base.slice(0, 8) || authorId;
18
+ }
19
+
20
+ /**
21
+ * The rail label for an author id: a local rename wins, then the collaborator's
22
+ * own name, then anonymous for a known-but-unnamed participant, then a short
23
+ * handle for an id we hold no identity for.
24
+ */
25
+ export function resolveDisplayName(authorId: string, participants: Identity[] | undefined, overrides: Record<string, string>): string {
26
+ const override = overrides[authorId];
27
+ if (override) return override;
28
+ const identity = participants?.find((participant) => participant.id === authorId);
29
+ if (identity?.name) return identity.name;
30
+ if (identity) return ANONYMOUS_LABEL;
31
+ return shortHandle(authorId);
32
+ }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { clipboardCommands } from "./clipboard";
3
+
4
+ describe(clipboardCommands, () => {
5
+ test("uses pbcopy on macOS", () => {
6
+ // Assert
7
+ expect(clipboardCommands("darwin")).toEqual([["pbcopy"]]);
8
+ });
9
+
10
+ test("uses clip.exe on Windows", () => {
11
+ // Assert
12
+ expect(clipboardCommands("win32")).toEqual([["clip.exe"]]);
13
+ });
14
+
15
+ test("prefers wayland, then X11 tools on Linux", () => {
16
+ // Act
17
+ const commands = clipboardCommands("linux");
18
+
19
+ // Assert
20
+ expect(commands[0]).toEqual(["wl-copy"]);
21
+ expect(commands.map((command) => command[0])).toEqual(["wl-copy", "xclip", "xsel"]);
22
+ });
23
+ });
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Best-effort system clipboard. Each platform has its own tool; we try them in
3
+ * order and report whether one worked, so the caller can fall back to printing
4
+ * the line (the over-SSH case, where no clipboard tool exists). Mirrors how
5
+ * herdr confirms a copy.
6
+ */
7
+
8
+ /** The clipboard-write commands to try, most-preferred first, for this platform. */
9
+ export function clipboardCommands(platform: NodeJS.Platform = process.platform): string[][] {
10
+ if (platform === "darwin") return [["pbcopy"]];
11
+ if (platform === "win32") return [["clip.exe"]];
12
+ return [["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]];
13
+ }
14
+
15
+ /** Copy `text` to the clipboard; false when no tool is available or all fail. */
16
+ export async function copyToClipboard(text: string): Promise<boolean> {
17
+ for (const command of clipboardCommands()) {
18
+ try {
19
+ const proc = Bun.spawn(command, { stdin: Buffer.from(text), stdout: "ignore", stderr: "ignore" });
20
+ if ((await proc.exited) === 0) return true;
21
+ } catch {
22
+ // tool not installed - try the next one
23
+ }
24
+ }
25
+ return false;
26
+ }