@cueloop/client 0.1.0-alpha.10 → 0.1.0-alpha.12

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 (66) hide show
  1. package/package.json +10 -10
  2. package/src/App.autoclose.test.tsx +24 -35
  3. package/src/App.diff.test.tsx +5 -18
  4. package/src/App.export.test.tsx +8 -21
  5. package/src/App.plan-review.test.tsx +21 -52
  6. package/src/App.readonly.test.tsx +3 -18
  7. package/src/App.rendering.test.tsx +5 -13
  8. package/src/App.submit-confirm.test.tsx +7 -25
  9. package/src/App.test.tsx +13 -52
  10. package/src/App.tsx +197 -910
  11. package/src/components/AnnotationCard.stories.tsx +75 -0
  12. package/src/components/AnnotationCard.tsx +136 -0
  13. package/src/components/Breadcrumb.stories.tsx +19 -0
  14. package/src/components/Breadcrumb.tsx +38 -0
  15. package/src/components/CodeBlock.stories.tsx +25 -0
  16. package/src/components/CodeBlock.tsx +68 -0
  17. package/src/components/CompletionOverlay.stories.tsx +23 -0
  18. package/src/components/CompletionOverlay.tsx +50 -0
  19. package/src/components/ComposeBar.stories.tsx +18 -0
  20. package/src/components/ComposeBar.tsx +54 -0
  21. package/src/components/ConfirmCard.stories.tsx +22 -0
  22. package/src/components/ConfirmCard.tsx +130 -0
  23. package/src/components/DiffSheet.stories.tsx +44 -0
  24. package/src/components/DiffSheet.tsx +243 -0
  25. package/src/components/InboxList.stories.tsx +18 -0
  26. package/src/components/InboxList.tsx +42 -0
  27. package/src/components/PlanSheet.stories.tsx +81 -0
  28. package/src/components/PlanSheet.tsx +309 -0
  29. package/src/components/ReviewRail.stories.tsx +103 -0
  30. package/src/components/ReviewRail.tsx +158 -0
  31. package/src/components/SettingsDialog.stories.tsx +63 -0
  32. package/src/components/SettingsDialog.tsx +132 -0
  33. package/src/components/SettingsRows.stories.tsx +18 -0
  34. package/src/components/SettingsRows.tsx +92 -0
  35. package/src/components/__snapshots__/stories.test.tsx.snap +1189 -0
  36. package/src/components/format.ts +6 -0
  37. package/src/components/primitives/Button.stories.tsx +32 -0
  38. package/src/components/primitives/Button.tsx +44 -0
  39. package/src/components/primitives/Card.stories.tsx +25 -0
  40. package/src/components/primitives/Card.tsx +58 -0
  41. package/src/components/primitives/Dialog.stories.tsx +24 -0
  42. package/src/components/primitives/Dialog.tsx +52 -0
  43. package/src/components/primitives/StatusBar.stories.tsx +11 -0
  44. package/src/components/primitives/StatusBar.tsx +22 -0
  45. package/src/components/primitives/Tabs.stories.tsx +29 -0
  46. package/src/components/primitives/Tabs.tsx +97 -0
  47. package/src/components/primitives/Toolbar.stories.tsx +17 -0
  48. package/src/components/primitives/Toolbar.tsx +14 -0
  49. package/src/components/stories-app.tsx +94 -0
  50. package/src/components/stories.test.tsx +51 -0
  51. package/src/components/story-fixtures.ts +90 -0
  52. package/src/components/story.ts +66 -0
  53. package/src/components/syntax-highlight.ts +57 -0
  54. package/src/components/theme-context.stories.tsx +41 -0
  55. package/src/components/theme-context.tsx +30 -0
  56. package/src/key-bindings.test.ts +103 -0
  57. package/src/key-bindings.ts +332 -0
  58. package/src/keymap.ts +8 -2
  59. package/src/run.ts +3 -1
  60. package/src/session-controller.ts +28 -7
  61. package/src/test-support.ts +87 -0
  62. package/src/theme.ts +3 -0
  63. package/src/view.test.ts +35 -13
  64. package/src/view.ts +41 -64
  65. package/src/syntax.test.ts +0 -41
  66. package/src/syntax.ts +0 -88
