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

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,27 @@ 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
+ // Editing the plan, submitting a verdict, and sharing are all the owner's
78
+ // alone - a collaborator annotates, an observer only reads. One predicate
79
+ // feeds all three; split it the day a collaborator earns one of them.
80
+ const isOwner = !observer && role === "owner";
82
81
  const controller = useMemo(
83
- () => createReviewController({ home, sessionId, readOnly, onExit, clock }),
82
+ () => createReviewController({ home, sessionId, readOnly: observer, onExit, clock, openClient }),
84
83
  // eslint-disable-next-line react-hooks/exhaustive-deps
85
84
  [home, sessionId],
86
85
  );
@@ -198,7 +197,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
198
197
  };
199
198
 
200
199
  const openCardEdit = (annotationId: string): void => {
201
- if (readOnly) return controller.setStatus("observer - read-only");
200
+ if (observer) return controller.setStatus("observer - read-only");
202
201
  if (resolved) return controller.setStatus("review submitted - read-only");
203
202
  const annotation = session?.annotations.find((candidate) => candidate.id === annotationId);
204
203
  if (!annotation) return;
@@ -217,158 +216,36 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
217
216
  };
218
217
 
219
218
  // ── 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
- };
219
+ const dispatch = createIntentDispatch({
220
+ controller,
221
+ onExit,
222
+ isDiff,
223
+ display,
224
+ rows,
225
+ cursor,
226
+ inbox,
227
+ inboxCursor,
228
+ mode,
229
+ session,
230
+ reviewMode,
231
+ reviewWidth,
232
+ terminalWidth,
233
+ focusedAnnotationId,
234
+ liveInput,
235
+ reviewWidthRef,
236
+ planSheetRef,
237
+ setCursor,
238
+ setInboxCursor,
239
+ setMode,
240
+ setReviewMode,
241
+ setReviewWidth,
242
+ setRailTab,
243
+ setFocusedAnnotationId,
244
+ setPulsedAnnotationId,
245
+ selectCardFromDocument,
246
+ runEditorHandOff,
247
+ openCardEdit,
248
+ });
372
249
 
373
250
  const overlay: KeyState["overlay"] =
374
251
  mode.type === "compose" || mode.type === "railEdit"
@@ -386,7 +263,10 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
386
263
  useKeyboard((key) => {
387
264
  const state: KeyState = {
388
265
  keys: keysRef.current,
389
- readOnly,
266
+ readOnly: observer,
267
+ canEditPlan: isOwner,
268
+ canSubmitVerdict: isOwner,
269
+ canShare: isOwner,
390
270
  overlay,
391
271
  view: !session ? "inbox" : isDiff ? "diff" : "plan",
392
272
  spanMode: mode.type === "span",
@@ -479,14 +359,23 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
479
359
  };
480
360
 
481
361
  const onEditRequest = (): void => {
482
- if (readOnly) return controller.setStatus("observer - read-only");
362
+ // A share viewer/observer has no Edit affordance (the button is hidden), so
363
+ // this is owner-only; stay silent rather than nag if it is ever reached.
364
+ if (!isOwner) return;
483
365
  if (resolved) return controller.setStatus("review submitted - read-only");
484
366
  runEditorHandOff();
485
367
  };
486
368
 
369
+ // clicking the rail Share button: publish the plan, copy the ssh line
370
+ const onShareRequest = (): void => {
371
+ if (!isOwner) return controller.setStatus("only the plan owner can share");
372
+ controller.share();
373
+ };
374
+
487
375
  // clicking the rail Submit button: same read-only answer as the submit key
488
376
  const onSubmitRequest = (): void => {
489
- if (readOnly) return controller.setStatus("observer - read-only");
377
+ if (observer) return controller.setStatus("observer - read-only");
378
+ if (!isOwner) return controller.setStatus("shared view - your notes save as you go");
490
379
  if (resolved) return;
491
380
  dispatch({ type: "openSubmit" });
492
381
  };
@@ -543,12 +432,13 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
543
432
  { label: "cueloop", tone: "accent" },
544
433
  { label: `${activeSession.artifact.meta.title ?? activeSession.artifact.meta.planPath ?? activeSession.id} · rev ${activeSession.revisions.length}`, tone: "dim" },
545
434
  ...(resolved ? [{ label: `resolved: ${activeSession.verdict!.kind.replace("_", " ")}`, tone: "green" as const }] : []),
546
- ...(readOnly ? [{ label: "observer", tone: "dim" as const }] : []),
435
+ ...(observer ? [{ label: "observer", tone: "dim" as const }] : []),
436
+ ...(role === "collaborator" ? [{ label: "shared · your notes save as you go", tone: "dim" as const }] : []),
547
437
  ...(status ? [{ label: status, tone: "accent" as const }] : []),
548
438
  ];
549
439
 
550
440
  keyBindings.setContext({ overlay, spanMode: mode.type === "span" });
551
- const hintMode: HintMode = readOnly
441
+ const hintMode: HintMode = observer
552
442
  ? "read-only"
553
443
  : mode.type === "submit"
554
444
  ? "submit"
@@ -560,7 +450,9 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
560
450
  ? "walk"
561
451
  : focusedAnnotationId !== undefined
562
452
  ? "card"
563
- : "normal";
453
+ : role === "collaborator"
454
+ ? "collaborator"
455
+ : "normal";
564
456
 
565
457
  return (
566
458
  <ThemeProvider theme={theme}>
@@ -601,6 +493,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
601
493
  editOrphanCount={editOrphanCount}
602
494
  onLineActivate={onLineActivate}
603
495
  onEditRequest={onEditRequest}
496
+ canEdit={isOwner}
604
497
  />
605
498
  )}
