@cueloop/client 0.1.0-alpha.20 → 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.
Files changed (47) hide show
  1. package/package.json +5 -5
  2. package/src/App.autoclose.test.tsx +29 -5
  3. package/src/App.diff.test.tsx +20 -0
  4. package/src/App.export.test.tsx +12 -8
  5. package/src/App.plan-review.test.tsx +182 -7
  6. package/src/App.readonly.test.tsx +19 -5
  7. package/src/App.rendering.test.tsx +21 -2
  8. package/src/App.review-panel.test.tsx +120 -0
  9. package/src/App.submit-confirm.test.tsx +57 -6
  10. package/src/App.test.tsx +62 -5
  11. package/src/App.tsx +147 -185
  12. package/src/App.walk.test.tsx +63 -8
  13. package/src/clipboard.test.ts +23 -0
  14. package/src/clipboard.ts +26 -0
  15. package/src/components/AnnotationCard.compose-rows.test.ts +49 -0
  16. package/src/components/AnnotationCard.tsx +66 -13
  17. package/src/components/ReviewPanel.stories.tsx +86 -0
  18. package/src/components/ReviewPanel.tsx +116 -0
  19. package/src/components/ReviewRail.tsx +30 -5
  20. package/src/components/__snapshots__/stories.test.tsx.snap +140 -0
  21. package/src/components/primitives/Card.tsx +2 -1
  22. package/src/components/primitives/Dialog.tsx +2 -1
  23. package/src/components/primitives/frame.test.ts +12 -0
  24. package/src/components/primitives/frame.ts +14 -0
  25. package/src/components/stories-app.tsx +2 -1
  26. package/src/components/stories.test.tsx +5 -0
  27. package/src/config.test.ts +113 -1
  28. package/src/config.ts +58 -22
  29. package/src/editor.test.ts +24 -0
  30. package/src/herdr.test.ts +4 -0
  31. package/src/index.ts +2 -0
  32. package/src/integrations.test.ts +12 -0
  33. package/src/intent-dispatch.test.ts +213 -0
  34. package/src/intent-dispatch.ts +270 -0
  35. package/src/key-bindings.test.ts +16 -5
  36. package/src/keymap.test.ts +141 -9
  37. package/src/keymap.ts +54 -7
  38. package/src/review-panel.test.ts +111 -0
  39. package/src/review-panel.ts +76 -0
  40. package/src/serve.test.ts +7 -0
  41. package/src/session-controller.ts +39 -4
  42. package/src/share.ts +47 -0
  43. package/src/view-diff.test.ts +11 -0
  44. package/src/view-diff.ts +6 -6
  45. package/src/view-plan.test.ts +81 -0
  46. package/src/view-plan.ts +3 -0
  47. package/src/walk.test.ts +6 -0
package/src/App.tsx CHANGED
@@ -11,28 +11,43 @@
11
11
 
12
12
  import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
13
13
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
14
- import type { Clock } 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";
14
+ import type { Clock, MouseEvent } from "@opentui/core";
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";
26
28
  import { Breadcrumb, type BreadcrumbItem } from "./components/Breadcrumb";
27
29
  import { PlanSheet, type PlanSheetHandle } from "./components/PlanSheet";
28
30
  import { DiffSheet } from "./components/DiffSheet";
29
- import { ReviewRail, annotationBlocking, type ReviewRailHandle } from "./components/ReviewRail";
30
- import { VERDICTS } from "./components/ConfirmCard";
31
+ import { annotationBlocking, type ReviewRailHandle } from "./components/ReviewRail";
32
+ import { ReviewPanel } from "./components/ReviewPanel";
33
+ import {
34
+ REVIEW_DEFAULT_WIDTH,
35
+ resolveReviewWidth,
36
+ toggleReviewPanelMode,
37
+ widthFromMouseColumn,
38
+ type ReviewPanelMode,
39
+ } from "./review-panel";
31
40
  import { CompletionOverlay } from "./components/CompletionOverlay";
32
41
  import { InboxList } from "./components/InboxList";
33
42
  import { ComposeBar } from "./components/ComposeBar";
