@cueloop/client 0.1.0-alpha.32 → 0.1.0-alpha.34

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 (101) hide show
  1. package/package.json +27 -26
  2. package/src/App.autoclose.test.tsx +7 -4
  3. package/src/App.diff.test.tsx +131 -5
  4. package/src/App.export.test.tsx +23 -5
  5. package/src/App.plan-review.test.tsx +133 -32
  6. package/src/App.readonly.test.tsx +35 -13
  7. package/src/App.rendering.test.tsx +14 -4
  8. package/src/App.review-panel.test.tsx +27 -15
  9. package/src/App.submit-confirm.test.tsx +14 -13
  10. package/src/App.test.tsx +50 -13
  11. package/src/App.toast.test.tsx +75 -0
  12. package/src/App.tsx +505 -101
  13. package/src/App.walk.test.tsx +31 -11
  14. package/src/attribution.ts +8 -2
  15. package/src/clipboard.ts +5 -1
  16. package/src/components/AnnotationCard.stories.tsx +29 -10
  17. package/src/components/AnnotationCard.tsx +57 -37
  18. package/src/components/Breadcrumb.tsx +7 -1
  19. package/src/components/CodeBlock.stories.tsx +19 -2
  20. package/src/components/CodeBlock.tsx +24 -9
  21. package/src/components/CompletionOverlay.tsx +13 -3
  22. package/src/components/ConfirmCard.stories.tsx +22 -3
  23. package/src/components/ConfirmCard.tsx +23 -5
  24. package/src/components/ConfirmDialog.tsx +10 -2
  25. package/src/components/DiffSheet.scroll.test.tsx +123 -0
  26. package/src/components/DiffSheet.stories.tsx +35 -1
  27. package/src/components/DiffSheet.syntax.test.tsx +62 -0
  28. package/src/components/DiffSheet.tsx +213 -101
  29. package/src/components/InboxList.stories.tsx +16 -3
  30. package/src/components/InboxList.tsx +17 -10
  31. package/src/components/KeybindsDialog.stories.tsx +31 -0
  32. package/src/components/KeybindsDialog.tsx +58 -0
  33. package/src/components/MarkerPopover.stories.tsx +58 -0
  34. package/src/components/MarkerPopover.tsx +102 -0
  35. package/src/components/MenuBar.stories.tsx +37 -0
  36. package/src/components/MenuBar.tsx +74 -0
  37. package/src/components/PlanSheet.stories.tsx +35 -3
  38. package/src/components/PlanSheet.tsx +160 -100
  39. package/src/components/PromptDialog.tsx +19 -3
  40. package/src/components/ReviewPanel.stories.tsx +17 -19
  41. package/src/components/ReviewPanel.tsx +17 -32
  42. package/src/components/ReviewRail.stories.tsx +159 -47
  43. package/src/components/ReviewRail.tsx +206 -62
  44. package/src/components/SettingsDialog.stories.tsx +51 -3
  45. package/src/components/SettingsDialog.tsx +11 -5
  46. package/src/components/SettingsRows.stories.tsx +1 -1
  47. package/src/components/SettingsRows.tsx +19 -3
  48. package/src/components/Toast.stories.tsx +12 -0
  49. package/src/components/Toast.tsx +44 -0
  50. package/src/components/WalkWizard.stories.tsx +13 -2
  51. package/src/components/WalkWizard.tsx +9 -3
  52. package/src/components/__snapshots__/stories.test.tsx.snap +853 -623
  53. package/src/components/diff-sheet-layout.test.ts +127 -0
  54. package/src/components/diff-sheet-layout.ts +167 -0
  55. package/src/components/primitives/Button.stories.tsx +2 -2
  56. package/src/components/primitives/Button.tsx +5 -1
  57. package/src/components/primitives/Dialog.tsx +12 -1
  58. package/src/components/primitives/Tabs.tsx +40 -39
  59. package/src/components/stories-app.tsx +34 -6
  60. package/src/components/story-fixtures.ts +11 -3
  61. package/src/components/story.ts +3 -1
  62. package/src/components/syntax-highlight.ts +47 -12
  63. package/src/components/theme-context.stories.tsx +6 -1
  64. package/src/components/theme-context.tsx +7 -1
  65. package/src/config.test.ts +178 -2
  66. package/src/config.ts +121 -23
  67. package/src/diff-hunk-curate.test.ts +315 -0
  68. package/src/diff-hunk-curate.ts +233 -0
  69. package/src/diff-intraline.test.ts +164 -0
  70. package/src/diff-intraline.ts +132 -0
  71. package/src/diff-syntax.test.ts +96 -0
  72. package/src/diff-syntax.ts +154 -0
  73. package/src/editor.test.ts +13 -4
  74. package/src/editor.ts +31 -5
  75. package/src/herdr.ts +8 -2
  76. package/src/index.ts +9 -1
  77. package/src/integrations.ts +3 -1
  78. package/src/intent-dispatch.test.ts +193 -9
  79. package/src/intent-dispatch.ts +151 -18
  80. package/src/key-bindings.test.ts +19 -9
  81. package/src/key-bindings.ts +77 -16
  82. package/src/keymap.test.ts +171 -26
  83. package/src/keymap.ts +83 -16
  84. package/src/review-panel.test.ts +3 -1
  85. package/src/review-panel.ts +9 -2
  86. package/src/serve.test.ts +13 -5
  87. package/src/session-controller.curate.test.ts +253 -0
  88. package/src/session-controller.share.test.ts +77 -16
  89. package/src/session-controller.ts +319 -26
  90. package/src/share.ts +41 -8
  91. package/src/test-support.ts +97 -23
  92. package/src/theme-presets.ts +168 -0
  93. package/src/theme.ts +1 -3
  94. package/src/version.ts +5 -0
  95. package/src/view-diff.ts +15 -4
  96. package/src/view-plan.test.ts +40 -6
  97. package/src/view-plan.ts +71 -28
  98. package/src/walk.test.ts +7 -2
  99. package/src/walk.ts +7 -2
  100. package/src/components/ComposeBar.stories.tsx +0 -18
  101. package/src/components/ComposeBar.tsx +0 -54
