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

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.
package/src/App.tsx CHANGED
@@ -12,14 +12,16 @@
12
12
  import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
13
13
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
14
14
  import type { Clock, MouseEvent } from "@opentui/core";
15
- import { isAgentNote, type ReviewSession, type VerdictKind } from "@cueloop/schema";
16
- import { displayText, marksByDisplay, spanKey, startSpan, type Mark, type SpanState } from "./view-plan";
15
+ import { type VerdictKind } from "@cueloop/schema";
16
+ import { displayText, marksByDisplay, type Mark } from "./view-plan";
17
17
  import { noteForFile, viewedCount } from "./walk";
18
18
  import { DARK, dimmedTheme } from "./theme";
19
19
  import { DEFAULT_KEYS, loadConfig } from "./config";
20
20
  import { returnPaneFor } from "@cueloop/schema";
21
21
  import { createReviewController } from "./session-controller";
22
- import { reduceKey, type Intent, type KeyState } from "./keymap";
22
+ import type { SessionClient } from "@cueloop/daemon/client";
23
+ import { createIntentDispatch, reviewerAnnotations, type Mode } from "./intent-dispatch";
24
+ import { reduceKey, type KeyState } from "./keymap";
23
25
  import { KeyBindings, type HintMode } from "./key-bindings";
24
26
  import { ThemeProvider } from "./components/theme-context";
25
27
  import { StatusBar } from "./components/primitives/StatusBar";
@@ -30,14 +32,11 @@ import { annotationBlocking, type ReviewRailHandle } from "./components/ReviewRa
30
32
  import { ReviewPanel } from "./components/ReviewPanel";
31
33
  import {
32
34
  REVIEW_DEFAULT_WIDTH,
33
- REVIEW_RESIZE_STEP,
34
- cycleReviewPanelMode,
35
35
  resolveReviewWidth,
36
36
  toggleReviewPanelMode,
37
37
  widthFromMouseColumn,
38
38
  type ReviewPanelMode,
39
39
  } from "./review-panel";
40
- import { VERDICTS } from "./components/ConfirmCard";
41
40
  import { CompletionOverlay } from "./components/CompletionOverlay";
42
41
  import { InboxList } from "./components/InboxList";
43
42
  import { ComposeBar } from "./components/ComposeBar";
@@ -60,27 +59,26 @@ export interface AppProps {
60
59
  onExit?: (code: number) => void;
61
60
  /** Timer source for the auto-close countdown; tests inject a ManualClock. */
62
61
  clock?: Clock;
62
+ /** Session source; the sharing gateway injects a blob-backed client. */
63
+ openClient?: () => Promise<SessionClient>;
64
+ /**
65
+ * Who is at the keyboard. `owner` is the local planner (default). `observer`
66
+ * is a passive `cueloop serve` watcher (read-only). `collaborator` is a share
67
+ * viewer: annotates, but cannot edit the plan or submit an agent verdict.
68
+ */
69
+ role?: "owner" | "observer" | "collaborator";
63
70
  }
64
71
 