606
499
  <ReviewPanel
@@ -623,6 +516,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
623
516
  onSelectCard: selectCardFromRail,
624
517
  onActivateCard: openCardEdit,
625
518
  onSubmitRequest,
519
+ onShareRequest: isOwner ? onShareRequest : undefined,
626
520
  }}
627
521
  />
628
522
  </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
+ }
@@ -6,7 +6,7 @@ import { fixtureDisplay, fixtureMarks, fixturePlanSession } from "./story-fixtur
6
6
 
7
7
  export const meta: StoryMeta = { title: "PlanSheet" };
8
8
 
9
- const callbacks = { onLineActivate: () => {}, onEditRequest: () => {} };
9
+ const callbacks = { onLineActivate: () => {}, onEditRequest: () => {}, canEdit: true };
10
10
 
11
11
  export const AnnotatedPlan: Story = {
12
12
  render: () => (
@@ -64,6 +64,8 @@ export interface PlanSheetProps {
64
64
  editOrphanCount: number;
65
65
  onLineActivate: (displayIndex: number) => void;
66
66
  onEditRequest: () => void;
67
+ /** Owner-only: show the Edit affordance. A share viewer never sees it. */
68
+ canEdit: boolean;
67
69
  theme?: Theme;
68
70
  }
69
71
 
@@ -97,6 +99,7 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
97
99
  editOrphanCount,
98
100
  onLineActivate,
99
101
  onEditRequest,
102
+ canEdit,
100
103
  theme,
101
104
  }: PlanSheetProps,
102
105
  handleRef,
@@ -215,7 +218,7 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
215
218
 
216
219
  return (
217
220
  <box style={{ flexGrow: 1, flexDirection: "column" }}>
218
- <SheetHeader session={session} onEditRequest={onEditRequest} theme={theme} />
221
+ <SheetHeader session={session} onEditRequest={onEditRequest} canEdit={canEdit} theme={theme} />
219
222
  {editOrphanCount > 0 ? (
220
223
  <box style={{ height: 1, backgroundColor: tokens.markCommentBg, paddingLeft: 2 }}>
221
224
  <text fg={tokens.red}>
@@ -234,10 +237,12 @@ export const PlanSheet = forwardRef<PlanSheetHandle, PlanSheetProps>(function Pl
234
237
  function SheetHeader({
235
238
  session,
236
239
  onEditRequest,
240
+ canEdit,
237
241
  theme,
238
242
  }: {
239
243
  session: ReviewSession;
240
244
  onEditRequest: () => void;
245
+ canEdit: boolean;
241
246
  theme?: Theme;
242
247
  }): React.ReactNode {
243
248
  const tokens = useComponentTheme(theme);
@@ -255,11 +260,13 @@ function SheetHeader({
255
260
  ) : null}
256
261
  </text>
257
262
  <box style={{ flexGrow: 1 }} />
258
- <Toolbar>
259
- <Button onPress={onEditRequest} theme={theme}>
260
- {" Edit "}
261
- </Button>
262
- </Toolbar>
263
+ {canEdit ? (
264
+ <Toolbar>
265
+ <Button onPress={onEditRequest} theme={theme}>
266
+ {" Edit "}
267
+ </Button>
268
+ </Toolbar>
269
+ ) : null}
263
270
  </box>
264
271
  );
265
272
  }