package/src/App.tsx CHANGED
@@ -13,24 +13,52 @@ import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } fro
13
13
  import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
14
14
  import type { Clock, MouseEvent } from "@opentui/core";
15
15
  import { type VerdictKind } from "@cueloop/schema";
16
- import { displayText, marksByDisplay, type Mark } from "./view-plan";
16
+ import { displayText, marksByDisplay, spanFromRange, type Mark } from "./view-plan";
17
17
  import { noteForFile, viewedCount } from "./walk";
18
18
  import { DARK, dimmedTheme } from "./theme";
19
- import { DEFAULT_KEYS, loadConfig, persistAuthorName } from "./config";
19
+ import {
20
+ DEFAULT_KEYS,
21
+ DEFAULT_QUICK_ACTIONS,
22
+ loadConfig,
23
+ persistAuthorName,
24
+ persistAutoClose,
25
+ persistTheme,
26
+ type AutoClose,
27
+ type QuickAction,
28
+ } from "./config";
29
+ import {
30
+ composeTheme,
31
+ DEFAULT_THEME_NAME,
32
+ THEME_LABELS,
33
+ THEME_NAMES,
34
+ type ThemeName,
35
+ } from "./theme-presets";
36
+ import type { Theme } from "./theme";
20
37
  import { returnPaneFor } from "@cueloop/schema";
21
38
  import { createReviewController } from "./session-controller";
22
39
  import type { SessionClient } from "@cueloop/daemon/client";
23
- import { createIntentDispatch, reviewerAnnotations, type Mode } from "./intent-dispatch";
40
+ import {
41
+ activeSpanState,
42
+ createIntentDispatch,
43
+ reviewerAnnotations,
44
+ type Mode,
45
+ } from "./intent-dispatch";
24
46
  import { reduceKey, type KeyState } from "./keymap";
25
- import { KeyBindings, type HintMode } from "./key-bindings";
47
+ import { KeyBindings } from "./key-bindings";
26
48
  import { ThemeProvider } from "./components/theme-context";
27
- import { StatusBar } from "./components/primitives/StatusBar";
49
+ import { Button } from "./components/primitives/Button";
50
+ import { Toolbar } from "./components/primitives/Toolbar";
51
+ import { MenuBar } from "./components/MenuBar";
52
+ import { KeybindsDialog } from "./components/KeybindsDialog";
53
+ import { SettingsDialog, type SettingsCategory } from "./components/SettingsDialog";
54
+ import { CLIENT_VERSION } from "./version";
28
55
  import { Breadcrumb, type BreadcrumbItem } from "./components/Breadcrumb";
29
56
  import { PlanSheet, type PlanSheetHandle } from "./components/PlanSheet";
30
57
  import { DiffSheet } from "./components/DiffSheet";
31
58
  import { annotationBlocking, type ReviewRailHandle } from "./components/ReviewRail";
32
59
  import { ReviewPanel } from "./components/ReviewPanel";