34
43
  import { WalkWizard } from "./components/WalkWizard";
35
44
 
45
+ /**
46
+ * The breadcrumb header and the status bar each occupy one terminal row; the
47
+ * review layout (plan column, divider, rail) gets the rows that remain.
48
+ */
49
+ const CHROME_ROWS = 2;
50
+
36
51
  export interface AppProps {
37
52
  home?: string;
38
53
  sessionId?: string;
@@ -44,27 +59,26 @@ export interface AppProps {
44
59
  onExit?: (code: number) => void;
45
60
  /** Timer source for the auto-close countdown; tests inject a ManualClock. */
46
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";
47
70
  }
48
71
 
49
- type Mode =
50
- | { type: "normal" }
51
- | { type: "span"; span: SpanState }
52
- | { type: "compose"; kind: "comment" | "suggestion"; displayIndex: number; start: number; end: number; text: string }
53
- | { type: "railEdit"; id: string; text: string }
54
- | { type: "submit"; verdict: VerdictKind; summary: string };
55
-
56
- /** Reviewer-authored annotations only: agent notes never count as feedback. */
57
- function reviewerAnnotations(session: ReviewSession) {
58
- return session.annotations.filter((annotation) => !isAgentNote(annotation));
59
- }
60
-
61
- function defaultVerdict(session: ReviewSession): VerdictKind {
62
- return reviewerAnnotations(session).length || session.workingCopy !== undefined ? "request_changes" : "approve";
63
- }
64
-
65
- 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";
66
80
  const controller = useMemo(
67
- () => createReviewController({ home, sessionId, readOnly, onExit, clock }),
81
+ () => createReviewController({ home, sessionId, readOnly: observer, onExit, clock, openClient }),
68
82
  // eslint-disable-next-line react-hooks/exhaustive-deps
69
83
  [home, sessionId],
70
84
  );
@@ -78,7 +92,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
78
92
  controller.getSnapshot,
79
93
  );
80
94
  const renderer = useRenderer();
81
- const { width: terminalWidth } = useTerminalDimensions();
95
+ const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions();
82
96
 
83
97
  // ── view state ──────────────────────────────
84
98
  const [cursor, setCursor] = useState(0);
@@ -86,6 +100,13 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
86
100
  const [mode, setMode] = useState<Mode>({ type: "normal" });
87
101
  const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
88
102
  const [railTab, setRailTab] = useState<"review" | "agent">("review");
103
+ // review panel layout: mode + expanded width are client view state, loaded
104
+ // from and persisted to the user config so they survive a restart. The ref
105
+ // mirrors the width so the drag-end persist reads the latest value.
106
+ const [reviewMode, setReviewMode] = useState<ReviewPanelMode>("expanded");
107
+ const [reviewWidth, setReviewWidth] = useState(REVIEW_DEFAULT_WIDTH);
108
+ const [dividerDragging, setDividerDragging] = useState(false);
109
+ const reviewWidthRef = useRef(REVIEW_DEFAULT_WIDTH);
89
110
  // ~2s focus pulse on the document highlight when a rail card is activated
90
111
  const [pulsedAnnotationId, setPulsedAnnotationId] = useState<string | null>(null);
91
112
  const pulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -103,6 +124,9 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
103
124
  keysRef.current = config.keys;
104
125
  keyBindings.setKeys(config.keys);
105
126
  setTheme(config.theme);
127
+ setReviewMode(config.ui.reviewState);
128
+ setReviewWidth(config.ui.reviewWidth);
129
+ reviewWidthRef.current = config.ui.reviewWidth;
106
130
  controller.applyConfig(config);
107
131
  }, [session?.workspace.repoRoot, controller, keyBindings]);
108
132
  useEffect(
@@ -172,7 +196,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
172
196
  };
173
197
 
174
198
  const openCardEdit = (annotationId: string): void => {
175
- if (readOnly) return controller.setStatus("observer - read-only");
199
+ if (observer) return controller.setStatus("observer - read-only");
176
200
  if (resolved) return controller.setStatus("review submitted - read-only");
177
201
  const annotation = session?.annotations.find((candidate) => candidate.id === annotationId);
178
202
  if (!annotation) return;
@@ -191,143 +215,36 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
191
215
  };