65
- type Mode =
66
- | { type: "normal" }
67
- | { type: "span"; span: SpanState }
68
- | { type: "compose"; kind: "comment" | "suggestion"; displayIndex: number; start: number; end: number; text: string }
69
- | { type: "railEdit"; id: string; text: string }
70
- | { type: "submit"; verdict: VerdictKind; summary: string };
71
-
72
- /** Reviewer-authored annotations only: agent notes never count as feedback. */
73
- function reviewerAnnotations(session: ReviewSession) {
74
- return session.annotations.filter((annotation) => !isAgentNote(annotation));
75
- }
76
-
77
- function defaultVerdict(session: ReviewSession): VerdictKind {
78
- return reviewerAnnotations(session).length || session.workingCopy !== undefined ? "request_changes" : "approve";
79
- }
80
-
81
- export function App({ home, sessionId, readOnly = false, onExit, clock }: AppProps): React.ReactNode {
72
+ export function App({ home, sessionId, readOnly = false, onExit, clock, openClient, role = "owner" }: AppProps): React.ReactNode {
73
+ // Observer stays fully read-only; a collaborator writes annotations but not
74
+ // the plan or a verdict. `observer` is what the controller and every write
75
+ // gate key off; the two capability flags carve out the collaborator's middle.
76
+ const observer = readOnly || role === "observer";
77
+ const canEditPlan = !observer && role !== "collaborator";
78
+ const canSubmitVerdict = !observer && role !== "collaborator";
79
+ const canShare = !observer && role === "owner";
82
80
  const controller = useMemo(
83
- () => createReviewController({ home, sessionId, readOnly, onExit, clock }),
81
+ () => createReviewController({ home, sessionId, readOnly: observer, onExit, clock, openClient }),
84
82
  // eslint-disable-next-line react-hooks/exhaustive-deps
85
83
  [home, sessionId],
86
84
  );
@@ -198,7 +196,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
198
196
  };
199
197
 
200
198
  const openCardEdit = (annotationId: string): void => {
201
- if (readOnly) return controller.setStatus("observer - read-only");
199
+ if (observer) return controller.setStatus("observer - read-only");
202
200
  if (resolved) return controller.setStatus("review submitted - read-only");
203
201
  const annotation = session?.annotations.find((candidate) => candidate.id === annotationId);
204
202
  if (!annotation) return;
@@ -217,158 +215,36 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
217
215
  };
218
216
 
219
217
  // ── keyboard grammar: build state, reduce, dispatch ──
220
- const dispatch = (intent: Intent): void => {
221
- switch (intent.type) {
222
- case "exit":
223
- return void onExit?.(0);
224
- case "status":
225
- return controller.setStatus(intent.message);
226
- case "move": {
227
- const navigableCount = isDiff ? rows.length : display.length;
228
- if (intent.to === "down") setCursor((current) => Math.min(navigableCount - 1, current + 1));
229
- else if (intent.to === "up") setCursor((current) => Math.max(0, current - 1));
230
- else if (intent.to === "top") setCursor(0);
231
- else setCursor(navigableCount - 1);
232
- return;
233
- }
234
- case "inboxMove": {
235
- const navigableCount = inbox?.length ?? 0;
236
- setInboxCursor((current) => (intent.to === "down" ? Math.min(navigableCount - 1, current + 1) : Math.max(0, current - 1)));
237
- return;
238
- }
239
- case "openSession": {
240
- const selected = inbox?.[inboxCursor];
241
- if (selected) controller.open(selected.id);
242
- return;
243
- }
244
- case "startSpan": {
245
- const block = display[cursor];
246
- if (!block?.work) return;
247
- const span = startSpan(cursor, displayText(block));
248
- if (span) setMode({ type: "span", span });
249
- return;
250
- }
251
- case "spanKey":
252
- if (mode.type === "span") {
253
- const span = spanKey(mode.span, intent.name, displayText(display[mode.span.displayIndex]!));
254
- setMode({ type: "span", span });
255
- }
256
- return;
257
- case "openCompose": {
258
- liveInput.current = "";
259
- if (intent.from === "span" && mode.type === "span") {
260
- setMode({
261
- type: "compose",
262
- kind: intent.kind,
263
- displayIndex: mode.span.displayIndex,
264
- start: mode.span.start,
265
- end: mode.span.end,
266
- text: "",
267
- });
268
- } else if (isDiff) {
269
- const row = rows[cursor];
270
- if (row) setMode({ type: "compose", kind: intent.kind, displayIndex: cursor, start: 0, end: row.text.length, text: "" });
271
- } else {
272
- // a mouse drag leaves a native selection; it wins over the cursor block
273
- const native = planSheetRef.current?.readSelection() ?? null;
274
- if (native) {
275
- setMode({ type: "compose", kind: intent.kind, ...native, text: "" });
276
- } else {
277
- const block = display[cursor];
278
- if (block) setMode({ type: "compose", kind: intent.kind, displayIndex: cursor, start: 0, end: displayText(block).length, text: "" });
279
- }
280
- }
281
- return;
282
- }
283
- case "openSubmit":
284
- if (!session) return;
285
- liveInput.current = "";
286
- // the confirm card lives in the expanded review rail; a compact or hidden
287
- // panel would swallow the whole submit flow, so force the rail open (live
288
- // only - the saved panel preference is left untouched)
289
- setReviewMode("expanded");
290
- setRailTab("review");
291
- return void setMode({ type: "submit", verdict: defaultVerdict(session), summary: "" });
292
- case "cut":
293
- return controller.cut(cursor);
294
- case "edit":
295
- return runEditorHandOff();
296
- case "editCard":
297
- if (focusedAnnotationId) openCardEdit(focusedAnnotationId);
298
- return;
299
- case "nextAnnotation":
300
- case "prevAnnotation": {
301
- const annotations = session?.annotations ?? [];
302
- if (!annotations.length) return;
303
- const focusedIndex = annotations.findIndex((annotation) => annotation.id === focusedAnnotationId);
304
- const nextIndex =
305
- focusedIndex === -1
306
- ? 0
307
- : (focusedIndex + (intent.type === "nextAnnotation" ? 1 : -1) + annotations.length) % annotations.length;
308
- return void selectCardFromDocument(annotations[nextIndex]!.id);
309
- }
310
- case "walkStart":
311
- return controller.walkStart();
312
- case "walkForward":
313
- return controller.walkForward();
314
- case "walkBack":
315
- return controller.walkBack();
316
- case "walkLeave":
317
- return controller.walkLeave();
318
- case "removeAnnotation":
319
- if (focusedAnnotationId) {
320
- controller.removeAnnotation(focusedAnnotationId);
321
- setFocusedAnnotationId(undefined);
322
- }
323
- return;
324
- case "deselect":
325
- planSheetRef.current?.clearSelection();
326
- setFocusedAnnotationId(undefined);
327
- setPulsedAnnotationId(null);
328
- return;
329
- case "closeOverlay":
330
- return void setMode({ type: "normal" });
331
- case "saveCompose": {
332
- const body = liveInput.current.trim();
333
- if (mode.type === "railEdit") {
334
- if (session && body) controller.updateAnnotation(mode.id, body);
335
- return void setMode({ type: "normal" });
336
- }
337
- if (mode.type !== "compose") return;
338
- if (session && body) {
339
- const annotationId = controller.annotate(mode.kind, mode.displayIndex, mode.start, mode.end, body);
340
- if (annotationId) setFocusedAnnotationId(annotationId);
341
- }
342
- return void setMode({ type: "normal" });
343
- }
344
- case "submitVerdict":
345
- if (mode.type === "submit") controller.submit(mode.verdict, liveInput.current);
346
- return void setMode({ type: "normal" });
347
- case "cycleVerdict": {
348
- if (mode.type !== "submit") return;
349
- const verdictIndex = (VERDICTS.indexOf(mode.verdict) + intent.direction + VERDICTS.length) % VERDICTS.length;
350
- return void setMode({ ...mode, verdict: VERDICTS[verdictIndex]! });
351
- }
352
- case "finishReview":
353
- return controller.finishReview();
354
- case "optInAutoClose":
355
- return controller.optInAutoClose();
356
- case "dismissCompletion":
357
- return controller.dismissCompletion();
358
- case "cycleReviewPanel": {
359
- const next = cycleReviewPanelMode(reviewMode);
360
- setReviewMode(next);
361
- return controller.saveReviewPanel({ mode: next });
362
- }
363
- case "resizeReviewPanel": {
364
- if (reviewMode !== "expanded") return;
365
- const next = resolveReviewWidth(reviewWidth + intent.direction * REVIEW_RESIZE_STEP, terminalWidth);
366
- reviewWidthRef.current = next;
367
- setReviewWidth(next);
368
- return controller.saveReviewPanel({ width: next });
369
- }
370
- }
371
- };
218
+ const dispatch = createIntentDispatch({
219
+ controller,
220
+ onExit,
221
+ isDiff,
222
+ display,
223
+ rows,
224
+ cursor,
225
+ inbox,
226
+ inboxCursor,
227
+ mode,
228
+ session,
229
+ reviewMode,
230
+ reviewWidth,
231
+ terminalWidth,
232
+ focusedAnnotationId,
233
+ liveInput,
234
+ reviewWidthRef,
235
+ planSheetRef,
236
+ setCursor,
237
+ setInboxCursor,
238
+ setMode,
239
+ setReviewMode,
240
+ setReviewWidth,
241
+ setRailTab,
242
+ setFocusedAnnotationId,
243
+ setPulsedAnnotationId,
244
+ selectCardFromDocument,
245
+ runEditorHandOff,
246
+ openCardEdit,
247
+ });
372
248
 
373
249
  const overlay: KeyState["overlay"] =
374
250
  mode.type === "compose" || mode.type === "railEdit"
@@ -386,7 +262,10 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
386
262
  useKeyboard((key) => {
387
263
  const state: KeyState = {
388
264
  keys: keysRef.current,
389
- readOnly,
265
+ readOnly: observer,
266
+ canEditPlan,
267
+ canSubmitVerdict,
268
+ canShare,
390
269
  overlay,
391
270
  view: !session ? "inbox" : isDiff ? "diff" : "plan",
392
271
  spanMode: mode.type === "span",
@@ -479,14 +358,22 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
479
358
  };
480
359
 
481
360
  const onEditRequest = (): void => {
482
- if (readOnly) return controller.setStatus("observer - read-only");
361
+ if (observer) return controller.setStatus("observer - read-only");
362
+ if (!canEditPlan) return controller.setStatus("shared plan - edit it in your own copy");
483
363
  if (resolved) return controller.setStatus("review submitted - read-only");
484
364
  runEditorHandOff();
485
365
  };
486
366
 
367
+ // clicking the rail Share button: publish the plan, copy the ssh line
368
+ const onShareRequest = (): void => {
369
+ if (!canShare) return controller.setStatus("only the plan owner can share");
370
+ controller.share();
371
+ };
372
+
487
373
  // clicking the rail Submit button: same read-only answer as the submit key
488
374
  const onSubmitRequest = (): void => {
489
- if (readOnly) return controller.setStatus("observer - read-only");
375
+ if (observer) return controller.setStatus("observer - read-only");
376
+ if (!canSubmitVerdict) return controller.setStatus("shared view - your notes save as you go");
490
377
  if (resolved) return;
491
378
  dispatch({ type: "openSubmit" });
492
379
  };
@@ -543,12 +430,13 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
543
430
  { label: "cueloop", tone: "accent" },
544
431
  { label: `${activeSession.artifact.meta.title ?? activeSession.artifact.meta.planPath ?? activeSession.id} · rev ${activeSession.revisions.length}`, tone: "dim" },
545
432
  ...(resolved ? [{ label: `resolved: ${activeSession.verdict!.kind.replace("_", " ")}`, tone: "green" as const }] : []),
546
- ...(readOnly ? [{ label: "observer", tone: "dim" as const }] : []),
433
+ ...(observer ? [{ label: "observer", tone: "dim" as const }] : []),
434
+ ...(role === "collaborator" ? [{ label: "shared · your notes save as you go", tone: "dim" as const }] : []),
547
435
  ...(status ? [{ label: status, tone: "accent" as const }] : []),
548
436
  ];
549
437
 
550
438
  keyBindings.setContext({ overlay, spanMode: mode.type === "span" });
551
- const hintMode: HintMode = readOnly
439
+ const hintMode: HintMode = observer
552
440
  ? "read-only"
553
441
  : mode.type === "submit"
554
442
  ? "submit"
@@ -623,6 +511,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
623
511
  onSelectCard: selectCardFromRail,
624
512
  onActivateCard: openCardEdit,
625
513
  onSubmitRequest,
514
+ onShareRequest: canShare ? onShareRequest : undefined,
626
515
  }}