33
60
  import {
61
+ REVIEW_COMPACT_WIDTH,
34
62
  REVIEW_DEFAULT_WIDTH,
35
63
  resolveReviewWidth,
36
64
  toggleReviewPanelMode,
@@ -41,14 +69,11 @@ import { CompletionOverlay } from "./components/CompletionOverlay";
41
69
  import { InboxList } from "./components/InboxList";
42
70
  import { ConfirmDialog } from "./components/ConfirmDialog";
43
71
  import { PromptDialog } from "./components/PromptDialog";
44
- import { ComposeBar } from "./components/ComposeBar";
45
72
  import { WalkWizard } from "./components/WalkWizard";
73
+ import { Toast } from "./components/Toast";
46
74
 
47
- /**
48
- * The breadcrumb header and the status bar each occupy one terminal row; the
49
- * review layout (plan column, divider, rail) gets the rows that remain.
50
- */
51
- const CHROME_ROWS = 2;
75
+ /** A toast clears itself after this idle; esc dismisses it sooner. */
76
+ const TOAST_DISMISS_MS = 4000;
52
77
 
53
78
  export interface AppProps {
54
79
  home?: string;
@@ -76,7 +101,16 @@ export interface AppProps {
76
101
  selfAuthor?: string;
77
102
  }
78
103
 
79
- export function App({ home, sessionId, readOnly = false, onExit, clock, openClient, role = "owner", selfAuthor }: AppProps): React.ReactNode {
104
+ export function App({
105
+ home,
106
+ sessionId,
107
+ readOnly = false,
108
+ onExit,
109
+ clock,
110
+ openClient,
111
+ role = "owner",
112
+ selfAuthor,
113
+ }: AppProps): React.ReactNode {
80
114
  // Observer stays fully read-only; a collaborator writes annotations but not
81
115
  // the plan or a verdict. `observer` is what the controller and every write
82
116
  // gate key off; the two capability flags carve out the collaborator's middle.
@@ -86,7 +120,8 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
86
120
  // feeds all three; split it the day a collaborator earns one of them.
87
121
  const isOwner = !observer && role === "owner";
88
122
  const controller = useMemo(
89
- () => createReviewController({ home, sessionId, readOnly: observer, onExit, clock, openClient }),
123
+ () =>
124
+ createReviewController({ home, sessionId, readOnly: observer, onExit, clock, openClient }),
90
125
  // eslint-disable-next-line react-hooks/exhaustive-deps
91
126
  [home, sessionId],
92
127
  );
@@ -94,11 +129,8 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
94
129
  controller.connect();
95
130
  return () => controller.close();
96
131
  }, [controller]);
97
- const { session, inbox, status, error, completion, editOrphanCount, walk } = useSyncExternalStore(
98
- controller.subscribe,
99
- controller.getSnapshot,
100
- controller.getSnapshot,
101
- );
132
+ const { session, inbox, status, toast, error, completion, editOrphanCount, walk } =
133
+ useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
102
134
  // A shared plan you own polls for collaborator notes while it is open; the
103
135
  // merge refreshes through the normal event path. Stops on leave.
104
136
  useEffect(() => {
@@ -106,13 +138,27 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
106
138
  return controller.startSharePoll();
107
139
  }, [isOwner, session?.id, session?.shareId, controller]);
108
140
  const renderer = useRenderer();
109
- const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions();
141
+ const { width: terminalWidth } = useTerminalDimensions();
110
142
 
111
143
  // ── view state ──────────────────────────────
112
144
  const [cursor, setCursor] = useState(0);
113
145
  const [inboxCursor, setInboxCursor] = useState(0);
114
146
  const [mode, setMode] = useState<Mode>({ type: "normal" });
147
+ // the bottom-left menu drop-up and the centered dialog it opens
148
+ const [menuOpen, setMenuOpen] = useState(false);
149
+ const [menuDialog, setMenuDialog] = useState<"keybinds" | "settings" | null>(null);
150
+ const [autoClose, setAutoClose] = useState<AutoClose>("off");
151
+ const [settingsNav, setSettingsNav] = useState<{
152
+ categoryId: string;
153
+ rowIndex: number;
154
+ zone: "nav" | "body";
155
+ }>({
156
+ categoryId: "general",
157
+ rowIndex: 0,
158
+ zone: "body",
159
+ });
115
160
  const [focusedAnnotationId, setFocusedAnnotationId] = useState<string | undefined>(undefined);
161
+ const [selectedCurationId, setSelectedCurationId] = useState<string | undefined>(undefined);
116
162
  const [railTab, setRailTab] = useState<"review" | "agent">("review");
117
163
  // review panel layout: mode + expanded width are client view state, loaded
118
164
  // from and persisted to the user config so they survive a restart. The ref
@@ -133,16 +179,23 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
133
179
  const keysRef = useRef(DEFAULT_KEYS);
134
180
  const keyBindings = useMemo(() => new KeyBindings(DEFAULT_KEYS), []);
135
181
  const [theme, setTheme] = useState(DARK);
182
+ const [themeName, setThemeName] = useState<ThemeName>(DEFAULT_THEME_NAME);
183
+ const [themeOverrides, setThemeOverrides] = useState<Partial<Theme>>({});
136
184
  const [authorNames, setAuthorNames] = useState<Record<string, string>>({});
185
+ const [quickActions, setQuickActions] = useState<QuickAction[]>(DEFAULT_QUICK_ACTIONS);
137
186
  useEffect(() => {
138
187
  const config = loadConfig({ repoRoot: session?.workspace.repoRoot });
139
188
  keysRef.current = config.keys;
140
189
  keyBindings.setKeys(config.keys);
141
190
  setTheme(config.theme);
191
+ setThemeName(config.ui.theme);
192
+ setThemeOverrides(config.themeOverrides);
142
193
  setReviewMode(config.ui.reviewState);
143
194
  setReviewWidth(config.ui.reviewWidth);
144
195
  reviewWidthRef.current = config.ui.reviewWidth;
145
196
  setAuthorNames(config.authors);
197
+ setQuickActions(config.actions);
198
+ setAutoClose(config.ui.autoClose);
146
199
  controller.applyConfig(config);
147
200
  }, [session?.workspace.repoRoot, controller, keyBindings]);
148
201
  useEffect(
@@ -152,6 +205,12 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
152
205
  [],
153
206
  );
154
207
 
208
+ useEffect(() => {
209
+ if (!toast) return;
210
+ const timer = setTimeout(() => controller.dismissToast(), TOAST_DISMISS_MS);
211
+ return () => clearTimeout(timer);
212
+ }, [toast, controller]);
213
+
155
214
  // First open of a share: ask the collaborator for a display name once, unless
156
215
  // a past visit already recorded one. esc skips and their notes read anonymous.
157
216
  const promptedSelfRef = useRef(false);
@@ -165,8 +224,13 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
165
224
  // ── derived view model ──────────────────────
166
225
  const display = controller.display();
167
226
  const rows = controller.rows();
227
+ const rejectedRows = controller.rejectedRows();
228
+ const curationItems = controller.curationItems();
168
229
  const marks = useMemo(
169
- () => (session ? marksByDisplay(session.annotations, display, pulsedAnnotationId ?? undefined) : new Map<number, Mark[]>()),
230
+ () =>
231
+ session
232
+ ? marksByDisplay(session.annotations, display, pulsedAnnotationId ?? undefined)
233
+ : new Map<number, Mark[]>(),
170
234
  [session, display, pulsedAnnotationId],
171
235
  );
172
236
  /** Annotation ids whose anchor resolved against the working copy. */
@@ -179,6 +243,25 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
179
243
  }, [marks]);
180
244
  const resolved = session?.status === "resolved";
181
245
  const isDiff = session?.artifact.type === "diff";