192
216
 
193
217
  // ── keyboard grammar: build state, reduce, dispatch ──
194
- const dispatch = (intent: Intent): void => {
195
- switch (intent.type) {
196
- case "exit":
197
- return void onExit?.(0);
198
- case "status":
199
- return controller.setStatus(intent.message);
200
- case "move": {
201
- const navigableCount = isDiff ? rows.length : display.length;
202
- if (intent.to === "down") setCursor((current) => Math.min(navigableCount - 1, current + 1));
203
- else if (intent.to === "up") setCursor((current) => Math.max(0, current - 1));
204
- else if (intent.to === "top") setCursor(0);
205
- else setCursor(navigableCount - 1);
206
- return;
207
- }
208
- case "inboxMove": {
209
- const navigableCount = inbox?.length ?? 0;
210
- setInboxCursor((current) => (intent.to === "down" ? Math.min(navigableCount - 1, current + 1) : Math.max(0, current - 1)));
211
- return;
212
- }
213
- case "openSession": {
214
- const selected = inbox?.[inboxCursor];
215
- if (selected) controller.open(selected.id);
216
- return;
217
- }
218
- case "startSpan": {
219
- const block = display[cursor];
220
- if (!block?.work) return;
221
- const span = startSpan(cursor, displayText(block));
222
- if (span) setMode({ type: "span", span });
223
- return;
224
- }
225
- case "spanKey":
226
- if (mode.type === "span") {
227
- const span = spanKey(mode.span, intent.name, displayText(display[mode.span.displayIndex]!));
228
- setMode({ type: "span", span });
229
- }
230
- return;
231
- case "openCompose": {
232
- liveInput.current = "";
233
- if (intent.from === "span" && mode.type === "span") {
234
- setMode({
235
- type: "compose",
236
- kind: intent.kind,
237
- displayIndex: mode.span.displayIndex,
238
- start: mode.span.start,
239
- end: mode.span.end,
240
- text: "",
241
- });
242
- } else if (isDiff) {
243
- const row = rows[cursor];
244
- if (row) setMode({ type: "compose", kind: intent.kind, displayIndex: cursor, start: 0, end: row.text.length, text: "" });
245
- } else {
246
- // a mouse drag leaves a native selection; it wins over the cursor block
247
- const native = planSheetRef.current?.readSelection() ?? null;
248
- if (native) {
249
- setMode({ type: "compose", kind: intent.kind, ...native, text: "" });
250
- } else {
251
- const block = display[cursor];
252
- if (block) setMode({ type: "compose", kind: intent.kind, displayIndex: cursor, start: 0, end: displayText(block).length, text: "" });
253
- }
254
- }
255
- return;
256
- }
257
- case "openSubmit":
258
- if (!session) return;
259
- liveInput.current = "";
260
- // the confirm card lives in the review tab; opening submit reveals it
261
- setRailTab("review");
262
- return void setMode({ type: "submit", verdict: defaultVerdict(session), summary: "" });
263
- case "cut":
264
- return controller.cut(cursor);
265
- case "edit":
266
- return runEditorHandOff();
267
- case "editCard":
268
- if (focusedAnnotationId) openCardEdit(focusedAnnotationId);
269
- return;
270
- case "nextAnnotation":
271
- case "prevAnnotation": {
272
- const annotations = session?.annotations ?? [];
273
- if (!annotations.length) return;
274
- const focusedIndex = annotations.findIndex((annotation) => annotation.id === focusedAnnotationId);
275
- const nextIndex =
276
- focusedIndex === -1
277
- ? 0
278
- : (focusedIndex + (intent.type === "nextAnnotation" ? 1 : -1) + annotations.length) % annotations.length;
279
- return void selectCardFromDocument(annotations[nextIndex]!.id);
280
- }
281
- case "walkStart":
282
- return controller.walkStart();
283
- case "walkForward":
284
- return controller.walkForward();
285
- case "walkBack":
286
- return controller.walkBack();
287
- case "walkLeave":
288
- return controller.walkLeave();
289
- case "removeAnnotation":
290
- if (focusedAnnotationId) {
291
- controller.removeAnnotation(focusedAnnotationId);
292
- setFocusedAnnotationId(undefined);
293
- }
294
- return;
295
- case "deselect":
296
- planSheetRef.current?.clearSelection();
297
- setFocusedAnnotationId(undefined);
298
- setPulsedAnnotationId(null);
299
- return;
300
- case "closeOverlay":
301
- return void setMode({ type: "normal" });
302
- case "saveCompose": {
303
- const body = liveInput.current.trim();
304
- if (mode.type === "railEdit") {
305
- if (session && body) controller.updateAnnotation(mode.id, body);
306
- return void setMode({ type: "normal" });
307
- }
308
- if (mode.type !== "compose") return;
309
- if (session && body) {
310
- const annotationId = controller.annotate(mode.kind, mode.displayIndex, mode.start, mode.end, body);
311
- if (annotationId) setFocusedAnnotationId(annotationId);
312
- }
313
- return void setMode({ type: "normal" });
314
- }
315
- case "submitVerdict":
316
- if (mode.type === "submit") controller.submit(mode.verdict, liveInput.current);
317
- return void setMode({ type: "normal" });
318
- case "cycleVerdict": {
319
- if (mode.type !== "submit") return;
320
- const verdictIndex = (VERDICTS.indexOf(mode.verdict) + intent.dir + VERDICTS.length) % VERDICTS.length;
321
- return void setMode({ ...mode, verdict: VERDICTS[verdictIndex]! });
322
- }
323
- case "finishReview":
324
- return controller.finishReview();
325
- case "optInAutoClose":
326
- return controller.optInAutoClose();
327
- case "dismissCompletion":
328
- return controller.dismissCompletion();
329
- }
330
- };
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
+ });
331
248
 
