@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/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 +121 -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 +71 -182
- package/src/App.walk.test.tsx +63 -8
- package/src/clipboard.test.ts +23 -0
- package/src/clipboard.ts +26 -0
- 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 +16 -5
- package/src/keymap.test.ts +97 -5
- package/src/keymap.ts +24 -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,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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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 (
|
|
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 = (
|
|
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
|
-
};
|
|
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 (
|
|
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 (
|
|
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
|
-
...(
|
|
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 =
|
|
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>
|
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
|
+
}
|
|
@@ -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
|
|
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
|
-
{
|
|
105
|
-
|
|
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
|
-
{
|
|
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>();
|