246
+ // sort position per annotation so the rail interleaves annotation and removal
247
+ // cards in one line-ordered stack: a diff row carries its blockIndex; a plan
248
+ // annotation resolves to the display index it marked
249
+ const annotationPositions = useMemo(() => {
250
+ const positions = new Map<string, number>();
251
+ if (!session) return positions;
252
+ if (isDiff) {
253
+ for (const annotation of session.annotations) {
254
+ const blockIndex = (annotation.anchor as { blockIndex?: number }).blockIndex;
255
+ if (blockIndex !== undefined) positions.set(annotation.id, blockIndex);
256
+ }
257
+ } else {
258
+ for (const [displayIndex, blockMarks] of marks) {
259
+ for (const mark of blockMarks)
260
+ if (mark.annotationId) positions.set(mark.annotationId, displayIndex);
261
+ }
262
+ }
263
+ return positions;
264
+ }, [session, isDiff, marks]);
182
265
 
183
266
  // ── the guided walk's view model ────────────
184
267
  const walkFileList = controller.files();
@@ -189,8 +272,11 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
189
272
  // out of span mode clears the renderer selection (compose paints its own
190
273
  // mark, and a mouse drag never changes the mode, so it survives)
191
274
  useEffect(() => {
192
- if (mode.type === "span") planSheetRef.current?.driveSpanSelection(mode.span);
193
- else planSheetRef.current?.clearSelection();
275
+ // one marker at a time: clear any prior selection, then paint the current
276
+ // span (span and its quick-actions sub-mode both keep it painted)
277
+ const span = activeSpanState(mode);
278
+ planSheetRef.current?.clearSelection();
279
+ if (span) planSheetRef.current?.driveSpanSelection(span);
194
280
  }, [mode]);
195
281
 
196
282
  // ── selection symmetry: one selected id, both sides ──
@@ -221,6 +307,25 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
221
307
  revealAnchor(annotationId);
222
308
  };
223
309
 