332
249
  const overlay: KeyState["overlay"] =
333
250
  mode.type === "compose" || mode.type === "railEdit"
@@ -345,7 +262,10 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
345
262
  useKeyboard((key) => {
346
263
  const state: KeyState = {
347
264
  keys: keysRef.current,
348
- readOnly,
265
+ readOnly: observer,
266
+ canEditPlan,
267
+ canSubmitVerdict,
268
+ canShare,
349
269
  overlay,
350
270
  view: !session ? "inbox" : isDiff ? "diff" : "plan",
351
271
  spanMode: mode.type === "span",
@@ -360,7 +280,8 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
360
280
  };
361
281
  keyBindings.setContext({ overlay: state.overlay, spanMode: state.spanMode });
362
282
  const action = keyBindings.resolveAction({ name: key.name, shift: !!key.shift });
363
- for (const intent of reduceKey(state, { name: key.name, shift: !!key.shift }, action)) dispatch(intent);
283
+ for (const intent of reduceKey(state, { name: key.name, shift: !!key.shift, meta: !!key.meta }, action))
284
+ dispatch(intent);
364
285
  });
365
286
 
366
287
  // ── render ──────────────────────────────────
@@ -437,18 +358,38 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
437
358
  };
438
359
 
439
360
  const onEditRequest = (): void => {
440
- 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");
441
363
  if (resolved) return controller.setStatus("review submitted - read-only");
442
364
  runEditorHandOff();
443
365
  };
444
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
+
445
373
  // clicking the rail Submit button: same read-only answer as the submit key
446
374
  const onSubmitRequest = (): void => {
447
- 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");
448
377
  if (resolved) return;
449
378
  dispatch({ type: "openSubmit" });
450
379
  };
451
380
 
381
+ // the clickable chevron toggles expanded <-> compact; the keybinding (b)
382
+ // cycles all three including hidden
383
+ const onToggleReviewPanel = (): void => {
384
+ const next = toggleReviewPanelMode(reviewMode);
385
+ setReviewMode(next);
386
+ controller.saveReviewPanel({ mode: next });
387
+ };
388
+ // grabbing the divider only arms a drag when there is a width to drag
389
+ const onDividerGrab = (): void => {
390
+ if (reviewMode === "expanded") setDividerDragging(true);
391
+ };
392
+
452
393
  const submitConfirmState =