627
516
  />
628
517
  </box>
@@ -1,11 +1,4 @@
1
- /**
2
- * The guided walk in the virtual terminal: the wizard steps every
3
- * changed file with a plain progress title, ] marks viewed and persists with
4
- * the session, esc keeps progress, w resumes at the first unviewed file
5
- * (across App instances - the daemon round-trip), the agent-note block
6
- * renders only for files carrying a note, and the end card hands over to the
7
- * submit confirm with the honest viewed count.
8
- */
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. */
9
2
 
10
3
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
11
4
  import { mkdtempSync, rmSync } from "node:fs";
@@ -87,56 +80,93 @@ function foregroundsOf(setup: Setup, needle: string): string[] {
87
80
 
88
81
  describe("the guided walk", () => {
89
82
  test("w walks all three files: progress title, persisted marks, end card, submit hand-over", async () => {
83
+ // Arrange
90
84
  const setup = await renderApp();
85
+
86
+ // Act
91
87
  await press(setup, "w");
88
+
89
+ // Assert
92
90
  await waitForText(setup, "file 1 of 3 · 0 viewed");
93
91
  expect(setup.captureCharFrame()).toContain("src/a.ts");
94
92
  expect(setup.captureCharFrame()).toContain("+1 -1");
95
93
 
94
+ // Act
96
95
  await press(setup, "]");
96
+
97
+ // Assert
97
98
  await waitForText(setup, "file 2 of 3 · 1 viewed");
98
99
  // the viewed mark rides the session record, not the client
99
100
  await waitForState(setup, () => (server.core.sessionGet(session.id).viewedPaths ?? []).includes("src/a.ts"));
100
101
 
102
+ // Act
101
103
  // [ steps back; the revisited card shows its viewed marker
102
104
  await press(setup, "[");
105
+
106
+ // Assert
103
107
  await waitForText(setup, "file 1 of 3 · 1 viewed");
104
108
  expect(setup.captureCharFrame()).toContain("· viewed");
109
+
110
+ // Act
105
111
  await press(setup, "]");
112
+
113
+ // Assert
106
114
  await waitForText(setup, "file 2 of 3 · 1 viewed");
107
115
 
116
+ // Act
108
117
  await press(setup, "]");
118
+
119
+ // Assert
109
120
  await waitForText(setup, "file 3 of 3 · 2 viewed");
121
+
122
+ // Act
110
123
  await press(setup, "]");
124
+
125
+ // Assert
111
126
  await waitForText(setup, "walk complete");
112
127
  expect(setup.captureCharFrame()).toContain("every file viewed (3/3)");
113
128
  expect(server.core.sessionGet(session.id).viewedPaths).toEqual(["src/a.ts", "src/b.ts", "src/c.ts"]);
114
129
 
130
+ // Act
115
131
  // the end card's return leaves the walk and opens the rail confirm
116
132
  await press(setup, "enter");
133
+
134
+ // Assert
117
135
  await waitForText(setup, "submit review");
118
136
  expect(setup.captureCharFrame()).toContain("3/3 files viewed");
137
+
138
+ // Act
119
139
  await press(setup, "enter");
140
+
141
+ // Assert
120
142
  await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
121
143
  });
122
144
 
123
145
  test("esc keeps progress and w resumes at the first unviewed file", async () => {
146
+ // Arrange
124
147
  const setup = await renderApp();
125
148
  await press(setup, "w");
126
149
  await waitForText(setup, "file 1 of 3 · 0 viewed");
127
150
  await press(setup, "]");
128
151
  await waitForText(setup, "file 2 of 3 · 1 viewed");
129
152
 
153
+ // Act
130
154
  await press(setup, "escape");
155
+
156
+ // Assert
131
157
  await waitForTextGone(setup, "file 2 of 3");
132
158
  // the sheet is back at full strength: no wizard card on screen
133
159
  expect(setup.captureCharFrame()).not.toContain("· 1 viewed");
134
160
 
161
+ // Act
135
162
  await press(setup, "w");
163
+
164
+ // Assert
136
165
  await waitForText(setup, "file 2 of 3 · 1 viewed");
137
166
  });
138
167
 
139
168
  test("a half-walked review resumes across App instances via the daemon", async () => {
169
+ // Arrange
140
170
  const first = await renderApp();
141
171
  await press(first, "w");
142
172
  await waitForText(first, "file 1 of 3 · 0 viewed");
@@ -146,15 +176,19 @@ describe("the guided walk", () => {
146
176
  await waitForState(first, () => (server.core.sessionGet(session.id).viewedPaths ?? []).length === 2);
147
177
  first.renderer.destroy();
148
178
 
179
+ // Act
149
180
  // a fresh client reads the viewed set back from the session record
150
181
  const second = await renderApp();
151
182
  await press(second, "w");
183
+
184
+ // Assert
152
185
  await waitForText(second, "file 3 of 3 · 2 viewed");
153
186
  expect(second.captureCharFrame()).toContain("src/c.ts");
154
187
  // two full App boots with daemon round-trips need more than the default budget
155
188
  }, 15_000);
156
189
 
157
190
  test("the agent-note block renders only for files carrying a note", async () => {
191
+ // Arrange
158
192
  server.core.sessionAnnotate(session.id, {
159
193
  id: "note-b",
160
194
  kind: "note",
@@ -162,14 +196,22 @@ describe("the guided walk", () => {
162
196
  body: "Swaps the stale line for the new constant.",
163
197
  });
164
198
  const setup = await renderApp();
199
+
200
+ // Act
165
201
  await press(setup, "w");
202
+
203
+ // Assert
166
204
  await waitForText(setup, "file 1 of 3");
167
205
  expect(setup.captureCharFrame()).not.toContain("agent note");
168
206
 
207
+ // Act
169
208
  await press(setup, "]");
209
+
210
+ // Assert
170
211
  await waitForText(setup, "agent note");
171
212
  expect(setup.captureCharFrame()).toContain("Swaps the stale line for the new constant.");
172
213
 
214
+ // Act
173
215
  // a note is agent context, not reviewer feedback: it neither counts nor
174
216
  // flips the default verdict, and never comes back in the feedback doc
175
217
  await press(setup, "escape");
@@ -177,6 +219,8 @@ describe("the guided walk", () => {
177
219
  await press(setup, "enter");
178
220
  await waitForText(setup, "0 annotations");
179
221
  await press(setup, "enter");
222
+
223
+ // Assert
180
224
  await waitForState(setup, () => server.core.sessionGet(session.id).status === "resolved");
181
225
  const resolved = server.core.sessionGet(session.id);
182
226
  expect(resolved.verdict!.kind).toBe("approve");
@@ -184,12 +228,18 @@ describe("the guided walk", () => {
184
228
  });
185
229
 
186
230
  test("the sheet dims behind the wizard; the preview keeps the diff colors", async () => {
231
+ // Arrange
187
232
  const setup = await renderApp();
233
+
234
+ // Assert
188
235
  // before the walk the sheet's added line wears the insertion color
189
236
  await waitForText(setup, "+new line");
190
237
  expect(foregroundsOf(setup, "+new line")).toContain(DARK.insFg);
191
238
 
239
+ // Act
192
240
  await press(setup, "w");
241
+
242
+ // Assert
193
243
  await waitForText(setup, "file 1 of 3 · 0 viewed");
194
244
  // dimmed: the sheet line drops to the dim token...
195
245
  expect(foregroundsOf(setup, "+new line")).toEqual([DARK.textDim]);
@@ -198,10 +248,15 @@ describe("the guided walk", () => {
198
248
  });
199
249
 
200
250
  test("walking a resolved review answers read-only", async () => {
251
+ // Arrange
201
252
  server.core.sessionResolve(session.id, "approve", "");
202
253
  const setup = await renderApp();
203
254
  await waitForText(setup, "resolved");
255
+
256
+ // Act
204
257
  await press(setup, "w");
258
+
259
+ // Assert
205
260
  await waitForText(setup, "review submitted - read-only");
206
261
  expect(setup.captureCharFrame()).not.toContain("file 1 of 3");
207
262
  });
@@ -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
+ }
@@ -8,7 +8,7 @@
8
8
 
9
9
  import React, { forwardRef, useImperativeHandle, useRef } from "react";
10
10
  import type { ScrollBoxRenderable } from "@opentui/core";
11
- import type { Annotation, ReviewSession } from "@cueloop/schema";
11
+ import { isAddressed, type Annotation, type ReviewSession } from "@cueloop/schema";
12
12
  import type { Theme } from "../theme";
13
13
  import { useComponentTheme } from "./theme-context";
14
14
  import { Tabs, Tab, TabList } from "./primitives/Tabs";
@@ -40,6 +40,8 @@ export interface ReviewRailProps {
40
40
  onSelectCard: (id: string) => void;
41
41
  onActivateCard: (id: string) => void;
42
42
  onSubmitRequest: () => void;
43
+ /** Publish the plan as a share; omitted for non-owners (hides the button). */
44
+ onShareRequest?: () => void;
43
45
  /** Rail column width; the app derives it from the persisted review layout. */
44
46
  width?: number;
45
47
  /**
@@ -68,6 +70,7 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
68
70
  onSelectCard,
69
71
  onActivateCard,
70
72
  onSubmitRequest,
73
+ onShareRequest,
71
74
  width = 34,
72
75
  onCollapse,
73
76
  theme,
@@ -76,6 +79,9 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
76
79
  ): React.ReactNode {
77
80
  const tokens = useComponentTheme(theme);
78
81
  const scrollRef = useRef<ScrollBoxRenderable | null>(null);
82
+ // addressed annotations leave the card list; a dim count keeps them honest
83
+ const openAnnotations = session.annotations.filter((annotation) => !isAddressed(annotation));
84
+ const addressedCount = session.annotations.length - openAnnotations.length;
79
85
 
80
86
  useImperativeHandle(handleRef, () => ({
81
87
  revealCard: (annotationId: string): void => {
@@ -101,11 +107,12 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
101
107
  ) : (
102
108
  <>
103
109
  {session.workingCopy !== undefined ? <text fg={tokens.textDim}>± plan edits → one diff</text> : null}
104
- {session.annotations.length === 0 ? (
105
- <text fg={tokens.textDim}>no annotations yet</text>
110
+ {addressedCount > 0 ? <text fg={tokens.textDim}>✓ {addressedCount} addressed by revision</text> : null}
111
+ {openAnnotations.length === 0 ? (
112
+ <text fg={tokens.textDim}>{session.annotations.length === 0 ? "no annotations yet" : "all annotations addressed"}</text>
106
113
  ) : (
107
114
  <scrollbox ref={scrollRef} style={{ flexGrow: 1 }} focused={false}>
108
- {session.annotations.map((annotation) => (
115
+ {openAnnotations.map((annotation) => (
109
116
  <AnnotationCard
110
117
  key={annotation.id}
111
118
  id={`annotation-card-${annotation.id}`}
@@ -145,6 +152,11 @@ export const ReviewRail = forwardRef<ReviewRailHandle, ReviewRailProps>(function
145
152
  {`Submit review (${pendingCount}) ⏎`}
146
153
  </Button>
147
154
  )}
155
+ {onShareRequest && session.status !== "resolved" ? (
156
+ <Button variant="plain" onPress={onShareRequest} theme={theme}>
157
+ {"Share plan ⇧S"}
158
+ </Button>
159
+ ) : null}
148
160
  </>
149
161
  )}
150
162
  {/* the collapse chevron: muted, left-bound, one column from the divider.
@@ -28,10 +28,15 @@ describe("stories catalog", () => {
28
28
 
29
29
  for (const { moduleTitle, storyName, story } of stories) {
30
30
  test(`${moduleTitle}/${storyName} renders, snapshots, and carries its colors`, async () => {
31
+ // Arrange
31
32
  const size = story.size ?? { width: 80, height: 24 };
33
+
34
+ // Act
32
35
  const setup = await testRender(story.render(), size);
33
36
  await setup.waitForVisualIdle();
34
37
  const frame = setup.captureCharFrame();
38
+
39
+ // Assert
35
40
  expect(frame).toMatchSnapshot(`${moduleTitle}/${storyName}`);
36
41
  if (story.expectedColors?.length) {
37
42
  const seenColors = new Set<string>();