310
+ // selecting a removal reveals its source line, so the reader sees what the
311
+ // pending undo would bring back: the diff sheet follows the cursor; the plan
312
+ // sheet also needs an explicit scroll to the cut block
313
+ const selectCurationFromRail = (curationId: string): void => {
314
+ setSelectedCurationId(curationId);
315
+ const item = curationItems.find((candidate) => candidate.id === curationId);
316
+ if (!item) return;
317
+ setCursor(item.revealIndex);
318
+ if (item.source === "plan") planSheetRef.current?.revealBlock(item.revealIndex);
319
+ };
320
+
321
+ // the selected removal card's undo button: same restore path as the u key,
322
+ // including the observer read-only guard the keyboard path gets via reduceKey
323
+ const undoCurationFromRail = (curationId: string): void => {
324
+ if (observer) return controller.setStatus("observer - read-only");
325
+ controller.restoreCuration(curationId);
326
+ setSelectedCurationId(undefined);
327
+ };
328
+
224
329
  const openCardEdit = (annotationId: string): void => {
225
330
  if (observer) return controller.setStatus("observer - read-only");
226
331
  const annotation = session?.annotations.find((candidate) => candidate.id === annotationId);
@@ -229,7 +334,11 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
229
334
  // the author rather than editing the body the planner does not own
230
335
  if (annotation.author) {
231
336
  setFocusedAnnotationId(annotationId);
232
- return void setMode({ type: "rename", authorId: annotation.author, text: authorNames[annotation.author] ?? "" });
337
+ return void setMode({
338
+ type: "rename",
339
+ authorId: annotation.author,
340
+ text: authorNames[annotation.author] ?? "",
341
+ });
233
342
  }
234
343
  if (resolved) return controller.setStatus("review submitted - read-only");
235
344
  liveInput.current = annotation.body;
@@ -262,7 +371,9 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
262
371
  reviewWidth,
263
372
  terminalWidth,
264
373
  focusedAnnotationId,
374
+ selectedCurationId,
265
375
  authorNames,
376
+ quickActions,
266
377
  renameAuthor: (id: string, name: string) => {
267
378
  persistAuthorName(id, name);
268
379
  setAuthorNames((prev) => ({ ...prev, [id]: name }));
@@ -277,6 +388,7 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
277
388
  setReviewWidth,
278
389
  setRailTab,
279
390
  setFocusedAnnotationId,
391
+ setSelectedCurationId,
280
392
  setPulsedAnnotationId,
281
393
  selectCardFromDocument,
282
394
  runEditorHandOff,
@@ -290,17 +402,126 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
290
402
  ? "submit"
291
403
  : mode.type === "confirmDelete"
292
404
  ? "confirm"
293
- : mode.type === "rename" || mode.type === "nameSelf"
294
- ? "prompt"
295
- : completion.phase === "prompt"
296
- ? "completion-prompt"
297
- : completion.phase === "counting"
298
- ? "completion-counting"
299
- : walking
300
- ? "walk"
301
- : "none";
405
+ : mode.type === "rename" || mode.type === "nameSelf"
406
+ ? "prompt"
407
+ : mode.type === "spanActions"
408
+ ? "spanActions"
409
+ : completion.phase === "prompt"
410
+ ? "completion-prompt"
411
+ : completion.phase === "counting"
412
+ ? "completion-counting"
413
+ : walking
414
+ ? "walk"
415
+ : "none";
416
+
417
+ // ── settings dialog: config-backed model, navigation, persistence ──
418
+ const settingsCategories: SettingsCategory[] = [
419
+ {
420
+ id: "general",
421
+ name: "General",
422
+ description: "submission behaviour",
423
+ rows: [
424
+ {
425
+ key: "autoClose",
426
+ label: "Auto-close on submit",
427
+ kind: "cycle",
428
+ options: ["off", "3s", "10s"],
429
+ },
430
+ ],
431
+ },
432
+ {
433
+ id: "display",
434
+ name: "Display",
435
+ description: "the review panel",
436
+ rows: [
437
+ {
438
+ key: "reviewPanel",
439
+ label: "Review panel",
440
+ kind: "cycle",
441
+ options: ["expanded", "compact", "hidden"],
442
+ },
443
+ ],
444
+ },
445
+ {
446
+ id: "appearance",
447
+ name: "Appearance",
448
+ description: "the color theme",
449
+ rows: [
450
+ {
451
+ key: "theme",
452
+ label: "Theme",
453
+ kind: "cycle",
454
+ options: THEME_NAMES.map((name) => THEME_LABELS[name]),
455
+ },
456
+ ],
457
+ },
458
+ ];
459
+ const settingsValues = {
460
+ autoClose: autoClose === "off" ? "off" : `${autoClose}s`,
461
+ reviewPanel: reviewMode,
462
+ theme: THEME_LABELS[themeName],
463
+ };
464
+ const cycleSetting = (rowKey: string): void => {
465
+ if (rowKey === "autoClose") {
466
+ const next: AutoClose = autoClose === "off" ? 3 : autoClose === 3 ? 10 : "off";
467
+ setAutoClose(next);
468
+ persistAutoClose(next);
469
+ } else if (rowKey === "reviewPanel") {
470
+ const order: ReviewPanelMode[] = ["expanded", "compact", "hidden"];
471
+ const next = order[(order.indexOf(reviewMode) + 1) % order.length]!;
472
+ setReviewMode(next);
473
+ controller.saveReviewPanel({ mode: next });
474
+ } else if (rowKey === "theme") {
475
+ const next = THEME_NAMES[(THEME_NAMES.indexOf(themeName) + 1) % THEME_NAMES.length]!;
476
+ setThemeName(next);
477
+ setTheme(composeTheme(next, themeOverrides));
478
+ persistTheme(next);
479
+ }
480
+ };
481
+ const handleSettingsKey = (name: string): void => {
482
+ if (name === "escape") return void setMenuDialog(null);
483
+ const categoryIndex = settingsCategories.findIndex(
484
+ (category) => category.id === settingsNav.categoryId,
485
+ );
486
+ const category = settingsCategories[categoryIndex]!;
487
+ if (settingsNav.zone === "nav") {
488
+ if (name === "j" || name === "down")
489
+ setSettingsNav({
490
+ categoryId:
491
+ settingsCategories[Math.min(settingsCategories.length - 1, categoryIndex + 1)]!.id,
492
+ rowIndex: 0,
493
+ zone: "nav",
494
+ });
495
+ else if (name === "k" || name === "up")
496
+ setSettingsNav({
497
+ categoryId: settingsCategories[Math.max(0, categoryIndex - 1)]!.id,
498
+ rowIndex: 0,
499
+ zone: "nav",
500
+ });
501
+ else if (name === "l" || name === "tab" || name === "return")
502
+ setSettingsNav((state) => ({ ...state, zone: "body", rowIndex: 0 }));
503
+ return;
504
+ }
505
+ if (name === "j" || name === "down")
506
+ setSettingsNav((state) => ({
507
+ ...state,
508
+ rowIndex: Math.min(category.rows.length - 1, state.rowIndex + 1),
509
+ }));
510
+ else if (name === "k" || name === "up")
511
+ setSettingsNav((state) => ({ ...state, rowIndex: Math.max(0, state.rowIndex - 1) }));
512
+ else if (name === "h" || name === "tab") setSettingsNav((state) => ({ ...state, zone: "nav" }));
513
+ else if (name === "return" || name === "space")
514
+ cycleSetting(category.rows[settingsNav.rowIndex]!.key);
515
+ };
302
516
 
303
517
  useKeyboard((key) => {
518
+ if (menuDialog === "settings") return void handleSettingsKey(key.name);
519
+ if (menuDialog) return void (key.name === "escape" && setMenuDialog(null));
520
+ if (menuOpen) return void (key.name === "escape" && setMenuOpen(false));
521
+ // the toast is non-modal: escape only dismisses it when nothing else owns
522
+ // escape, so an open overlay (compose, submit, prompt, walk) still cancels
523
+ if (toast && key.name === "escape" && overlay === "none" && mode.type !== "span")
524
+ return controller.dismissToast();
304
525
  const state: KeyState = {
305
526
  keys: keysRef.current,
306
527
  readOnly: observer,
@@ -316,15 +537,59 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
316
537
  hasFocusedAnnotation: focusedAnnotationId !== undefined,
317
538
  walkAtEnd: walk !== null && walk.index >= walkFileList.length,
318
539
  cursorAnnotatable: isDiff
319
- ? rows[cursor] !== undefined && rows[cursor]!.kind !== "file" && rows[cursor]!.kind !== "hunk"
540
+ ? rows[cursor] !== undefined &&
541
+ rows[cursor]!.kind !== "file" &&
542
+ rows[cursor]!.kind !== "hunk"
320
543
  : !!display[cursor]?.work,
321
544
  };
322
545
  keyBindings.setContext({ overlay: state.overlay, spanMode: state.spanMode });
323
546
  const action = keyBindings.resolveAction({ name: key.name, shift: !!key.shift });
324
- for (const intent of reduceKey(state, { name: key.name, shift: !!key.shift, meta: !!key.meta }, action))
547
+ for (const intent of reduceKey(
548
+ state,
549
+ { name: key.name, shift: !!key.shift, meta: !!key.meta },
550
+ action,
551
+ ))
325
552
  dispatch(intent);
326
553
  });
327
554
 
555
+ // ── shared bottom chrome: the menu bar and its drop-up dialogs, one render
556
+ // reused by the inbox and by plan/diff review so the two never drift ──
557
+ const menuChrome = (
558
+ <>
559
+ <MenuBar
560
+ open={menuOpen}
561
+ version={CLIENT_VERSION}
562
+ status={status}
563
+ onToggle={() => setMenuOpen((isOpen) => !isOpen)}
564
+ onSettings={() => {
565
+ setMenuOpen(false);
566
+ setMenuDialog("settings");
567
+ }}
568
+ onKeybinds={() => {
569
+ setMenuOpen(false);
570
+ setMenuDialog("keybinds");
571
+ }}
572
+ theme={theme}
573
+ />
574
+ {menuDialog === "keybinds" ? (
575
+ <KeybindsDialog sections={keyBindings.cheatsheet()} theme={theme} />
576
+ ) : null}
577
+ {menuDialog === "settings" ? (
578
+ <SettingsDialog
579
+ isOpen
580
+ categories={settingsCategories}
581
+ values={settingsValues}
582
+ activeCategoryId={settingsNav.categoryId}
583
+ activeRowIndex={settingsNav.rowIndex}
584
+ activeZone={settingsNav.zone}
585
+ onCategorySelect={(id) => setSettingsNav({ categoryId: id, rowIndex: 0, zone: "body" })}
586
+ onRowActivate={(row) => cycleSetting(row.key)}
587
+ theme={theme}
588
+ />
589
+ ) : null}
590
+ </>
591
+ );
592
+
328
593
  // ── render ──────────────────────────────────
329
594
  if (error) {
330
595
  return (
@@ -344,28 +609,65 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
344
609
  const confirming = mode.type === "confirmDelete" ? mode : null;
345
610
  return (
346
611
  <ThemeProvider theme={theme}>
347
- <InboxList
348
- inbox={inbox}
349
- cursor={inboxCursor}
350
- onRequestDelete={(id, title) => setMode({ type: "confirmDelete", sessionId: id, title })}
351
- />
352
- <ConfirmDialog
353
- isOpen={confirming !== null}
354
- title=" Delete plan "
355
- message={confirming ? `Delete "${confirming.title}"? This removes the plan and its review.` : ""}
356
- onConfirm={() => {
357
- if (confirming) controller.deleteSession(confirming.sessionId);
358
- setMode({ type: "normal" });
612
+ <box
613
+ style={{
614
+ flexDirection: "column",
615
+ width: "100%",
616
+ height: "100%",
617
+ backgroundColor: theme.background,
359
618
  }}
360
- onCancel={() => setMode({ type: "normal" })}
361
- theme={theme}
362
- />
619
+ >
620
+ {/* mirrors the review header row: same box, position, and accent product
621
+ word, with a " · resume" separator and no Edit/Share toolbar */}
622
+ <box
623
+ style={{ flexDirection: "row", height: 2, paddingTop: 1, backgroundColor: theme.panel }}
624
+ >
625
+ <box
626
+ style={{
627
+ height: 1,
628
+ backgroundColor: theme.panel,
629
+ paddingLeft: 1,
630
+ flexDirection: "row",
631
+ }}
632
+ >
633
+ <text>
634
+ <span fg={theme.accent}>cueloop</span>
635
+ <span fg={theme.textDim}> · resume</span>
636
+ </text>
637
+ </box>
638
+ <box style={{ flexGrow: 1 }} />
639
+ </box>
640
+ <InboxList
641
+ inbox={inbox}
642
+ cursor={inboxCursor}
643
+ onRequestDelete={(id, title) =>
644
+ setMode({ type: "confirmDelete", sessionId: id, title })
645
+ }
646
+ />
647
+ {menuChrome}
648
+ <ConfirmDialog
649
+ isOpen={confirming !== null}
650
+ title=" Delete plan "
651
+ message={
652
+ confirming
653
+ ? `Delete "${confirming.title}"? This removes the plan and its review.`
654
+ : ""
655
+ }
656
+ onConfirm={() => {
657
+ if (confirming) controller.deleteSession(confirming.sessionId);
658
+ setMode({ type: "normal" });
659
+ }}
660
+ onCancel={() => setMode({ type: "normal" })}
661
+ theme={theme}
662
+ />
663
+ </box>
363
664
  </ThemeProvider>
364
665
  );
365
666
  }
366
667
 
367
668
  const activeSession = session!;
368
- const pendingCount = reviewerAnnotations(activeSession).length + (activeSession.workingCopy !== undefined ? 1 : 0);
669
+ const pendingCount =
670
+ reviewerAnnotations(activeSession).length + (activeSession.workingCopy !== undefined ? 1 : 0);
369
671
 
370
672
  if ((completion.phase === "prompt" || completion.phase === "counting") && activeSession.verdict) {
371
673
  return (
@@ -374,7 +676,11 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
374
676
  verdict={activeSession.verdict.kind}
375
677
  completion={completion}
376
678
  status={status}
377
- returnsTo={returnPaneFor(activeSession.artifact.meta.herdrPane) ? (activeSession.artifact.meta.agent ?? "the agent") : undefined}
679
+ returnsTo={
680
+ returnPaneFor(activeSession.artifact.meta.herdrPane)
681
+ ? (activeSession.artifact.meta.agent ?? "the agent")
682
+ : undefined
683
+ }
378
684
  />
379
685
  </ThemeProvider>
380
686
  );
@@ -398,17 +704,93 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
398
704
  }
399
705
  : null;
400
706
 
401
- const activeSpan =
402
- mode.type === "span"
403
- ? { displayIndex: mode.span.displayIndex, start: mode.span.start, end: mode.span.end }
404
- : mode.type === "compose" && !isDiff
405
- ? // the compose anchor stays painted selection-style while the box is open
406
- { displayIndex: mode.displayIndex, start: mode.start, end: mode.end }
407
- : null;
707
+ const diffComposeState =
708
+ mode.type === "compose" && isDiff
709
+ ? {
710
+ kind: mode.kind,
711
+ rowIndex: mode.displayIndex,
712
+ quote: rows[mode.displayIndex]?.text.replace(/\n$/, "") ?? "",
713
+ draft: {
714
+ text: mode.text,
715
+ onInput: (text: string) => {
716
+ liveInput.current = text;
717
+ setMode({ ...mode, text });
718
+ },
719
+ onSave: () => dispatch({ type: "saveCompose" }),
720
+ onCancel: () => dispatch({ type: "closeOverlay" }),
721
+ },
722
+ }
723
+ : null;
724
+
725
+ const markedSpan = activeSpanState(mode);
726
+ const activeSpan = markedSpan
727
+ ? { displayIndex: markedSpan.displayIndex, start: markedSpan.start, end: markedSpan.end }
728
+ : mode.type === "compose" && !isDiff
729
+ ? // the compose anchor stays painted selection-style while the box is open
730
+ { displayIndex: mode.displayIndex, start: mode.start, end: mode.end }
731
+ : null;
732
+
733
+ // mouse mutations bypass reduceKey, so they replay its gates: an observer or a
734
+ // resolved review is read-only. Returns the status to answer with, or null.
735
+ const spanMutationBlock = (): string | null =>
736
+ observer ? "observer - read-only" : resolved ? "review submitted - read-only" : null;
737
+
738
+ // the marker-actions popover is span mode made visible: an inline toolbar at
739
+ // the marked block, or its quick-actions list. Cut is owner-only (hidden for a
740
+ // collaborator, like every other plan-edit affordance).
741
+ const popoverState =
742
+ markedSpan && !isDiff
743
+ ? {
744
+ displayIndex: markedSpan.displayIndex,
745
+ view: mode.type === "spanActions" ? ("actions" as const) : ("toolbar" as const),
746
+ actions: quickActions,
747
+ actionIndex: mode.type === "spanActions" ? mode.index : 0,
748
+ canCut: isOwner,
749
+ onComment: () => {
750
+ const blocked = spanMutationBlock();
751
+ if (blocked) return controller.setStatus(blocked);
752
+ dispatch({ type: "openCompose", kind: "comment", from: "span" });
753
+ },
754
+ onCut: () => {
755
+ const blocked = spanMutationBlock();
756
+ if (blocked) return controller.setStatus(blocked);
757
+ if (!isOwner) return;
758
+ dispatch({ type: "spanCut" });
759
+ },
760
+ onOpenActions: () => {
761
+ const blocked = spanMutationBlock();
762
+ if (blocked) return controller.setStatus(blocked);
763
+ dispatch({ type: "openSpanActions" });
764
+ },
765
+ onClose: () => dispatch({ type: "closeOverlay" }),
766
+ onPickAction: (index: number) => {
767
+ const blocked = spanMutationBlock();
768
+ if (blocked) return controller.setStatus(blocked);
769
+ dispatch({ type: "pickSpanAction", index });
770
+ },
771
+ onBack: () => dispatch({ type: "closeSpanActions" }),
772
+ }
773
+ : null;
408
774
 
409
775
  const onLineActivate = (displayIndex: number): void => {
410
- // releasing a drag-selection lands here too; a live selection is not a click
411
- if (renderer?.hasSelection) return;
776
+ // a mouse drag leaves a native selection: turn it into a word span so the
777
+ // marker popover opens at the dragged range, mirroring the `v` grammar
778
+ if (renderer?.hasSelection) {
779
+ // the release block re-anchors the span, so each drag replaces the last
780
+ const selection = planSheetRef.current?.readSelection(displayIndex);
781
+ const block = selection ? display[selection.displayIndex] : undefined;
782
+ const span =
783
+ selection && block
784
+ ? spanFromRange(
785
+ selection.displayIndex,
786
+ displayText(block),
787
+ selection.start,
788
+ selection.end,
789
+ )
790
+ : null;
791
+ if (span) setMode({ type: "span", span });
792
+ return;
793
+ }
412
794
  setCursor(displayIndex);
413
795
  const annotationId = marks.get(displayIndex)?.[0]?.annotationId;
414
796
  if (annotationId) selectCardFromDocument(annotationId);
@@ -484,36 +866,48 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
484
866
  }
485
867
  : null;
486
868
 
869
+ // width the review panel occupies (divider + rail), so the header can reserve
870
+ // it and keep Edit/Share pinned to the plan sheet's right edge, not the rail's
871
+ const railFootprint =
872
+ reviewMode === "hidden"
873
+ ? 0
874
+ : 1 +
875
+ (reviewMode === "compact"
876
+ ? REVIEW_COMPACT_WIDTH
877
+ : resolveReviewWidth(reviewWidth, terminalWidth));
878
+
879
+ // status badges sit right after the product word so they survive a header
880
+ // that is too narrow for the whole trail (the rail can eat the width)
487
881
  const headerItems: BreadcrumbItem[] = [
488
882
  { label: "cueloop", tone: "accent" },
489
- { label: `${activeSession.artifact.meta.title ?? activeSession.artifact.meta.planPath ?? activeSession.id} · rev ${activeSession.revisions.length}`, tone: "dim" },
490
- ...(resolved ? [{ label: `resolved: ${activeSession.verdict!.kind.replace("_", " ")}`, tone: "green" as const }] : []),
883
+ ...(resolved
884
+ ? [
885
+ {
886
+ label: `resolved: ${activeSession.verdict!.kind.replace("_", " ")}`,
887
+ tone: "green" as const,
888
+ },
889
+ ]
890
+ : []),
491
891
  ...(observer ? [{ label: "observer", tone: "dim" as const }] : []),
492
- ...(role === "collaborator" ? [{ label: "shared · your notes save as you go", tone: "dim" as const }] : []),
493
- ...(status ? [{ label: status, tone: "accent" as const }] : []),
892
+ ...(role === "collaborator"
893
+ ? [{ label: "shared · your notes save as you go", tone: "dim" as const }]
894
+ : []),
895
+ {
896
+ label: `${activeSession.artifact.meta.title ?? activeSession.artifact.meta.planPath ?? activeSession.id} · rev ${activeSession.revisions.length}`,
897
+ tone: "dim",
898
+ },
899
+ { label: `submitted by ${activeSession.artifact.meta.agent ?? "unknown"}`, tone: "dim" },
494
900
  ];
495
901
 
496
- keyBindings.setContext({ overlay, spanMode: mode.type === "span" });
497
- const hintMode: HintMode = observer
498
- ? "read-only"
499
- : mode.type === "submit"
500
- ? "submit"
501
- : mode.type === "span"
502
- ? "span"
503
- : mode.type === "compose" || mode.type === "railEdit"
504
- ? "compose"
505
- : walking
506
- ? "walk"
507
- : focusedAnnotationId !== undefined
508
- ? "card"
509
- : role === "collaborator"
510
- ? "collaborator"
511
- : "normal";
512
-
513
902
  return (
514
903
  <ThemeProvider theme={theme}>
515
904
  <box
516
- style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: theme.background }}
905
+ style={{
906
+ flexDirection: "column",
907
+ width: "100%",
908
+ height: "100%",
909
+ backgroundColor: theme.background,
910
+ }}
517
911
  onMouseDrag={(event: MouseEvent) => {
518
912
  if (!dividerDragging || reviewMode !== "expanded") return;
519
913
  const next = widthFromMouseColumn(event.x, terminalWidth);
@@ -526,7 +920,25 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
526
920
  controller.saveReviewPanel({ width: reviewWidthRef.current });
527
921
  }}
528
922
  >
529
- <Breadcrumb items={headerItems} />
923
+ <box
924
+ style={{ flexDirection: "row", height: 2, paddingTop: 1, backgroundColor: theme.panel }}
925
+ >
926
+ <box style={{ flexGrow: 1, flexDirection: "row", paddingRight: 1 }}>
927
+ <Breadcrumb items={headerItems} />
928
+ <box style={{ flexGrow: 1 }} />
929
+ {isOwner && !isDiff && !resolved ? (
930
+ <Toolbar>
931
+ <Button onPress={onEditRequest} theme={theme}>
932
+ {" Edit "}
933
+ </Button>
934
+ <Button onPress={onShareRequest} theme={theme}>
935
+ {" Share "}
936
+ </Button>
937
+ </Toolbar>
938
+ ) : null}
939
+ </box>
940
+ <box style={{ width: railFootprint }} />
941
+ </box>
530
942
  <box style={{ flexGrow: 1, flexDirection: "row" }}>
531
943
  {isDiff ? (
532
944
  // the sheet dims to reading-quiet colors while the wizard has focus
@@ -535,6 +947,8 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
535
947
  cursor={cursor}
536
948
  annotations={activeSession.annotations}
537
949
  focusedAnnotationId={focusedAnnotationId}
950
+ rejectedRows={rejectedRows}
951
+ compose={diffComposeState}
538
952
  theme={walking ? dimmedTheme(theme) : undefined}
539
953
  />
540
954
  ) : (
@@ -546,18 +960,14 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
546
960
  cursor={cursor}
547
961
  activeSpan={activeSpan}
548
962
  compose={composeState}
963
+ popover={popoverState}
549
964
  editOrphanCount={editOrphanCount}
550
965
  onLineActivate={onLineActivate}
551
- onEditRequest={onEditRequest}
552
- onShareRequest={onShareRequest}
553
- canEdit={isOwner}
554
966
  />
555
967
  )}
556
968
  <ReviewPanel
557
969
  mode={reviewMode}
558
970
  width={resolveReviewWidth(reviewWidth, terminalWidth)}
559
- height={terminalHeight - CHROME_ROWS}
560
- dragging={dividerDragging}
561
971
  onDividerGrab={onDividerGrab}
562
972
  onToggle={onToggleReviewPanel}
563
973
  railRef={railRef}
@@ -566,6 +976,9 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
566
976
  authorNames,
567
977
  selectedId: focusedAnnotationId,
568
978
  resolvedIds: isDiff ? null : resolvedIds,
979
+ curationItems,
980
+ selectedCurationId,
981
+ annotationPositions,
569
982
  railTab,
570
983
  pendingCount,
571
984
  cardEdit: cardEditState,
@@ -573,23 +986,12 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
573
986
  onTabChange: setRailTab,
574
987
  onSelectCard: selectCardFromRail,
575
988
  onActivateCard: openCardEdit,
989
+ onSelectCuration: selectCurationFromRail,
990
+ onUndoCuration: undoCurationFromRail,
576
991
  onSubmitRequest,
577
992
  }}
578
993
  />
579
994
  </box>
580
- {mode.type === "compose" && isDiff ? (
581
- <ComposeBar
582
- kind={mode.kind}
583
- quote={rows[mode.displayIndex]?.text ?? ""}
584
- text={mode.text}
585
- onInput={(text) => {
586
- liveInput.current = text;
587
- setMode({ ...mode, text });
588
- }}
589
- />
590
- ) : (
591
- <StatusBar>{keyBindings.statusHint(hintMode)}</StatusBar>
592
- )}
593
995
  {walking && walk !== null ? (
594
996
  <WalkWizard
595
997
  files={walkFileList}
@@ -630,6 +1032,8 @@ export function App({ home, sessionId, readOnly = false, onExit, clock, openClie
630
1032
  theme={theme}
631
1033
  />
632
1034
  ) : null}
1035
+ {toast ? <Toast title={toast.title} body={toast.body} theme={theme} /> : null}
1036
+ {menuChrome}
633
1037
  </box>
634
1038
  </ThemeProvider>
635
1039
  );