453
394
  mode.type === "submit"
454
395
  ? {
@@ -489,12 +430,13 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
489
430
  { label: "cueloop", tone: "accent" },
490
431
  { label: `${activeSession.artifact.meta.title ?? activeSession.artifact.meta.planPath ?? activeSession.id} · rev ${activeSession.revisions.length}`, tone: "dim" },
491
432
  ...(resolved ? [{ label: `resolved: ${activeSession.verdict!.kind.replace("_", " ")}`, tone: "green" as const }] : []),
492
- ...(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 }] : []),
493
435
  ...(status ? [{ label: status, tone: "accent" as const }] : []),
494
436
  ];
495
437
 
496
438
  keyBindings.setContext({ overlay, spanMode: mode.type === "span" });
497
- const hintMode: HintMode = readOnly
439
+ const hintMode: HintMode = observer
498
440
  ? "read-only"
499
441
  : mode.type === "submit"
500
442
  ? "submit"
@@ -507,11 +449,23 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
507
449
  : focusedAnnotationId !== undefined
508
450
  ? "card"
509
451
  : "normal";
510
- const railWidth = terminalWidth >= 100 ? 34 : 28;
511
452
 
512
453
  return (
513
454
  <ThemeProvider theme={theme}>
514
- <box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: theme.bg }}>
455
+ <box
456
+ style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: theme.bg }}
457
+ onMouseDrag={(event: MouseEvent) => {
458
+ if (!dividerDragging || reviewMode !== "expanded") return;
459
+ const next = widthFromMouseColumn(event.x, terminalWidth);
460
+ reviewWidthRef.current = next;
461
+ setReviewWidth(next);
462
+ }}
463
+ onMouseUp={() => {
464
+ if (!dividerDragging) return;
465
+ setDividerDragging(false);
466
+ controller.saveReviewPanel({ width: reviewWidthRef.current });
467
+ }}
468
+ >
515
469
  <Breadcrumb items={headerItems} />
516
470
  <box style={{ flexGrow: 1, flexDirection: "row" }}>
517
471
  {isDiff ? (
@@ -537,20 +491,28 @@ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppPro
537
491
  onEditRequest={onEditRequest}
538
492
  />
539
493
  )}
540
- <ReviewRail
541
- ref={railRef}
542
- session={activeSession}
543
- selectedId={focusedAnnotationId}
544
- resolvedIds={isDiff ? null : resolvedIds}
545
- railTab={railTab}
546
- pendingCount={pendingCount}
547
- cardEdit={cardEditState}
548
- submitConfirm={submitConfirmState}
549
- onTabChange={setRailTab}
550
- onSelectCard={selectCardFromRail}
551
- onActivateCard={openCardEdit}
552
- onSubmitRequest={onSubmitRequest}
553
- width={railWidth}
494
+ <ReviewPanel
495
+ mode={reviewMode}
496
+ width={resolveReviewWidth(reviewWidth, terminalWidth)}
497
+ height={terminalHeight - CHROME_ROWS}
498
+ dragging={dividerDragging}
499
+ onDividerGrab={onDividerGrab}
500
+ onToggle={onToggleReviewPanel}
501
+ railRef={railRef}
502
+ rail={{
503
+ session: activeSession,
504
+ selectedId: focusedAnnotationId,
505
+ resolvedIds: isDiff ? null : resolvedIds,
506
+ railTab,
507
+ pendingCount,
508
+ cardEdit: cardEditState,
509
+ submitConfirm: submitConfirmState,
510
+ onTabChange: setRailTab,
511
+ onSelectCard: selectCardFromRail,
512
+ onActivateCard: openCardEdit,
513
+ onSubmitRequest,
514
+ onShareRequest: canShare ? onShareRequest : undefined,
515
+ }}
554
516
  />
555
517
  </box>
556
518
  {mode.type === "compose" && isDiff ? (
@@ -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
+ });