@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/package.json +5 -5
- package/src/App.autoclose.test.tsx +29 -5
- package/src/App.diff.test.tsx +20 -0
- package/src/App.export.test.tsx +12 -8
- package/src/App.plan-review.test.tsx +140 -7
- package/src/App.readonly.test.tsx +19 -5
- package/src/App.rendering.test.tsx +15 -0
- package/src/App.review-panel.test.tsx +29 -6
- package/src/App.submit-confirm.test.tsx +57 -6
- package/src/App.test.tsx +62 -5
- package/src/App.tsx +77 -183
- package/src/App.walk.test.tsx +63 -8
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +26 -0
- package/src/components/PlanSheet.stories.tsx +1 -1
- package/src/components/PlanSheet.tsx +13 -6
- package/src/components/ReviewRail.tsx +16 -4
- package/src/components/stories.test.tsx +5 -0
- package/src/config.test.ts +62 -0
- package/src/config.ts +1 -0
- package/src/editor.test.ts +24 -0
- package/src/herdr.test.ts +4 -0
- package/src/index.ts +2 -0
- package/src/integrations.test.ts +12 -0
- package/src/intent-dispatch.test.ts +213 -0
- package/src/intent-dispatch.ts +270 -0
- package/src/key-bindings.test.ts +27 -5
- package/src/key-bindings.ts +11 -1
- package/src/keymap.test.ts +97 -5
- package/src/keymap.ts +25 -2
- package/src/serve.test.ts +7 -0
- package/src/session-controller.ts +22 -3
- package/src/share.ts +47 -0
- package/src/view-diff.test.ts +11 -0
- package/src/view-diff.ts +6 -6
- package/src/view-plan.test.ts +81 -0
- package/src/view-plan.ts +3 -0
- package/src/walk.test.ts +6 -0
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 {
|
|
16
|
-
import { displayText, marksByDisplay,
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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 (
|
|
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 = (
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
...(
|
|
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 =
|
|
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
|
-
: "
|
|
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>
|
package/src/App.walk.test.tsx
CHANGED
|
@@ -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
|
+
});
|
package/src/clipboard.ts
ADDED
|
@@ -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
|
-
|
|
259
|
-
<
|
|
260
|
-
{
|
|
261
|
-
|
|
262
|
-
|
|
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
|
}
|