package/src/App.tsx CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
- * The cueloop TUI (#22, #71): header, plan sheet (sheet header + inline
3
- * compose + selectable lines) or diff pane, right review rail, footer hint
4
- * bar. Thin renderer over the review-session controller: daemon IO and the
5
- * mutation verbs live in session-controller.ts, the keyboard grammar in
6
- * keymap.ts; local state is view state only (cursor, selection, overlays).
2
+ * The cueloop TUI (#22, #71, #86): state wiring, keymap dispatch, and layout
3
+ * composition - nothing else. Rendering lives in components/ (PlanSheet,
4
+ * DiffSheet, ReviewRail, ...), daemon IO and the mutation verbs live in
5
+ * session-controller.ts, the keyboard grammar in keymap.ts with binding
6
+ * resolution and status hints from key-bindings.ts, and theming flows
7
+ * through the ThemeProvider - config themes swap the provider value.
7
8
  *
8
9
  * The plan review grammar: selection is the entry primitive (mouse drag or
9
10
  * keyboard span on one native renderer selection), compose happens inline
@@ -13,31 +14,26 @@
13
14
  */
14
15
 
15
16
  import React, { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
16
- import { useKeyboard, useRenderer } from "@opentui/react";
17
- import type { ScrollBoxRenderable, TextRenderable } from "@opentui/core";
18
- import { type Annotation, type ReviewSession, type VerdictKind } from "@cueloop/schema";
19
- import {
20
- blockRuns,
21
- composeBoxHeight,
22
- displayText,
23
- marksByDisplay,
24
- overlayMarks,
25
- revisionDelta,
26
- spanKey,
27
- startSpan,
28
- wrapRuns,
29
- type DisplayBlock,
30
- type Mark,
31
- type SpanState,
32
- type StyleRun,
33
- } from "./view";
34
- import { type DiffRow } from "./view-diff";
35
- import { DARK as T } from "./theme";
36
- import { highlightCode, type CodeToken } from "./syntax";
17
+ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
18
+ import type { Clock } from "@opentui/core";
19
+ import { type ReviewSession, type VerdictKind } from "@cueloop/schema";
20
+ import { displayText, marksByDisplay, spanKey, startSpan, type Mark, type SpanState } from "./view";
21
+ import { DARK } from "./theme";
37
22
  import { DEFAULT_KEYS, loadConfig } from "./config";
38
23
  import { returnPaneFor } from "./herdr";
39
24
  import { createReviewController } from "./session-controller";
40
25
  import { reduceKey, type Intent, type KeyState } from "./keymap";
26
+ import { KeyBindings, type HintMode } from "./key-bindings";
27
+ import { ThemeProvider } from "./components/theme-context";
28
+ import { StatusBar } from "./components/primitives/StatusBar";
29
+ import { Breadcrumb, type BreadcrumbItem } from "./components/Breadcrumb";
30
+ import { PlanSheet, type PlanSheetHandle } from "./components/PlanSheet";
31
+ import { DiffSheet } from "./components/DiffSheet";
32
+ import { ReviewRail, annotationBlocking, type ReviewRailHandle } from "./components/ReviewRail";
33
+ import { VERDICTS } from "./components/ConfirmCard";
34
+ import { CompletionOverlay } from "./components/CompletionOverlay";
35
+ import { InboxList } from "./components/InboxList";
36
+ import { ComposeBar } from "./components/ComposeBar";
41
37
 
42
38
  export interface AppProps {
43
39
  home?: string;
@@ -48,6 +44,8 @@ export interface AppProps {
48
44
  */
49
45
  readOnly?: boolean;
50
46
  onExit?: (code: number) => void;
47
+ /** Timer source for the auto-close countdown; tests inject a ManualClock. */
48
+ clock?: Clock;
51
49
  }
52
50
 
53
51
  type Mode =
@@ -57,35 +55,13 @@ type Mode =
57
55
  | { m: "railEdit"; id: string; text: string }
58
56
  | { m: "submit"; verdict: VerdictKind; summary: string };
59
57
 
60
- type RailTab = "review" | "agent";
61
-
62
- const VERDICTS: VerdictKind[] = ["comment", "approve", "request_changes"];
63
- /** Selector words in the rail confirm card - one word per verdict. */
64
- const VERDICT_LABEL: Record<VerdictKind, string> = {
65
- comment: "Comment",
66
- approve: "Approve",
67
- request_changes: "Changes",
68
- };
69
-
70
- /** One rendered plan line, registered for the native selection primitive. */
71
- interface PlanLineRef {
72
- renderable: TextRenderable;
73
- dispIdx: number;
74
- lineIndex: number;
75
- /** Block-text offset of the line's first positioned run; null for pure-del lines. */
76
- lineStart: number | null;
77
- /** Screen columns before the run text (cursor glyph + list marker). */
78
- prefixColumns: number;
79
- /** Length of the positioned run text in this line. */
80
- textLength: number;
58
+ function defaultVerdict(session: ReviewSession): VerdictKind {
59
+ return session.annotations.length || session.workingCopy !== undefined ? "request_changes" : "approve";
81
60
  }
82
61
 
83
- /** Rail card height (3 text rows + 1 margin), for reveal-scroll math. */
84
- const RAIL_CARD_HEIGHT = 4;
85
-
86
- export function App({ home, sessionId, readOnly = false, onExit }: AppProps): React.ReactNode {
62
+ export function App({ home, sessionId, readOnly = false, onExit, clock }: AppProps): React.ReactNode {
87
63
  const controller = useMemo(
88
- () => createReviewController({ home, sessionId, readOnly, onExit }),
64
+ () => createReviewController({ home, sessionId, readOnly, onExit, clock }),
89
65
  // eslint-disable-next-line react-hooks/exhaustive-deps
90
66
  [home, sessionId],
91
67
  );
@@ -99,31 +75,33 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
99
75
  controller.getSnapshot,
100
76
  );
101
77
  const renderer = useRenderer();
78
+ const { width: terminalWidth } = useTerminalDimensions();
102
79
 
103
80
  // ── view state ──────────────────────────────
104
81
  const [cursor, setCursor] = useState(0);
105
82
  const [inboxCursor, setInboxCursor] = useState(0);
106
83
  const [mode, setMode] = useState<Mode>({ m: "normal" });
107
84
  const [focusedAnn, setFocusedAnn] = useState<string | undefined>(undefined);
108
- const [railTab, setRailTab] = useState<RailTab>("review");
85
+ const [railTab, setRailTab] = useState<"review" | "agent">("review");
109
86
  // ~2s focus pulse on the document highlight when a rail card is activated
110
87
  const [pulseAnn, setPulseAnn] = useState<string | null>(null);
111
88
  const pulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
112
89
  // live mirror of overlay input text: refs commit synchronously, so the
113
90
  // RETURN handler never reads a stale value mid-typing
114
91
  const liveInput = useRef("");
115
- // rendered plan lines, for driving and reading the native selection
116
- const lineRefs = useRef(new Map<string, PlanLineRef>());
117
- const docScrollRef = useRef<ScrollBoxRenderable | null>(null);
118
- const railScrollRef = useRef<ScrollBoxRenderable | null>(null);
119
- // keymap from layered config; theme overrides land on the shared tokens
92
+ const planSheetRef = useRef<PlanSheetHandle | null>(null);
93
+ const railRef = useRef<ReviewRailHandle | null>(null);
94
+ // keymap from layered config; the loaded theme swaps the provider value
120
95
  const keysRef = useRef(DEFAULT_KEYS);
96
+ const keyBindings = useMemo(() => new KeyBindings(DEFAULT_KEYS), []);
97
+ const [theme, setTheme] = useState(DARK);
121
98
  useEffect(() => {
122
99
  const cfg = loadConfig({ repoRoot: session?.workspace.repoRoot });
123
100
  keysRef.current = cfg.keys;
124
- Object.assign(T, cfg.theme);
101
+ keyBindings.setKeys(cfg.keys);
102
+ setTheme(cfg.theme);
125
103
  controller.applyConfig(cfg);
126
- }, [session?.workspace.repoRoot, controller]);
104
+ }, [session?.workspace.repoRoot, controller, keyBindings]);
127
105
  useEffect(
128
106
  () => () => {
129
107
  if (pulseTimer.current) clearTimeout(pulseTimer.current);
@@ -149,62 +127,14 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
149
127
  const resolved = session?.status === "resolved";
150
128
  const isDiff = session?.artifact.type === "diff";
151
129
 
152
- // ── the native selection: keyboard driver ───
153
- const lineRefFor = (dispIdx: number, offset: number): PlanLineRef | null => {
154
- for (const meta of lineRefs.current.values()) {
155
- if (meta.dispIdx !== dispIdx || meta.lineStart === null) continue;
156
- if (offset >= meta.lineStart && offset < meta.lineStart + meta.textLength) return meta;
157
- }
158
- return null;
159
- };
160
-
161
- /** Anchor/extend the renderer's native selection from keyboard span offsets. */
162
- const driveNativeSelection = (span: SpanState): void => {
163
- if (!renderer) return;
164
- const startMeta = lineRefFor(span.dispIdx, span.start);
165
- const endMeta = lineRefFor(span.dispIdx, span.end - 1);
166
- if (!startMeta || !endMeta) return;
167
- const startColumn = startMeta.prefixColumns + (span.start - startMeta.lineStart!);
168
- const endColumn = endMeta.prefixColumns + (span.end - 1 - endMeta.lineStart!);
169
- renderer.startSelection(startMeta.renderable, startMeta.renderable.x + startColumn, startMeta.renderable.y);
170
- renderer.updateSelection(endMeta.renderable, endMeta.renderable.x + endColumn + 1, endMeta.renderable.y);
171
- };
172
130
  // driving needs committed layout, so it runs after render; any transition
173
131
  // out of span mode clears the renderer selection (compose paints its own
174
132
  // mark, and a mouse drag never changes the mode, so it survives)
175
133
  useEffect(() => {
176
- if (mode.m === "span") driveNativeSelection(mode.span);
177
- else renderer?.clearSelection();
178
- // eslint-disable-next-line react-hooks/exhaustive-deps
134
+ if (mode.m === "span") planSheetRef.current?.driveSpanSelection(mode.span);
135
+ else planSheetRef.current?.clearSelection();
179
136
  }, [mode]);
180
137
 
181
- /**
182
- * Read the native selection (mouse drag) back as a block range. The quote
183
- * anchors in the FIRST selected block - quote anchors resolve within one
184
- * block. Columns map 1:1 onto text offsets except inside word-diffed mod
185
- * blocks, where inline del runs shift the screen columns.
186
- */
187
- const readNativeSelection = (): { dispIdx: number; start: number; end: number } | null => {
188
- if (!renderer?.hasSelection) return null;
189
- const ordered = [...lineRefs.current.values()].sort(
190
- (a, b) => a.dispIdx - b.dispIdx || a.lineIndex - b.lineIndex,
191
- );
192
- let found: { dispIdx: number; start: number; end: number } | null = null;
193
- for (const meta of ordered) {
194
- if (meta.lineStart === null) continue;
195
- const selection = meta.renderable.getSelection();
196
- if (!selection || selection.end <= selection.start) continue;
197
- const startInText = Math.min(meta.textLength, Math.max(0, selection.start - meta.prefixColumns));
198
- const endInText = Math.min(meta.textLength, Math.max(0, selection.end - meta.prefixColumns));
199
- if (endInText <= startInText) continue;
200
- const blockStart = meta.lineStart + startInText;
201
- const blockEnd = meta.lineStart + endInText;
202
- if (!found) found = { dispIdx: meta.dispIdx, start: blockStart, end: blockEnd };
203
- else if (found.dispIdx === meta.dispIdx) found.end = Math.max(found.end, blockEnd);
204
- }
205
- return found;
206
- };
207
-
208
138
  // ── selection symmetry: one selected id, both sides ──
209
139
  const pulse = (id: string): void => {
210
140
  setPulseAnn(id);
@@ -212,28 +142,19 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
212
142
  pulseTimer.current = setTimeout(() => setPulseAnn(null), 2000);
213
143
  };
214
144
 
215
- const revealRailCard = (annotationId: string): void => {
216
- const index = session?.annotations.findIndex((annotation) => annotation.id === annotationId) ?? -1;
217
- if (index >= 0) railScrollRef.current?.scrollTo(Math.max(0, index * RAIL_CARD_HEIGHT - 1));
218
- };
219
-
220
145
  /** Card activation scrolls the document to the anchor and pulses it. */
221
146
  const revealAnchor = (annotationId: string): void => {
222
147
  for (const [dispIdx, blockMarks] of marks) {
223
148
  if (!blockMarks.some((mark) => mark.annotationId === annotationId)) continue;
224
149
  setCursor(dispIdx);
225
- try {
226
- docScrollRef.current?.scrollChildIntoView(`plan-block-${dispIdx}`);
227
- } catch {
228
- // reveal is best-effort; selection state is already correct
229
- }
150
+ planSheetRef.current?.revealBlock(dispIdx);
230
151
  return;
231
152
  }
232
153
  };
233
154
 
234
155
  const selectCardFromDocument = (annotationId: string): void => {
235
156
  setFocusedAnn(annotationId);
236
- revealRailCard(annotationId);
157
+ railRef.current?.revealCard(annotationId);
237
158
  };
238
159
 
239
160
  const selectCardFromRail = (annotationId: string): void => {
@@ -251,6 +172,16 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
251
172
  setMode({ m: "railEdit", id: annotation.id, text: annotation.body });
252
173
  };
253
174
 
175
+ /** The $EDITOR hand-off releases the terminal: suspend, edit, resume. */
176
+ const runEditorHandOff = (): void => {
177
+ renderer?.suspend();
178
+ try {
179
+ controller.edit();
180
+ } finally {
181
+ renderer?.resume();
182
+ }
183
+ };
184
+
254
185
  // ── keyboard grammar: build state, reduce, dispatch ──
255
186
  const dispatch = (intent: Intent): void => {
256
187
  switch (intent.t) {
@@ -287,7 +218,6 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
287
218
  if (mode.m === "span") {
288
219
  const span = spanKey(mode.span, intent.name, displayText(display[mode.span.dispIdx]!));
289
220
  setMode({ m: "span", span });
290
- driveNativeSelection(span);
291
221
  }
292
222
  return;
293
223
  case "openCompose": {
@@ -306,7 +236,7 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
306
236
  if (row) setMode({ m: "compose", kind: intent.kind, dispIdx: cursor, start: 0, end: row.text.length, text: "" });
307
237
  } else {
308
238
  // a mouse drag leaves a native selection; it wins over the cursor block
309
- const native = readNativeSelection();
239
+ const native = planSheetRef.current?.readSelection() ?? null;
310
240
  if (native) {
311
241
  setMode({ m: "compose", kind: intent.kind, ...native, text: "" });
312
242
  } else {
@@ -325,7 +255,7 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
325
255
  case "cut":
326
256
  return controller.cut(cursor);
327
257
  case "edit":
328
- return controller.edit();
258
+ return runEditorHandOff();
329
259
  case "editCard":
330
260
  if (focusedAnn) openCardEdit(focusedAnn);
331
261
  return;
@@ -344,7 +274,7 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
344
274
  }
345
275
  return;
346
276
  case "deselect":
347
- renderer?.clearSelection();
277
+ planSheetRef.current?.clearSelection();
348
278
  setFocusedAnn(undefined);
349
279
  setPulseAnn(null);
350
280
  return;
@@ -380,20 +310,22 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
380
310
  }
381
311
  };
382
312
 
313
+ const overlay: KeyState["overlay"] =
314
+ mode.m === "compose" || mode.m === "railEdit"
315
+ ? "compose"
316
+ : mode.m === "submit"
317
+ ? "submit"
318
+ : completion.phase === "prompt"
319
+ ? "completion-prompt"
320
+ : completion.phase === "counting"
321
+ ? "completion-counting"
322
+ : "none";
323
+
383
324
  useKeyboard((key) => {
384
325
  const state: KeyState = {
385
326
  keys: keysRef.current,
386
327
  readOnly,
387
- overlay:
388
- mode.m === "compose" || mode.m === "railEdit"
389
- ? "compose"
390
- : mode.m === "submit"
391
- ? "submit"
392
- : completion.phase === "prompt"
393
- ? "completion-prompt"
394
- : completion.phase === "counting"
395
- ? "completion-counting"
396
- : "none",
328
+ overlay,
397
329
  view: !session ? "inbox" : isDiff ? "diff" : "plan",
398
330
  spanMode: mode.m === "span",
399
331
  resolved: !!resolved,
@@ -404,25 +336,47 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
404
336
  ? rows[cursor] !== undefined && rows[cursor]!.t !== "file" && rows[cursor]!.t !== "hunk"
405
337
  : !!display[cursor]?.work,
406
338
  };
407
- for (const intent of reduceKey(state, { name: key.name, shift: !!key.shift })) dispatch(intent);
339
+ keyBindings.setContext({ overlay: state.overlay, spanMode: state.spanMode });
340
+ const action = keyBindings.resolveAction({ name: key.name, shift: !!key.shift });
341
+ for (const intent of reduceKey(state, { name: key.name, shift: !!key.shift }, action)) dispatch(intent);
408
342
  });
409
343
 
410
344
  // ── render ──────────────────────────────────
411
- if (error) return <text fg={T.red}>cueloop: {error}</text>;
412
- if (!session && !inbox) return <text fg={T.textDim}>connecting to the daemon…</text>;
413
- if (!session && inbox) return <Inbox inbox={inbox} cursor={inboxCursor} />;
345
+ if (error) {
346
+ return (
347
+ <ThemeProvider theme={theme}>
348
+ <text fg={theme.red}>cueloop: {error}</text>
349
+ </ThemeProvider>
350
+ );
351
+ }
352
+ if (!session && !inbox) {
353
+ return (
354
+ <ThemeProvider theme={theme}>
355
+ <text fg={theme.textDim}>connecting to the daemon…</text>
356
+ </ThemeProvider>
357
+ );
358
+ }
359
+ if (!session && inbox) {
360
+ return (
361
+ <ThemeProvider theme={theme}>
362
+ <InboxList inbox={inbox} cursor={inboxCursor} />
363
+ </ThemeProvider>
364
+ );
365
+ }
414
366
 
415
367
  const s = session!;
416
368
  const pendingCount = s.annotations.length + (s.workingCopy !== undefined ? 1 : 0);
417
369
 
418
370
  if ((completion.phase === "prompt" || completion.phase === "counting") && s.verdict) {
419
371
  return (
420
- <CompletionOverlay
421
- verdict={s.verdict.kind}
422
- completion={completion}
423
- status={status}
424
- returnsTo={returnPaneFor(s.artifact.meta.herdrPane) ? (s.artifact.meta.agent ?? "the agent") : undefined}
425
- />
372
+ <ThemeProvider theme={theme}>
373
+ <CompletionOverlay
374
+ verdict={s.verdict.kind}
375
+ completion={completion}
376
+ status={status}
377
+ returnsTo={returnPaneFor(s.artifact.meta.herdrPane) ? (s.artifact.meta.agent ?? "the agent") : undefined}
378
+ />
379
+ </ThemeProvider>
426
380
  );
427
381
  }
428
382
 
@@ -432,20 +386,25 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
432
386
  kind: mode.kind,
433
387
  dispIdx: mode.dispIdx,
434
388
  quote: displayText(display[mode.dispIdx]!).slice(mode.start, mode.end),
435
- text: mode.text,
436
- onInput: (text: string) => {
437
- liveInput.current = text;
438
- setMode({ ...mode, text });
389
+ draft: {
390
+ text: mode.text,
391
+ onInput: (text: string) => {
392
+ liveInput.current = text;
393
+ setMode({ ...mode, text });
394
+ },
395
+ onSave: () => dispatch({ t: "saveCompose" }),
396
+ onCancel: () => dispatch({ t: "closeOverlay" }),
439
397
  },
440
- onSave: () => dispatch({ t: "saveCompose" }),
441
- onCancel: () => dispatch({ t: "closeOverlay" }),
442
398
  }
443
399
  : null;
444
400
 
445
- const registerLine = (key: string, renderable: TextRenderable | null, meta: Omit<PlanLineRef, "renderable">): void => {
446
- if (renderable) lineRefs.current.set(key, { renderable, ...meta });
447
- else lineRefs.current.delete(key);
448
- };
401
+ const activeSpan =
402
+ mode.m === "span"
403
+ ? { dispIdx: mode.span.dispIdx, start: mode.span.start, end: mode.span.end }
404
+ : mode.m === "compose" && !isDiff
405
+ ? // the compose anchor stays painted selection-style while the box is open
406
+ { dispIdx: mode.dispIdx, start: mode.start, end: mode.end }
407
+ : null;
449
408
 
450
409
  const onLineActivate = (dispIdx: number): void => {
451
410
  // releasing a drag-selection lands here too; a live selection is not a click
@@ -458,7 +417,7 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
458
417
  const onEditRequest = (): void => {
459
418
  if (readOnly) return controller.setStatus("observer - read-only");
460
419
  if (resolved) return controller.setStatus("review submitted - read-only");
461
- controller.edit();
420
+ runEditorHandOff();
462
421
  };
463
422
 
464
423
  // clicking the rail Submit button: same read-only answer as the submit key
@@ -468,7 +427,7 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
468
427
  dispatch({ t: "openSubmit" });
469
428
  };
470
429
 
471
- const submitConfirmState: SubmitConfirmState | null =
430
+ const submitConfirmState =
472
431
  mode.m === "submit"
473
432
  ? {
474
433
  verdict: mode.verdict,
@@ -485,765 +444,93 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
485
444
  }
486
445
  : null;
487
446
 
488
- return (
489
- <box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: T.bg }}>
490
- <box style={{ height: 1, backgroundColor: T.panel, paddingLeft: 1, flexDirection: "row" }}>
491
- <text fg={T.text}>
492
- <span fg={T.accent}>cueloop</span>
493
- <span fg={T.textDim}> · {s.artifact.meta.title ?? s.artifact.meta.planPath ?? s.id} · rev {s.revisions.length}</span>
494
- {resolved ? <span fg={T.green}> · resolved: {s.verdict!.kind.replace("_", " ")}</span> : null}
495
- {readOnly ? <span fg={T.textDim}> · observer</span> : null}
496
- {status ? <span fg={T.accent}> · {status}</span> : null}
497
- </text>
498
- </box>
499
- <box style={{ flexGrow: 1, flexDirection: "row" }}>
500
- {isDiff ? (
501
- <DiffPane rows={rows} cursor={cursor} annotations={s.annotations} focusedAnn={focusedAnn} />
502
- ) : (
503
- <PlanPane
504
- display={display}
505
- marks={marks}
506
- cursor={cursor}
507
- mode={mode}
508
- session={s}
509
- editOrphanCount={editOrphanCount}
510
- compose={composeState}
511
- registerLine={registerLine}
512
- onLineActivate={onLineActivate}
513
- onEditRequest={onEditRequest}
514
- docScrollRef={docScrollRef}
515
- />
516
- )}
517
- <Rail
518
- session={s}
519
- selectedId={focusedAnn}
520
- resolvedIds={isDiff ? null : resolvedIds}
521
- railTab={railTab}
522
- pendingCount={pendingCount}
523
- cardEdit={
524
- mode.m === "railEdit"
525
- ? {
526
- id: mode.id,
527
- text: mode.text,
528
- onInput: (text: string) => {
529
- liveInput.current = text;
530
- setMode({ m: "railEdit", id: mode.id, text });
531
- },
532
- onSave: () => dispatch({ t: "saveCompose" }),
533
- onCancel: () => dispatch({ t: "closeOverlay" }),
534
- }
535
- : null
536
- }
537
- submitConfirm={submitConfirmState}
538
- onTab={setRailTab}
539
- onSelectCard={selectCardFromRail}
540
- onActivateCard={openCardEdit}
541
- onSubmitRequest={onSubmitRequest}
542
- railScrollRef={railScrollRef}
543
- />
544
- </box>
545
- {mode.m === "compose" && isDiff ? (
546
- <ComposeBar mode={mode} quote={rows[mode.dispIdx]?.text ?? ""} onChange={(text) => { liveInput.current = text; setMode({ ...mode, text }); }} />
547
- ) : (
548
- <box style={{ height: 1, backgroundColor: T.panel, paddingLeft: 1 }}>
549
- <text fg={T.textDim}>
550
- {readOnly
551
- ? "observer - read-only · j/k move · n/p annotations · q quit"
552
- : mode.m === "submit"
553
- ? "verdict ←/→ · ⏎ submit · esc cancel"
554
- : mode.m === "span"
555
- ? "span · l/h grow/shrink · w/b slide · $ end · c comment · s suggest · esc"
556
- : mode.m === "compose" || mode.m === "railEdit"
557
- ? "typing · ⏎ save · esc cancel"
558
- : focusedAnn !== undefined
559
- ? "card · e edit · x Cut · n/p cards · esc deselect · ⏎ submit"
560
- : "j/k move · v span · drag selects · c comment · s suggest · x cut · e edit · n/p annotations · ⏎ submit · q quit"}
561
- </text>
562
- </box>
563
- )}
564
- </box>
565
- );
566
- }
567
-
568
- // ── panes ─────────────────────────────────────
569
-
570
- interface ComposeState {
571
- kind: "comment" | "suggestion";
572
- dispIdx: number;
573
- quote: string;
574
- text: string;
575
- onInput: (text: string) => void;
576
- onSave: () => void;
577
- onCancel: () => void;
578
- }
579
-
580
- function PlanPane({
581
- display,
582
- marks,
583
- cursor,
584
- mode,
585
- session,
586
- editOrphanCount,
587
- compose,
588
- registerLine,
589
- onLineActivate,
590
- onEditRequest,
591
- docScrollRef,
592
- }: {
593
- display: DisplayBlock[];
594
- marks: Map<number, Mark[]>;
595
- cursor: number;
596
- mode: Mode;
597
- session: ReviewSession;
598
- editOrphanCount: number;
599
- compose: ComposeState | null;
600
- registerLine: (key: string, renderable: TextRenderable | null, meta: Omit<PlanLineRef, "renderable">) => void;
601
- onLineActivate: (dispIdx: number) => void;
602
- onEditRequest: () => void;
603
- docScrollRef: React.RefObject<ScrollBoxRenderable | null>;
604
- }): React.ReactNode {
605
- const width = 76;
606
- const children: React.ReactNode[] = [];
607
- for (let i = 0; i < display.length; i++) {
608
- const d = display[i]!;
609
- const isCursor = i === cursor;
610
- const gap = topGap(display[i - 1], d);
611
- if (d.kind === "code") {
612
- children.push(
613
- <CodeBlock key={i} id={`plan-block-${i}`} block={d} isCursor={isCursor} gap={gap} annotated={(marks.get(i) ?? []).length > 0} />,
614
- );
615
- } else {
616
- const blockMarks = [...(marks.get(i) ?? [])];
617
- if (mode.m === "span" && mode.span.dispIdx === i) {
618
- blockMarks.push({ start: mode.span.start, end: mode.span.end, role: "kspan" });
619
- }
620
- // the compose anchor stays painted selection-style while the box is open
621
- if (mode.m === "compose" && mode.dispIdx === i) {
622
- blockMarks.push({ start: mode.start, end: mode.end, role: "kspan" });
623
- }
624
- const runs = overlayMarks(blockRuns(d, true), blockMarks);
625
- const lines = wrapRuns(runs, width - marker(d).length);
626
- const prefixColumns = 2 + marker(d).length;
627
- children.push(
628
- <box key={i} id={`plan-block-${i}`} style={{ flexDirection: "column", marginTop: gap }}>
629
- {lines.map((line, li) => {
630
- const positioned = line.filter((run) => run.start !== null);
631
- const lineStart = positioned.length ? positioned[0]!.start : null;
632
- const lastPositioned = positioned[positioned.length - 1];
633
- const textLength =
634
- lineStart !== null && lastPositioned ? lastPositioned.start! + lastPositioned.text.length - lineStart : 0;
635
- return (
636
- <text
637
- key={li}
638
- bg={isCursor ? T.cursorBg : undefined}
639
- selectable
640
- selectionBg={T.accent}
641
- selectionFg={T.accentInk}
642
- ref={(renderable: TextRenderable | null) =>
643
- registerLine(`${i}:${li}`, renderable, { dispIdx: i, lineIndex: li, lineStart, prefixColumns, textLength })
644
- }
645
- onMouseUp={() => onLineActivate(i)}
646
- >
647
- <span fg={isCursor ? T.accent : T.textDim}>{li === 0 ? cursorGlyph(isCursor) : " "}</span>
648
- <span fg={T.textDim}>{li === 0 ? marker(d) : " ".repeat(marker(d).length)}</span>
649
- {line.map((r, ri) => (
650
- <span key={ri} {...runStyle(r, d)}>
651
- {r.text}
652
- </span>
653
- ))}
654
- {li === 0 && d.type !== "same" ? <span fg={tagColor(d)}> [{tagLabel(d)}]</span> : null}
655
- </text>
656
- );
657
- })}
658
- </box>,
659
- );
660
- }
661
- if (compose && compose.dispIdx === i) {
662
- children.push(
663
- <AnnotationCard
664
- key={`compose-${i}`}
665
- kind={compose.kind}
666
- quote={compose.quote}
667
- draft={{ text: compose.text, onInput: compose.onInput, onSave: compose.onSave, onCancel: compose.onCancel }}
668
- />,
669
- );
670
- }
671
- }
672
- return (
673
- <box style={{ flexGrow: 1, flexDirection: "column" }}>
674
- <SheetHeader session={session} onEditRequest={onEditRequest} />
675
- {editOrphanCount > 0 ? (
676
- <box style={{ height: 1, backgroundColor: T.markCommentBg, paddingLeft: 2 }}>
677
- <text fg={T.red}>
678
- {editOrphanCount} annotation{editOrphanCount === 1 ? "" : "s"} no longer match - the passage was removed.
679
- </text>
680
- </box>
681
- ) : null}
682
- <scrollbox ref={docScrollRef} style={{ flexGrow: 1, paddingLeft: 2, paddingTop: 1 }} focused={false}>
683
- {children}
684
- </scrollbox>
685
- </box>
686
- );
687
- }
688
-
689
- /** Sheet chrome: submitted-by + revision delta left, the Edit word-button right. */
690
- function SheetHeader({ session, onEditRequest }: { session: ReviewSession; onEditRequest: () => void }): React.ReactNode {
691
- const revisionCount = session.revisions.length;
692
- const previous = revisionCount > 1 ? session.revisions[revisionCount - 2] : undefined;
693
- const delta = previous ? revisionDelta(previous.content, session.artifact.content) : null;
694
- return (
695
- <box style={{ height: 1, flexDirection: "row", paddingLeft: 2, paddingRight: 1 }}>
696
- <text fg={T.textDim}>
697
- submitted by <span fg={T.textMuted}>{session.artifact.meta.agent ?? "unknown"}</span> · revision {revisionCount}
698
- {delta ? (
699
- <span fg={T.green}>
700
- {" "}· v{revisionCount - 1}→v{revisionCount} +{delta.added} -{delta.removed}
701
- </span>
702
- ) : null}
703
- </text>
704
- <box style={{ flexGrow: 1 }} />
705
- <box onMouseUp={onEditRequest}>
706
- <text fg={T.textDim}> Edit </text>
707
- </box>
708
- </box>
709
- );
710
- }
711
-
712
- function CodeBlock({
713
- id,
714
- block,
715
- isCursor,
716
- gap,
717
- annotated,
718
- }: {
719
- id: string;
720
- block: DisplayBlock;
721
- isCursor: boolean;
722
- gap: number;
723
- annotated: boolean;
724
- }): React.ReactNode {
725
- const lang = (block.work ?? block.base)?.lang;
726
- const content = displayText(block);
727
- // Shiki tokens arrive async; plain verbatim lines render until then
728
- const [tokens, setTokens] = useState<CodeToken[][] | null>(null);
729
- useEffect(() => {
730
- let live = true;
731
- void highlightCode(content, lang).then((t) => {
732
- if (live && t) setTokens(t);
733
- });
734
- return () => {
735
- live = false;
736
- };
737
- }, [content, lang]);
738
- const lines: CodeToken[][] = tokens ?? content.split("\n").map((l) => [{ content: l }]);
739
- return (
740
- <box id={id} style={{ flexDirection: "column", marginTop: gap }}>
741
- <text>
742
- <span fg={isCursor ? T.accent : T.textDim}>{cursorGlyph(isCursor)}</span>
743
- <span fg={T.textDim}>{lang ?? "code"}</span>
744
- {annotated ? <span fg={T.accent}> ◆</span> : null}
745
- {block.type !== "same" ? <span fg={tagColor(block)}> [{tagLabel(block)}]</span> : null}
746
- </text>
747
- <box
748
- style={{
749
- flexDirection: "column",
750
- backgroundColor: T.elevated,
751
- paddingLeft: 2,
752
- paddingRight: 2,
753
- paddingTop: 1,
754
- paddingBottom: 1,
755
- marginLeft: 2,
756
- }}
757
- >
758
- {lines.map((line, li) => (
759
- <text key={li}>
760
- {line.length === 0 || (line.length === 1 && line[0]!.content === "") ? (
761
- " "
762
- ) : (
763
- line.map((tok, ti) => (
764
- <span key={ti} fg={tok.color ?? T.textMuted}>
765
- {tok.content}
766
- </span>
767
- ))
768
- )}
769
- </text>
770
- ))}
771
- </box>
772
- </box>
773
- );
774
- }
775
-
776
- /** Vertical rhythm: gaps live ABOVE blocks so boundaries never collapse. */
777
- function topGap(prev: DisplayBlock | undefined, cur: DisplayBlock): number {
778
- if (!prev) return 0;
779
- const tightPair =
780
- (cur.kind === "li" && prev.kind === "li") || (cur.kind === "oli" && prev.kind === "oli");
781
- return tightPair ? 0 : 1;
782
- }
783
-
784
- // ── the rail ──────────────────────────────────
785
-
786
- interface CardEditState {
787
- id: string;
788
- text: string;
789
- onInput: (text: string) => void;
790
- onSave: () => void;
791
- onCancel: () => void;
792
- }
447
+ const cardEditState =
448
+ mode.m === "railEdit"
449
+ ? {
450
+ id: mode.id,
451
+ text: mode.text,
452
+ onInput: (text: string) => {
453
+ liveInput.current = text;
454
+ setMode({ m: "railEdit", id: mode.id, text });
455
+ },
456
+ onSave: () => dispatch({ t: "saveCompose" }),
457
+ onCancel: () => dispatch({ t: "closeOverlay" }),
458
+ }
459
+ : null;
793
460
 
794
- /** The open confirm card: verdict, summary draft, counts, and its actions. */
795
- interface SubmitConfirmState {
796
- verdict: VerdictKind;
797
- summary: string;
798
- annotationCount: number;
799
- blockingCount: number;
800
- onInput: (summary: string) => void;
801
- onSelectVerdict: (verdict: VerdictKind) => void;
802
- onSubmit: () => void;
803
- onCancel: () => void;
804
- }
461
+ const headerItems: BreadcrumbItem[] = [
462
+ { label: "cueloop", tone: "accent" },
463
+ { label: `${s.artifact.meta.title ?? s.artifact.meta.planPath ?? s.id} · rev ${s.revisions.length}`, tone: "dim" },
464
+ ...(resolved ? [{ label: `resolved: ${s.verdict!.kind.replace("_", " ")}`, tone: "green" as const }] : []),
465
+ ...(readOnly ? [{ label: "observer", tone: "dim" as const }] : []),
466
+ ...(status ? [{ label: status, tone: "accent" as const }] : []),
467
+ ];
468
+
469
+ keyBindings.setContext({ overlay, spanMode: mode.m === "span" });
470
+ const hintMode: HintMode = readOnly
471
+ ? "read-only"
472
+ : mode.m === "submit"
473
+ ? "submit"
474
+ : mode.m === "span"
475
+ ? "span"
476
+ : mode.m === "compose" || mode.m === "railEdit"
477
+ ? "compose"
478
+ : focusedAnn !== undefined
479
+ ? "card"
480
+ : "normal";
481
+ const railWidth = terminalWidth >= 100 ? 34 : 28;
805
482
 
806
- function Rail({
807
- session,
808
- selectedId,
809
- resolvedIds,
810
- railTab,
811
- pendingCount,
812
- cardEdit,
813
- submitConfirm,
814
- onTab,
815
- onSelectCard,
816
- onActivateCard,
817
- onSubmitRequest,
818
- railScrollRef,
819
- }: {
820
- session: ReviewSession;
821
- selectedId?: string;
822
- /** Ids whose anchor resolved; null = orphan display off (diff view). */
823
- resolvedIds: Set<string> | null;
824
- railTab: RailTab;
825
- pendingCount: number;
826
- cardEdit: CardEditState | null;
827
- submitConfirm: SubmitConfirmState | null;
828
- onTab: (tab: RailTab) => void;
829
- onSelectCard: (id: string) => void;
830
- onActivateCard: (id: string) => void;
831
- onSubmitRequest: () => void;
832
- railScrollRef: React.RefObject<ScrollBoxRenderable | null>;
833
- }): React.ReactNode {
834
483
  return (
835
- <box style={{ width: 34, backgroundColor: T.panel, flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
836
- <box style={{ height: 1, flexDirection: "row" }}>
837
- <box onMouseUp={() => onTab("review")}>
838
- <text fg={railTab === "review" ? T.accent : T.textDim}>Review ({pendingCount})</text>
839
- </box>
840
- <text fg={T.textDim}>{" "}</text>
841
- <box onMouseUp={() => onTab("agent")}>
842
- <text fg={railTab === "agent" ? T.accent : T.textDim}>Agent</text>
843
- </box>
844
- </box>
845
- <text> </text>
846
- {railTab === "agent" ? (
847
- <AgentTab session={session} />
848
- ) : (
849
- <>
850
- {session.workingCopy !== undefined ? <text fg={T.textDim}>± plan edits → one diff</text> : null}
851
- {session.annotations.length === 0 ? (
852
- <text fg={T.textDim}>no annotations yet</text>
853
- ) : (
854
- <scrollbox ref={railScrollRef} style={{ flexGrow: 1 }} focused={false}>
855
- {session.annotations.map((annotation) => (
856
- <AnnotationCard
857
- key={annotation.id}
858
- kind={annotation.kind}
859
- quote={annotation.anchor.quote}
860
- saved={{
861
- body: annotation.body,
862
- selected: annotation.id === selectedId,
863
- orphan: resolvedIds !== null && !resolvedIds.has(annotation.id),
864
- blocking: annotationBlocking(annotation),
865
- editing:
866
- cardEdit && cardEdit.id === annotation.id
867
- ? { text: cardEdit.text, onInput: cardEdit.onInput, onSave: cardEdit.onSave, onCancel: cardEdit.onCancel }
868
- : null,
869
- onMouseUp: () =>
870
- annotation.id === selectedId ? onActivateCard(annotation.id) : onSelectCard(annotation.id),
871
- }}
872
- />
873
- ))}
874
- </scrollbox>
875
- )}
876
- <box style={{ flexGrow: 1 }} />
877
- {/* the confirm card sits OUTSIDE the scrollbox: the annotation stack
878
- above scrolls while the card stays pinned to the rail bottom */}
879
- {session.status === "resolved" ? (
880
- <text fg={T.green}>resolved: {session.verdict!.kind.replace("_", " ")}</text>
881
- ) : submitConfirm ? (
882
- <SubmitConfirmCard confirm={submitConfirm} />
484
+ <ThemeProvider theme={theme}>
485
+ <box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: theme.bg }}>
486
+ <Breadcrumb items={headerItems} />
487
+ <box style={{ flexGrow: 1, flexDirection: "row" }}>
488
+ {isDiff ? (
489
+ <DiffSheet rows={rows} cursor={cursor} annotations={s.annotations} focusedAnnotationId={focusedAnn} />
883
490
  ) : (
884
- <box onMouseUp={onSubmitRequest}>
885
- <text fg={T.accent}>{`Submit review (${pendingCount}) ⏎`}</text>
886
- </box>
491
+ <PlanSheet
492
+ ref={planSheetRef}
493
+ session={s}
494
+ display={display}
495
+ marks={marks}
496
+ cursor={cursor}
497
+ activeSpan={activeSpan}
498
+ compose={composeState}
499
+ editOrphanCount={editOrphanCount}
500
+ onLineActivate={onLineActivate}
501
+ onEditRequest={onEditRequest}
502
+ />
887
503
  )}
888
- </>
889
- )}
890
- </box>
891
- );
892
- }
893
-
894
- /**
895
- * Confirm card content rows: counts, spacer, verdict selector, spacer,
896
- * summary input, spacer, Submit/Cancel buttons. The bordered box height is
897
- * this count plus the two border rows - a pure function of the content, so
898
- * layout and render never drift.
899
- */
900
- const SUBMIT_CONFIRM_CONTENT_ROWS = 7;
901
-
902
- function submitConfirmHeight(): number {
903
- return SUBMIT_CONFIRM_CONTENT_ROWS + 2;
904
- }
905
-
906
- /**
907
- * The Submit button expanded into a bordered confirm card at the rail bottom:
908
- * honest counts, the verdict selector (arrow keys or click), the optional
909
- * summary, and plain word-buttons - key hints live in the status line only.
910
- */
911
- function SubmitConfirmCard({ confirm }: { confirm: SubmitConfirmState }): React.ReactNode {
912
- return (
913
- <box
914
- style={{
915
- height: submitConfirmHeight(),
916
- marginRight: 1,
917
- border: true,
918
- borderStyle: "rounded",
919
- borderColor: T.accent,
920
- backgroundColor: T.elevated,
921
- flexDirection: "column",
922
- paddingLeft: 1,
923
- }}
924
- title=" submit review "
925
- >
926
- <text fg={T.textDim}>{`${confirm.annotationCount} annotations · ${confirm.blockingCount} blocking`}</text>
927
- <box style={{ height: 1 }} />
928
- <box style={{ flexDirection: "row", height: 1 }}>
929
- {VERDICTS.map((candidate) => (
930
- <box key={candidate} style={{ paddingRight: 1 }} onMouseUp={() => confirm.onSelectVerdict(candidate)}>
931
- <text fg={candidate === confirm.verdict ? verdictColor(candidate) : T.textDim}>
932
- {candidate === confirm.verdict ? `[${VERDICT_LABEL[candidate]}]` : ` ${VERDICT_LABEL[candidate]} `}
933
- </text>
934
- </box>
935
- ))}
936
- </box>
937
- <box style={{ height: 1 }} />
938
- <input focused value={confirm.summary} onInput={confirm.onInput} placeholder="summary for the agent (optional)" />
939
- <box style={{ height: 1 }} />
940
- <box style={{ flexDirection: "row", height: 1 }}>
941
- <box style={{ backgroundColor: T.accent, marginRight: 2 }} onMouseUp={confirm.onSubmit}>
942
- <text fg={T.accentInk}> Submit </text>
943
- </box>
944
- <box onMouseUp={confirm.onCancel}>
945
- <text fg={T.textDim}> Cancel </text>
504
+ <ReviewRail
505
+ ref={railRef}
506
+ session={s}
507
+ selectedId={focusedAnn}
508
+ resolvedIds={isDiff ? null : resolvedIds}
509
+ railTab={railTab}
510
+ pendingCount={pendingCount}
511
+ cardEdit={cardEditState}
512
+ submitConfirm={submitConfirmState}
513
+ onTabChange={setRailTab}
514
+ onSelectCard={selectCardFromRail}
515
+ onActivateCard={openCardEdit}
516
+ onSubmitRequest={onSubmitRequest}
517
+ width={railWidth}
518
+ />
946
519
  </box>
520
+ {mode.m === "compose" && isDiff ? (
521
+ <ComposeBar
522
+ kind={mode.kind}
523
+ quote={rows[mode.dispIdx]?.text ?? ""}
524
+ text={mode.text}
525
+ onInput={(text) => {
526
+ liveInput.current = text;
527
+ setMode({ ...mode, text });
528
+ }}
529
+ />
530
+ ) : (
531
+ <StatusBar>{keyBindings.statusHint(hintMode)}</StatusBar>
532
+ )}
947
533
  </box>
948
- </box>
949
- );
950
- }
951
-
952
- /** Agent tab: who submitted, where the session stands, which revision. */
953
- function AgentTab({ session }: { session: ReviewSession }): React.ReactNode {
954
- return (
955
- <box style={{ flexDirection: "column", flexGrow: 1 }}>
956
- <text fg={T.textMuted}>{session.artifact.meta.agent ?? "unknown"}</text>
957
- <text fg={T.textDim}>status: {session.status}</text>
958
- <text fg={T.textDim}>revision {session.revisions.length}</text>
959
- </box>
960
- );
961
- }
962
-
963
- /**
964
- * The composer and the rail card are one component in two modes: draft props
965
- * attached (bordered box inline in the document flow) or saved props attached
966
- * (rail card, optionally editing its body in place). Compose, saved, and
967
- * re-edit all share this rendering path.
968
- */
969
- function AnnotationCard({
970
- kind,
971
- quote,
972
- draft,
973
- saved,
974
- }: {
975
- kind: string;
976
- quote: string;
977
- draft?: { text: string; onInput: (text: string) => void; onSave: () => void; onCancel: () => void };
978
- saved?: {
979
- body: string;
980
- selected: boolean;
981
- orphan: boolean;
982
- blocking: boolean;
983
- editing: { text: string; onInput: (text: string) => void; onSave: () => void; onCancel: () => void } | null;
984
- onMouseUp: () => void;
985
- };
986
- }): React.ReactNode {
987
- const color = kind === "suggestion" ? T.green : T.accent;
988
- if (draft) {
989
- const verb = kind === "suggestion" ? "suggest replacement for" : "comment on";
990
- return (
991
- <box
992
- style={{
993
- height: composeBoxHeight(),
994
- marginLeft: 2,
995
- marginRight: 2,
996
- border: true,
997
- borderStyle: "rounded",
998
- borderColor: color,
999
- backgroundColor: T.elevated,
1000
- flexDirection: "column",
1001
- paddingLeft: 1,
1002
- }}
1003
- title={` ${verb} "${truncate(quote, 40)}" `}
1004
- >
1005
- <input focused value={draft.text} onInput={draft.onInput} placeholder="write a note..." />
1006
- <SaveCancelRow onSave={draft.onSave} onCancel={draft.onCancel} />
1007
- </box>
1008
- );
1009
- }
1010
- const card = saved!;
1011
- return (
1012
- <box
1013
- style={{ flexDirection: "column", marginBottom: 1, backgroundColor: card.selected ? T.elevated : undefined }}
1014
- onMouseUp={card.onMouseUp}
1015
- >
1016
- <text fg={card.selected ? T.text : color}>
1017
- {card.selected ? "▸ " : " "}
1018
- {kind.toUpperCase()}
1019
- {card.blocking ? <span fg={T.red}> · BLOCKING</span> : null}
1020
- <span fg={T.textDim}>{card.orphan ? " · ORPHANED" : " · pending"}</span>
1021
- </text>
1022
- <text fg={T.textDim}> "{truncate(quote, 26)}"</text>
1023
- {card.editing ? (
1024
- <>
1025
- <input focused value={card.editing.text} onInput={card.editing.onInput} />
1026
- <SaveCancelRow onSave={card.editing.onSave} onCancel={card.editing.onCancel} />
1027
- </>
1028
- ) : (
1029
- <text fg={card.orphan ? T.textDim : T.textMuted}> {truncate(card.body, 28)}</text>
1030
- )}
1031
- </box>
1032
- );
1033
- }
1034
-
1035
- function SaveCancelRow({ onSave, onCancel }: { onSave: () => void; onCancel: () => void }): React.ReactNode {
1036
- return (
1037
- <box style={{ flexDirection: "row", height: 1 }}>
1038
- <box style={{ backgroundColor: T.accent, marginRight: 2 }} onMouseUp={onSave}>
1039
- <text fg={T.accentInk}> Save ⏎ </text>
1040
- </box>
1041
- <box onMouseUp={onCancel}>
1042
- <text fg={T.textDim}> Cancel esc </text>
1043
- </box>
1044
- </box>
1045
- );
1046
- }
1047
-
1048
- /** Forward-compatible: open annotation kinds may carry a blocking flag. */
1049
- function annotationBlocking(annotation: Annotation): boolean {
1050
- return (annotation as Annotation & { blocking?: boolean }).blocking === true;
1051
- }
1052
-
1053
- function Inbox({ inbox, cursor }: { inbox: ReviewSession[]; cursor: number }): React.ReactNode {
1054
- return (
1055
- <box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: T.bg, padding: 1 }}>
1056
- <text fg={T.accent}>cueloop · inbox ({inbox.length} pending)</text>
1057
- <text> </text>
1058
- {inbox.length === 0 ? (
1059
- <text fg={T.textDim}>nothing waiting for review</text>
1060
- ) : (
1061
- inbox.map((s, i) => (
1062
- <text key={s.id} fg={i === cursor ? T.text : T.textMuted} bg={i === cursor ? T.cursorBg : undefined}>
1063
- {i === cursor ? "▸ " : " "}
1064
- {s.artifact.meta.title ?? s.id} · {s.workspace.branch} · {s.artifact.type}
1065
- </text>
1066
- ))
1067
- )}
1068
- <box style={{ flexGrow: 1 }} />
1069
- <text fg={T.textDim}>j/k move · ⏎ open · q quit</text>
1070
- </box>
1071
- );
1072
- }
1073
-
1074
- /** Bottom compose bar - the diff view still composes here. */
1075
- function ComposeBar({
1076
- mode,
1077
- quote,
1078
- onChange,
1079
- }: {
1080
- mode: Extract<Mode, { m: "compose" }>;
1081
- quote: string;
1082
- onChange: (text: string) => void;
1083
- }): React.ReactNode {
1084
- return (
1085
- <box style={{ height: 2, backgroundColor: T.elevated, flexDirection: "column", paddingLeft: 1 }}>
1086
- <text fg={mode.kind === "suggestion" ? T.green : T.accent}>
1087
- {mode.kind === "suggestion" ? "SUGGEST REPLACEMENT FOR" : "COMMENT ON"} “{truncate(quote, 60)}” · ⏎ save · esc cancel
1088
- </text>
1089
- <input focused value={mode.text} onInput={onChange} />
1090
- </box>
534
+ </ThemeProvider>
1091
535
  );
1092
536
  }
1093
-
1094
- function DiffPane({
1095
- rows,
1096
- cursor,
1097
- annotations,
1098
- focusedAnn,
1099
- }: {
1100
- rows: DiffRow[];
1101
- cursor: number;
1102
- annotations: Annotation[];
1103
- focusedAnn?: string;
1104
- }): React.ReactNode {
1105
- // simple windowing: keep the cursor in view without a scroll dependency
1106
- const windowStart = Math.max(0, cursor - 12);
1107
- const visible = rows.slice(windowStart, windowStart + 200);
1108
- const annotated = new Map<number, Annotation>();
1109
- for (const a of annotations) {
1110
- const idx = rows.findIndex((r) => r.text === a.anchor.quote && (r.t === "ctx" || r.t === "add" || r.t === "del"));
1111
- if (idx !== -1) annotated.set(idx, a);
1112
- }
1113
- return (
1114
- <box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
1115
- {visible.map((row, vi) => {
1116
- const i = windowStart + vi;
1117
- const isCursor = i === cursor;
1118
- const ann = annotated.get(i);
1119
- const gutter = row.t === "del" ? (row.oldLine?.toString() ?? "") : (row.newLine?.toString() ?? "");
1120
- const sign = row.t === "add" ? "+" : row.t === "del" ? "-" : " ";
1121
- if (row.t === "file") {
1122
- return (
1123
- <text key={i} fg={T.text} bg={isCursor ? T.cursorBg : T.panel}>
1124
- {isCursor ? "▎" : " "}■ {row.text}
1125
- </text>
1126
- );
1127
- }
1128
- if (row.t === "hunk") {
1129
- return (
1130
- <text key={i} fg={T.blue} bg={isCursor ? T.cursorBg : undefined}>
1131
- {isCursor ? "▎" : " "}{row.text}
1132
- </text>
1133
- );
1134
- }
1135
- const fg = row.t === "add" ? T.insFg : row.t === "del" ? T.delFg : T.textMuted;
1136
- return (
1137
- <box key={i} style={{ flexDirection: "column" }}>
1138
- <text bg={isCursor ? T.cursorBg : ann ? T.markCommentBg : undefined}>
1139
- <span fg={T.textDim}>{isCursor ? "▎" : " "}{gutter.padStart(4)} </span>
1140
- <span fg={fg}>{sign}{row.text}</span>
1141
- </text>
1142
- {ann ? (
1143
- <text>
1144
- <span fg={T.textDim}>{" "}</span>
1145
- <span fg={ann.id === focusedAnn ? T.text : T.accent}>◆ {truncate(ann.body, 70)}</span>
1146
- </text>
1147
- ) : null}
1148
- </box>
1149
- );
1150
- })}
1151
- </box>
1152
- );
1153
- }
1154
-
1155
- function CompletionOverlay({
1156
- verdict,
1157
- completion,
1158
- status,
1159
- returnsTo,
1160
- }: {
1161
- verdict: VerdictKind;
1162
- completion: { phase: "prompt" } | { phase: "counting"; remaining: number };
1163
- /** Latest status line (e.g. the vault-export path) stays visible here. */
1164
- status: string;
1165
- /** Where focus goes on close (the agent's herdr pane), when known. */
1166
- returnsTo?: string;
1167
- }): React.ReactNode {
1168
- const approved = verdict === "approve";
1169
- return (
1170
- <box
1171
- style={{
1172
- width: "100%",
1173
- height: "100%",
1174
- backgroundColor: T.bg,
1175
- flexDirection: "column",
1176
- justifyContent: "center",
1177
- alignItems: "center",
1178
- }}
1179
- >
1180
- <text fg={approved ? T.green : T.accent}>{approved ? "✓ review approved" : "✎ feedback sent"}</text>
1181
- <text> </text>
1182
- <text fg={T.text}>The agent has your {approved ? "approval" : "feedback"} and is unblocked.</text>
1183
- {status ? <text fg={T.textDim}>{status}</text> : null}
1184
- <text> </text>
1185
- {completion.phase === "counting" ? (
1186
- <text fg={T.textDim}>
1187
- closing in {completion.remaining}s{returnsTo ? ` - returning to ${returnsTo}` : ""} · ⏎ now · esc stay
1188
- </text>
1189
- ) : (
1190
- <text fg={T.textDim}>⏎ close · a always close after submit (3s) · esc stay</text>
1191
- )}
1192
- </box>
1193
- );
1194
- }
1195
-
1196
- // ── small helpers ─────────────────────────────
1197
-
1198
- function marker(d: DisplayBlock): string {
1199
- if (d.kind === "li") return "- ";
1200
- if (d.kind === "oli") return `${d.oliNum ?? 1}. `;
1201
- if (d.kind === "quote") return "▏ ";
1202
- return "";
1203
- }
1204
-
1205
- function cursorGlyph(isCursor: boolean): string {
1206
- return isCursor ? "▎ " : " ";
1207
- }
1208
-
1209
- function tagLabel(d: DisplayBlock): string {
1210
- return d.type === "del" ? "cut" : d.type === "add" ? "new" : "edited";
1211
- }
1212
-
1213
- function tagColor(d: DisplayBlock): string {
1214
- return d.type === "del" ? T.red : d.type === "add" ? T.green : T.accent;
1215
- }
1216
-
1217
- function verdictColor(v: VerdictKind): string {
1218
- return v === "approve" ? T.green : v === "request_changes" ? T.red : T.blue;
1219
- }
1220
-
1221
- function defaultVerdict(s: ReviewSession): VerdictKind {
1222
- return s.annotations.length || s.workingCopy !== undefined ? "request_changes" : "approve";
1223
- }
1224
-
1225
- function runStyle(r: StyleRun, d: DisplayBlock): { fg?: string; bg?: string; attributes?: number } {
1226
- const headingFg = d.kind === "h1" ? T.text : d.kind === "h2" || d.kind === "h3" ? T.accent : undefined;
1227
- const struck = d.type === "del";
1228
- switch (r.role) {
1229
- case "ins":
1230
- return { fg: T.insFg };
1231
- case "del":
1232
- return { fg: T.delFg };
1233
- case "mark-comment":
1234
- return { fg: T.text, bg: T.markCommentBg };
1235
- case "mark-suggestion":
1236
- return { fg: T.text, bg: T.markSuggestionBg };
1237
- case "mark-focus":
1238
- return { fg: T.accentInk, bg: T.accent };
1239
- case "kspan":
1240
- return { fg: T.accentInk, bg: T.accent };
1241
- default:
1242
- return { fg: struck ? T.red : (headingFg ?? (d.kind === "code" ? T.textMuted : T.text)) };
1243
- }
1244
- }
1245
-
1246
- function truncate(s: string, n: number): string {
1247
- const one = s.replace(/\n/g, " ");
1248
- return one.length > n ? one.slice(0, n - 1) + "…" : one;
1249
- }