@cueloop/client 0.1.0-alpha.7 → 0.1.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/App.tsx CHANGED
@@ -1,44 +1,43 @@
1
1
  /**
2
- * The cueloop TUI (#22): Ledger IA reduced to its terminal shape - header,
3
- * center artifact pane (projection renderer), right review rail, footer hint
4
- * bar. Thin renderer over daemon state: every mutation round-trips through
5
- * DaemonClient; local state is view state only (cursor, span, overlays).
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).
7
+ *
8
+ * The plan review grammar: selection is the entry primitive (mouse drag or
9
+ * keyboard span on one native renderer selection), compose happens inline
10
+ * under the anchor, annotation text lives in the rail while the document
11
+ * keeps only the kind-colored highlight, and the rail edits what the
12
+ * document selects - one selected id drives both sides.
6
13
  */
7
14
 
8
- import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
9
- import { useKeyboard } from "@opentui/react";
10
- import { DaemonClient } from "@cueloop/daemon/client";
11
- import {
12
- cutBlock,
13
- makeAnchor,
14
- restoreBlock,
15
- restoreLine,
16
- type Annotation,
17
- type ReviewSession,
18
- type VerdictKind,
19
- } from "@cueloop/schema";
15
+ 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";
20
19
  import {
21
20
  blockRuns,
22
- buildDisplay,
21
+ composeBoxHeight,
23
22
  displayText,
24
23
  marksByDisplay,
25
- nextWorkBlock,
26
24
  overlayMarks,
25
+ revisionDelta,
27
26
  spanKey,
28
27
  startSpan,
29
28
  wrapRuns,
30
29
  type DisplayBlock,
30
+ type Mark,
31
31
  type SpanState,
32
32
  type StyleRun,
33
33
  } from "./view";
34
- import { diffRows, diffRowAnchor, diffRowLocation, type DiffRow } from "./view-diff";
35
- import { editInEditor } from "./editor";
34
+ import { type DiffRow } from "./view-diff";
36
35
  import { DARK as T } from "./theme";
37
36
  import { highlightCode, type CodeToken } from "./syntax";
38
- import { DEFAULT_KEYS, actionFor, loadConfig, persistAutoClose, type AutoClose } from "./config";
39
- import { focusHerdrPane, returnPaneFor } from "./herdr";
40
- import { Registry, type Exporter } from "@cueloop/extension-api";
41
- import { createObsidianExtension, shouldExport, type ObsidianConfig } from "@cueloop/integration-obsidian";
37
+ import { DEFAULT_KEYS, loadConfig } from "./config";
38
+ import { returnPaneFor } from "./herdr";
39
+ import { createReviewController } from "./session-controller";
40
+ import { reduceKey, type Intent, type KeyState } from "./keymap";
42
41
 
43
42
  export interface AppProps {
44
43
  home?: string;
@@ -55,10 +54,10 @@ type Mode =
55
54
  | { m: "normal" }
56
55
  | { m: "span"; span: SpanState }
57
56
  | { m: "compose"; kind: "comment" | "suggestion"; dispIdx: number; start: number; end: number; text: string }
57
+ | { m: "railEdit"; id: string; text: string }
58
58
  | { m: "submit"; verdict: VerdictKind; summary: string };
59
59
 
60
- /** Verbs that write session state; an observer never reaches their handlers. */
61
- const MUTATING_ACTIONS = new Set(["comment", "suggest", "cut", "edit", "delete_annotation", "submit"]);
60
+ type RailTab = "review" | "agent";
62
61
 
63
62
  const VERDICTS: VerdictKind[] = ["comment", "approve", "request_changes"];
64
63
  const VERDICT_LABEL: Record<VerdictKind, string> = {
@@ -67,353 +66,342 @@ const VERDICT_LABEL: Record<VerdictKind, string> = {
67
66
  request_changes: "Request changes",
68
67
  };
69
68
 
69
+ /** One rendered plan line, registered for the native selection primitive. */
70
+ interface PlanLineRef {
71
+ renderable: TextRenderable;
72
+ dispIdx: number;
73
+ lineIndex: number;
74
+ /** Block-text offset of the line's first positioned run; null for pure-del lines. */
75
+ lineStart: number | null;
76
+ /** Screen columns before the run text (cursor glyph + list marker). */
77
+ prefixColumns: number;
78
+ /** Length of the positioned run text in this line. */
79
+ textLength: number;
80
+ }
81
+
82
+ /** Rail card height (3 text rows + 1 margin), for reveal-scroll math. */
83
+ const RAIL_CARD_HEIGHT = 4;
84
+
70
85
  export function App({ home, sessionId, readOnly = false, onExit }: AppProps): React.ReactNode {
71
- const clientRef = useRef<DaemonClient | null>(null);
72
- const [error, setError] = useState<string | null>(null);
73
- const [inbox, setInbox] = useState<ReviewSession[] | null>(null);
74
- const [session, setSession] = useState<ReviewSession | null>(null);
86
+ const controller = useMemo(
87
+ () => createReviewController({ home, sessionId, readOnly, onExit }),
88
+ // eslint-disable-next-line react-hooks/exhaustive-deps
89
+ [home, sessionId],
90
+ );
91
+ useEffect(() => {
92
+ controller.connect();
93
+ return () => controller.close();
94
+ }, [controller]);
95
+ const { session, inbox, status, error, completion, editOrphanCount } = useSyncExternalStore(
96
+ controller.subscribe,
97
+ controller.getSnapshot,
98
+ controller.getSnapshot,
99
+ );
100
+ const renderer = useRenderer();
101
+
102
+ // ── view state ──────────────────────────────
75
103
  const [cursor, setCursor] = useState(0);
76
104
  const [inboxCursor, setInboxCursor] = useState(0);
77
105
  const [mode, setMode] = useState<Mode>({ m: "normal" });
78
106
  const [focusedAnn, setFocusedAnn] = useState<string | undefined>(undefined);
79
- const [status, setStatus] = useState("");
80
- /**
81
- * Post-submit lifecycle (a review pane should hand you back to the agent,
82
- * not linger): idle → prompt (auto-close off: offer the choice) or
83
- * counting (configured delay) → exit. esc dismisses to the resolved view.
84
- */
85
- const [completion, setCompletion] = useState<
86
- { phase: "idle" } | { phase: "prompt" } | { phase: "counting"; remaining: number } | { phase: "dismissed" }
87
- >({ phase: "idle" });
88
- const autoCloseRef = useRef<AutoClose>("off");
89
- /** Close the review and, inside herdr, bounce focus back to the agent. */
90
- const finishReview = useCallback(() => {
91
- const pane = returnPaneFor(session?.artifact.meta.herdrPane);
92
- if (pane) focusHerdrPane(pane);
93
- onExit?.(0);
94
- }, [session, onExit]);
107
+ const [railTab, setRailTab] = useState<RailTab>("review");
108
+ // ~2s focus pulse on the document highlight when a rail card is activated
109
+ const [pulseAnn, setPulseAnn] = useState<string | null>(null);
110
+ const pulseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
95
111
  // live mirror of overlay input text: refs commit synchronously, so the
96
112
  // RETURN handler never reads a stale value mid-typing
97
113
  const liveInput = useRef("");
114
+ // rendered plan lines, for driving and reading the native selection
115
+ const lineRefs = useRef(new Map<string, PlanLineRef>());
116
+ const docScrollRef = useRef<ScrollBoxRenderable | null>(null);
117
+ const railScrollRef = useRef<ScrollBoxRenderable | null>(null);
98
118
  // keymap from layered config; theme overrides land on the shared tokens
99
119
  const keysRef = useRef(DEFAULT_KEYS);
100
- const obsidianRef = useRef<ObsidianConfig | null>(null);
101
- const exportersRef = useRef<Map<string, Exporter>>(new Map());
102
120
  useEffect(() => {
103
121
  const cfg = loadConfig({ repoRoot: session?.workspace.repoRoot });
104
122
  keysRef.current = cfg.keys;
105
- autoCloseRef.current = cfg.ui.autoClose;
106
123
  Object.assign(T, cfg.theme);
107
- obsidianRef.current = cfg.integrations.obsidian;
108
- // bundled integrations register through the public extension API
109
- const registry = new Registry();
110
- void registry.load("obsidian", createObsidianExtension(cfg.integrations.obsidian)).then((rec) => {
111
- exportersRef.current = rec.exporters;
112
- });
113
- }, [session?.workspace.repoRoot]);
114
-
115
- // ── daemon wiring ───────────────────────────
116
- useEffect(() => {
117
- let closed = false;
118
- void (async () => {
119
- try {
120
- const client = await DaemonClient.connect({ home, autostart: true });
121
- if (closed) return void client.close();
122
- clientRef.current = client;
123
- client.onEvent((e) => {
124
- // another controller/observer changed state: re-fetch
125
- if (session && e.sessionId === session.id) void refreshSession(e.sessionId);
126
- if (!sessionId) void refreshInbox();
127
- });
128
- await client.subscribe();
129
- if (sessionId) {
130
- setSession(await client.sessionGet(sessionId));
131
- } else {
132
- setInbox(await client.sessionList({ status: "pending" }));
133
- }
134
- } catch (err) {
135
- setError(err instanceof Error ? err.message : String(err));
136
- }
137
- })();
138
- return () => {
139
- closed = true;
140
- clientRef.current?.close();
141
- };
142
- // eslint-disable-next-line react-hooks/exhaustive-deps
143
- }, [home, sessionId]);
144
-
145
- const refreshSession = useCallback(async (id: string) => {
146
- const c = clientRef.current;
147
- if (c) setSession(await c.sessionGet(id));
148
- }, []);
149
- const refreshInbox = useCallback(async () => {
150
- const c = clientRef.current;
151
- if (c) setInbox(await c.sessionList({ status: "pending" }));
152
- }, []);
124
+ controller.applyConfig(cfg);
125
+ }, [session?.workspace.repoRoot, controller]);
126
+ useEffect(
127
+ () => () => {
128
+ if (pulseTimer.current) clearTimeout(pulseTimer.current);
129
+ },
130
+ [],
131
+ );
153
132
 
154
133
  // ── derived view model ──────────────────────
155
- const working = session ? (session.workingCopy ?? session.artifact.content) : "";
156
- const display = useMemo(
157
- () => (session ? buildDisplay(session.artifact.content, session.workingCopy) : []),
158
- [session],
159
- );
134
+ const display = controller.display();
135
+ const rows = controller.rows();
160
136
  const marks = useMemo(
161
- () => (session ? marksByDisplay(session.annotations, display, focusedAnn) : new Map()),
162
- [session, display, focusedAnn],
137
+ () => (session ? marksByDisplay(session.annotations, display, pulseAnn ?? undefined) : new Map<number, Mark[]>()),
138
+ [session, display, pulseAnn],
163
139
  );
140
+ /** Annotation ids whose anchor resolved against the working copy. */
141
+ const resolvedIds = useMemo(() => {
142
+ const ids = new Set<string>();
143
+ for (const blockMarks of marks.values()) {
144
+ for (const mark of blockMarks) if (mark.annotationId) ids.add(mark.annotationId);
145
+ }
146
+ return ids;
147
+ }, [marks]);
164
148
  const resolved = session?.status === "resolved";
165
149
  const isDiff = session?.artifact.type === "diff";
166
- const rows = useMemo(() => (session && isDiff ? diffRows(session.artifact.content) : []), [session, isDiff]);
167
-
168
- const apply = useCallback((p: Promise<ReviewSession>) => {
169
- p.then(setSession).catch((e: unknown) => setStatus(String(e instanceof Error ? e.message : e)));
170
- }, []);
171
-
172
- // ── verbs ───────────────────────────────────
173
- const verbCut = useCallback(() => {
174
- if (!session || resolved) return;
175
- const d = display[cursor];
176
- if (!d) return;
177
- const c = clientRef.current!;
178
- if (d.type === "del") {
179
- const line = restoreLine(nextWorkBlock(display, cursor), working.split("\n").length);
180
- // restoreBlock returns undefined when the block structure round-trips
181
- // to the submitted revision - the working copy is gone
182
- const restored = restoreBlock(session.artifact.content, working, d.base!, line);
183
- apply(c.sessionSetWorkingCopy(session.id, restored));
184
- setStatus("cut restored");
185
- } else if (d.work) {
186
- apply(c.sessionSetWorkingCopy(session.id, cutBlock(working, d.work)));
187
- setStatus("block cut - it serializes into the diff");
188
- }
189
- }, [session, resolved, display, cursor, working, apply]);
190
-
191
- const verbEdit = useCallback(() => {
192
- if (!session || resolved) return;
193
- try {
194
- const result = editInEditor(working, "plan.md");
195
- if (result.changed) {
196
- apply(clientRef.current!.sessionSetWorkingCopy(session.id, result.content));
197
- setStatus("edits tracked - one diff");
198
- } else setStatus("no changes");
199
- } catch (err) {
200
- setStatus(err instanceof Error ? err.message : String(err));
201
- }
202
- }, [session, resolved, working, apply]);
203
-
204
- const saveCompose = useCallback(
205
- (m: Extract<Mode, { m: "compose" }>, liveText: string) => {
206
- if (!session || !liveText.trim()) return setMode({ m: "normal" });
207
- let anchor;
208
- if (session.artifact.type === "diff") {
209
- anchor = { ...diffRowAnchor(rows, m.dispIdx), blockIndex: m.dispIdx };
210
- } else {
211
- const workBlocks = display.filter((d) => d.work).map((d) => d.work!);
212
- const workIdx = display.slice(0, m.dispIdx + 1).filter((d) => d.work).length - 1;
213
- anchor = makeAnchor(workBlocks, workIdx, m.start, m.end);
214
- }
215
- apply(
216
- clientRef.current!.sessionAnnotate(session.id, {
217
- id: `a_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`,
218
- kind: m.kind,
219
- anchor,
220
- body: liveText.trim(),
221
- }),
222
- );
223
- setMode({ m: "normal" });
224
- setStatus(m.kind === "suggestion" ? "suggestion added - the agent applies it" : "comment added");
225
- },
226
- [session, display, rows, apply],
227
- );
228
150
 
229
- const submit = useCallback(
230
- (verdict: VerdictKind, summary: string) => {
231
- if (!session) return;
232
- clientRef.current!
233
- .sessionResolve(session.id, verdict, summary)
234
- .then((resolved) => {
235
- setSession(resolved);
236
- setStatus(`review submitted - ${verdict.replace("_", " ")}`);
237
- // notes-vault export: guarded by config, default is manual (no-op)
238
- const obsidian = obsidianRef.current;
239
- const exporter = exportersRef.current.get("obsidian");
240
- if (obsidian && exporter && shouldExport(obsidian.exportOn, verdict)) {
241
- void exporter(resolved).then((r) => {
242
- setStatus(r.success && r.path ? `exported to ${r.path}` : `export failed: ${r.error ?? "unknown"}`);
243
- });
244
- }
245
- // hand the reviewer back to the agent: prompt or count down per config;
246
- // inside herdr with a known return pane, closing is the default
247
- const delay = autoCloseRef.current;
248
- const returns = returnPaneFor(resolved.artifact.meta.herdrPane) !== undefined;
249
- if (delay === 0) finishReview();
250
- else if (typeof delay === "number") setCompletion({ phase: "counting", remaining: delay });
251
- else if (returns) setCompletion({ phase: "counting", remaining: 3 });
252
- else setCompletion({ phase: "prompt" });
253
- })
254
- .catch((e: unknown) => setStatus(String(e instanceof Error ? e.message : e)));
255
- setMode({ m: "normal" });
256
- },
257
- [session, finishReview],
258
- );
151
+ // ── the native selection: keyboard driver ───
152
+ const lineRefFor = (dispIdx: number, offset: number): PlanLineRef | null => {
153
+ for (const meta of lineRefs.current.values()) {
154
+ if (meta.dispIdx !== dispIdx || meta.lineStart === null) continue;
155
+ if (offset >= meta.lineStart && offset < meta.lineStart + meta.textLength) return meta;
156
+ }
157
+ return null;
158
+ };
259
159
 
160
+ /** Anchor/extend the renderer's native selection from keyboard span offsets. */
161
+ const driveNativeSelection = (span: SpanState): void => {
162
+ if (!renderer) return;
163
+ const startMeta = lineRefFor(span.dispIdx, span.start);
164
+ const endMeta = lineRefFor(span.dispIdx, span.end - 1);
165
+ if (!startMeta || !endMeta) return;
166
+ const startColumn = startMeta.prefixColumns + (span.start - startMeta.lineStart!);
167
+ const endColumn = endMeta.prefixColumns + (span.end - 1 - endMeta.lineStart!);
168
+ renderer.startSelection(startMeta.renderable, startMeta.renderable.x + startColumn, startMeta.renderable.y);
169
+ renderer.updateSelection(endMeta.renderable, endMeta.renderable.x + endColumn + 1, endMeta.renderable.y);
170
+ };
171
+ // driving needs committed layout, so it runs after render; any transition
172
+ // out of span mode clears the renderer selection (compose paints its own
173
+ // mark, and a mouse drag never changes the mode, so it survives)
260
174
  useEffect(() => {
261
- if (completion.phase !== "counting") return;
262
- if (completion.remaining <= 0) return void finishReview();
263
- const timer = setTimeout(
264
- () => setCompletion((c) => (c.phase === "counting" ? { phase: "counting", remaining: c.remaining - 1 } : c)),
265
- 1000,
266
- );
267
- return () => clearTimeout(timer);
268
- }, [completion, finishReview]);
175
+ if (mode.m === "span") driveNativeSelection(mode.span);
176
+ else renderer?.clearSelection();
177
+ // eslint-disable-next-line react-hooks/exhaustive-deps
178
+ }, [mode]);
269
179
 
270
- // ── keyboard grammar ────────────────────────
271
- useKeyboard((key) => {
272
- const name = key.name;
273
- // compose/submit overlays own the keys via focused inputs; only escape here
274
- if (mode.m === "compose" || mode.m === "submit") {
275
- if (name === "escape") setMode({ m: "normal" });
276
- else if (name === "return" || name === "enter") {
277
- if (mode.m === "compose") saveCompose(mode, liveInput.current);
278
- else submit(mode.verdict, liveInput.current);
279
- } else if (mode.m === "submit" && (name === "left" || name === "right")) {
280
- const dir = name === "left" ? -1 : 1;
281
- const idx = (VERDICTS.indexOf(mode.verdict) + dir + VERDICTS.length) % VERDICTS.length;
282
- setMode({ ...mode, verdict: VERDICTS[idx]! });
283
- }
284
- return;
285
- }
286
- if (completion.phase === "prompt" || completion.phase === "counting") {
287
- if (name === "return" || name === "enter" || name === "q") return finishReview();
288
- if (name === "a" && completion.phase === "prompt") {
289
- // opt in: close in 3s from now on, persisted to the user config
290
- try {
291
- persistAutoClose(3);
292
- } catch {
293
- // a read-only config dir must not block closing the review
294
- }
295
- autoCloseRef.current = 3;
296
- return setCompletion({ phase: "counting", remaining: 3 });
297
- }
298
- if (name === "escape") return setCompletion({ phase: "dismissed" });
299
- return;
300
- }
301
- const action = actionFor(keysRef.current, name, !!key.shift);
302
- if (action === "quit") return onExit?.(0);
303
- if (readOnly && action && MUTATING_ACTIONS.has(action)) return setStatus("observer - read-only");
304
-
305
- // inbox mode
306
- if (!session) {
307
- if (!inbox?.length) return;
308
- if (name === "j" || name === "down") setInboxCursor((c) => Math.min(inbox.length - 1, c + 1));
309
- else if (name === "k" || name === "up") setInboxCursor((c) => Math.max(0, c - 1));
310
- else if (name === "return" || name === "enter") {
311
- const s = inbox[inboxCursor];
312
- if (s) setSession(s);
313
- }
314
- return;
180
+ /**
181
+ * Read the native selection (mouse drag) back as a block range. The quote
182
+ * anchors in the FIRST selected block - quote anchors resolve within one
183
+ * block. Columns map 1:1 onto text offsets except inside word-diffed mod
184
+ * blocks, where inline del runs shift the screen columns.
185
+ */
186
+ const readNativeSelection = (): { dispIdx: number; start: number; end: number } | null => {
187
+ if (!renderer?.hasSelection) return null;
188
+ const ordered = [...lineRefs.current.values()].sort(
189
+ (a, b) => a.dispIdx - b.dispIdx || a.lineIndex - b.lineIndex,
190
+ );
191
+ let found: { dispIdx: number; start: number; end: number } | null = null;
192
+ for (const meta of ordered) {
193
+ if (meta.lineStart === null) continue;
194
+ const selection = meta.renderable.getSelection();
195
+ if (!selection || selection.end <= selection.start) continue;
196
+ const startInText = Math.min(meta.textLength, Math.max(0, selection.start - meta.prefixColumns));
197
+ const endInText = Math.min(meta.textLength, Math.max(0, selection.end - meta.prefixColumns));
198
+ if (endInText <= startInText) continue;
199
+ const blockStart = meta.lineStart + startInText;
200
+ const blockEnd = meta.lineStart + endInText;
201
+ if (!found) found = { dispIdx: meta.dispIdx, start: blockStart, end: blockEnd };
202
+ else if (found.dispIdx === meta.dispIdx) found.end = Math.max(found.end, blockEnd);
315
203
  }
204
+ return found;
205
+ };
316
206
 
317
- if (session.artifact.type === "diff") {
318
- if (action === "down") setCursor((c) => Math.min(rows.length - 1, c + 1));
319
- else if (action === "up") setCursor((c) => Math.max(0, c - 1));
320
- else if (action === "top") setCursor(0);
321
- else if (action === "bottom") setCursor(rows.length - 1);
322
- else if (action === "comment") {
323
- if (resolved) return setStatus("review submitted - read-only");
324
- const row = rows[cursor];
325
- if (!row || row.t === "file" || row.t === "hunk") return setStatus("move to a code line to comment");
326
- liveInput.current = "";
327
- setMode({ m: "compose", kind: "comment", dispIdx: cursor, start: 0, end: row.text.length, text: "" });
328
- } else if (action === "next_annotation" || action === "prev_annotation") {
329
- const anns = session.annotations;
330
- if (!anns.length) return setStatus("no annotations");
331
- const idx = anns.findIndex((a) => a.id === focusedAnn);
332
- const next = idx === -1 ? 0 : (idx + (action === "next_annotation" ? 1 : -1) + anns.length) % anns.length;
333
- setFocusedAnn(anns[next]!.id);
334
- } else if (action === "delete_annotation") {
335
- if (resolved || !focusedAnn) return;
336
- apply(clientRef.current!.sessionRemoveAnnotation(session.id, focusedAnn));
337
- setFocusedAnn(undefined);
338
- } else if (action === "submit") {
339
- if (!resolved) {
340
- liveInput.current = "";
341
- setMode({ m: "submit", verdict: defaultVerdict(session), summary: "" });
342
- }
343
- } else if (action === "span" || action === "cut" || action === "edit" || action === "suggest") {
344
- setStatus("plan-only verb - diff review uses c on a line");
207
+ // ── selection symmetry: one selected id, both sides ──
208
+ const pulse = (id: string): void => {
209
+ setPulseAnn(id);
210
+ if (pulseTimer.current) clearTimeout(pulseTimer.current);
211
+ pulseTimer.current = setTimeout(() => setPulseAnn(null), 2000);
212
+ };
213
+
214
+ const revealRailCard = (annotationId: string): void => {
215
+ const index = session?.annotations.findIndex((annotation) => annotation.id === annotationId) ?? -1;
216
+ if (index >= 0) railScrollRef.current?.scrollTo(Math.max(0, index * RAIL_CARD_HEIGHT - 1));
217
+ };
218
+
219
+ /** Card activation scrolls the document to the anchor and pulses it. */
220
+ const revealAnchor = (annotationId: string): void => {
221
+ for (const [dispIdx, blockMarks] of marks) {
222
+ if (!blockMarks.some((mark) => mark.annotationId === annotationId)) continue;
223
+ setCursor(dispIdx);
224
+ try {
225
+ docScrollRef.current?.scrollChildIntoView(`plan-block-${dispIdx}`);
226
+ } catch {
227
+ // reveal is best-effort; selection state is already correct
345
228
  }
346
229
  return;
347
230
  }
231
+ };
232
+
233
+ const selectCardFromDocument = (annotationId: string): void => {
234
+ setFocusedAnn(annotationId);
235
+ revealRailCard(annotationId);
236
+ };
348
237
 
349
- const vis = display;
350
- if (mode.m === "span") {
351
- if (name === "escape") return setMode({ m: "normal" });
352
- if (["l", "h", "w", "b", "$", "0"].includes(name)) {
353
- return setMode({ m: "span", span: spanKey(mode.span, name, displayText(vis[mode.span.dispIdx]!)) });
238
+ const selectCardFromRail = (annotationId: string): void => {
239
+ setFocusedAnn(annotationId);
240
+ pulse(annotationId);
241
+ revealAnchor(annotationId);
242
+ };
243
+
244
+ const openCardEdit = (annotationId: string): void => {
245
+ if (readOnly) return controller.setStatus("observer - read-only");
246
+ if (resolved) return controller.setStatus("review submitted - read-only");
247
+ const annotation = session?.annotations.find((candidate) => candidate.id === annotationId);
248
+ if (!annotation) return;
249
+ liveInput.current = annotation.body;
250
+ setMode({ m: "railEdit", id: annotation.id, text: annotation.body });
251
+ };
252
+
253
+ // ── keyboard grammar: build state, reduce, dispatch ──
254
+ const dispatch = (intent: Intent): void => {
255
+ switch (intent.t) {
256
+ case "exit":
257
+ return void onExit?.(0);
258
+ case "status":
259
+ return controller.setStatus(intent.msg);
260
+ case "move": {
261
+ const len = isDiff ? rows.length : display.length;
262
+ if (intent.to === "down") setCursor((c) => Math.min(len - 1, c + 1));
263
+ else if (intent.to === "up") setCursor((c) => Math.max(0, c - 1));
264
+ else if (intent.to === "top") setCursor(0);
265
+ else setCursor(len - 1);
266
+ return;
354
267
  }
355
- if (name === "c" || name === "s") {
356
- if (readOnly) return setStatus("observer - read-only");
357
- liveInput.current = "";
358
- return setMode({
359
- m: "compose",
360
- kind: name === "s" ? "suggestion" : "comment",
361
- dispIdx: mode.span.dispIdx,
362
- start: mode.span.start,
363
- end: mode.span.end,
364
- text: "",
365
- });
268
+ case "inboxMove": {
269
+ const len = inbox?.length ?? 0;
270
+ setInboxCursor((c) => (intent.to === "down" ? Math.min(len - 1, c + 1) : Math.max(0, c - 1)));
271
+ return;
366
272
  }
367
- return;
368
- }
369
-
370
- // normal mode
371
- if (action === "down") setCursor((c) => Math.min(vis.length - 1, c + 1));
372
- else if (action === "up") setCursor((c) => Math.max(0, c - 1));
373
- else if (action === "top") setCursor(0);
374
- else if (action === "bottom") setCursor(vis.length - 1);
375
- else if (action === "span") {
376
- const d = vis[cursor];
377
- if (d?.work) {
273
+ case "openSession": {
274
+ const s = inbox?.[inboxCursor];
275
+ if (s) controller.open(s.id);
276
+ return;
277
+ }
278
+ case "startSpan": {
279
+ const d = display[cursor];
280
+ if (!d?.work) return;
378
281
  const span = startSpan(cursor, displayText(d));
379
282
  if (span) setMode({ m: "span", span });
283
+ return;
380
284
  }
381
- } else if (action === "comment" || action === "suggest") {
382
- if (resolved) return setStatus("review submitted - read-only");
383
- const d = vis[cursor];
384
- if (!d?.work) return setStatus("text is cut - restore it first");
385
- liveInput.current = "";
386
- setMode({
387
- m: "compose",
388
- kind: action === "suggest" ? "suggestion" : "comment",
389
- dispIdx: cursor,
390
- start: 0,
391
- end: displayText(d).length,
392
- text: "",
393
- });
394
- } else if (action === "cut") {
395
- if (resolved) return setStatus("review submitted - read-only");
396
- verbCut();
397
- } else if (action === "edit") {
398
- if (resolved) return setStatus("review submitted - read-only");
399
- verbEdit();
400
- } else if (action === "next_annotation" || action === "prev_annotation") {
401
- const anns = session.annotations;
402
- if (!anns.length) return setStatus("no annotations");
403
- const idx = anns.findIndex((a) => a.id === focusedAnn);
404
- const next = idx === -1 ? 0 : (idx + (action === "next_annotation" ? 1 : -1) + anns.length) % anns.length;
405
- setFocusedAnn(anns[next]!.id);
406
- } else if (action === "delete_annotation") {
407
- if (resolved || !focusedAnn) return;
408
- apply(clientRef.current!.sessionRemoveAnnotation(session.id, focusedAnn));
409
- setFocusedAnn(undefined);
410
- setStatus("annotation deleted");
411
- } else if (action === "submit") {
412
- if (!resolved) {
285
+ case "spanKey":
286
+ if (mode.m === "span") {
287
+ const span = spanKey(mode.span, intent.name, displayText(display[mode.span.dispIdx]!));
288
+ setMode({ m: "span", span });
289
+ driveNativeSelection(span);
290
+ }
291
+ return;
292
+ case "openCompose": {
413
293
  liveInput.current = "";
414
- setMode({ m: "submit", verdict: defaultVerdict(session), summary: "" });
294
+ if (intent.from === "span" && mode.m === "span") {
295
+ setMode({
296
+ m: "compose",
297
+ kind: intent.kind,
298
+ dispIdx: mode.span.dispIdx,
299
+ start: mode.span.start,
300
+ end: mode.span.end,
301
+ text: "",
302
+ });
303
+ } else if (isDiff) {
304
+ const row = rows[cursor];
305
+ if (row) setMode({ m: "compose", kind: intent.kind, dispIdx: cursor, start: 0, end: row.text.length, text: "" });
306
+ } else {
307
+ // a mouse drag leaves a native selection; it wins over the cursor block
308
+ const native = readNativeSelection();
309
+ if (native) {
310
+ setMode({ m: "compose", kind: intent.kind, ...native, text: "" });
311
+ } else {
312
+ const d = display[cursor];
313
+ if (d) setMode({ m: "compose", kind: intent.kind, dispIdx: cursor, start: 0, end: displayText(d).length, text: "" });
314
+ }
315
+ }
316
+ return;
415
317
  }
318
+ case "openSubmit":
319
+ if (!session) return;
320
+ liveInput.current = "";
321
+ return void setMode({ m: "submit", verdict: defaultVerdict(session), summary: "" });
322
+ case "cut":
323
+ return controller.cut(cursor);
324
+ case "edit":
325
+ return controller.edit();
326
+ case "editCard":
327
+ if (focusedAnn) openCardEdit(focusedAnn);
328
+ return;
329
+ case "nextAnn":
330
+ case "prevAnn": {
331
+ const anns = session?.annotations ?? [];
332
+ if (!anns.length) return;
333
+ const idx = anns.findIndex((a) => a.id === focusedAnn);
334
+ const next = idx === -1 ? 0 : (idx + (intent.t === "nextAnn" ? 1 : -1) + anns.length) % anns.length;
335
+ return void selectCardFromDocument(anns[next]!.id);
336
+ }
337
+ case "removeAnnotation":
338
+ if (focusedAnn) {
339
+ controller.removeAnnotation(focusedAnn);
340
+ setFocusedAnn(undefined);
341
+ }
342
+ return;
343
+ case "deselect":
344
+ renderer?.clearSelection();
345
+ setFocusedAnn(undefined);
346
+ setPulseAnn(null);
347
+ return;
348
+ case "closeOverlay":
349
+ return void setMode({ m: "normal" });
350
+ case "saveCompose": {
351
+ const body = liveInput.current.trim();
352
+ if (mode.m === "railEdit") {
353
+ if (session && body) controller.updateAnnotation(mode.id, body);
354
+ return void setMode({ m: "normal" });
355
+ }
356
+ if (mode.m !== "compose") return;
357
+ if (session && body) {
358
+ const annotationId = controller.annotate(mode.kind, mode.dispIdx, mode.start, mode.end, body);
359
+ if (annotationId) setFocusedAnn(annotationId);
360
+ }
361
+ return void setMode({ m: "normal" });
362
+ }
363
+ case "submitVerdict":
364
+ if (mode.m === "submit") controller.submit(mode.verdict, liveInput.current);
365
+ return void setMode({ m: "normal" });
366
+ case "cycleVerdict": {
367
+ if (mode.m !== "submit") return;
368
+ const idx = (VERDICTS.indexOf(mode.verdict) + intent.dir + VERDICTS.length) % VERDICTS.length;
369
+ return void setMode({ ...mode, verdict: VERDICTS[idx]! });
370
+ }
371
+ case "finishReview":
372
+ return controller.finishReview();
373
+ case "optInAutoClose":
374
+ return controller.optInAutoClose();
375
+ case "dismissCompletion":
376
+ return controller.dismissCompletion();
416
377
  }
378
+ };
379
+
380
+ useKeyboard((key) => {
381
+ const state: KeyState = {
382
+ keys: keysRef.current,
383
+ readOnly,
384
+ overlay:
385
+ mode.m === "compose" || mode.m === "railEdit"
386
+ ? "compose"
387
+ : mode.m === "submit"
388
+ ? "submit"
389
+ : completion.phase === "prompt"
390
+ ? "completion-prompt"
391
+ : completion.phase === "counting"
392
+ ? "completion-counting"
393
+ : "none",
394
+ view: !session ? "inbox" : isDiff ? "diff" : "plan",
395
+ spanMode: mode.m === "span",
396
+ resolved: !!resolved,
397
+ hasInboxItems: !!inbox?.length,
398
+ annotationCount: session?.annotations.length ?? 0,
399
+ hasFocusedAnnotation: focusedAnn !== undefined,
400
+ cursorAnnotatable: isDiff
401
+ ? rows[cursor] !== undefined && rows[cursor]!.t !== "file" && rows[cursor]!.t !== "hunk"
402
+ : !!display[cursor]?.work,
403
+ };
404
+ for (const intent of reduceKey(state, { name: key.name, shift: !!key.shift })) dispatch(intent);
417
405
  });
418
406
 
419
407
  // ── render ──────────────────────────────────
@@ -435,6 +423,41 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
435
423
  );
436
424
  }
437
425
 
426
+ const composeState =
427
+ mode.m === "compose" && !isDiff
428
+ ? {
429
+ kind: mode.kind,
430
+ dispIdx: mode.dispIdx,
431
+ quote: displayText(display[mode.dispIdx]!).slice(mode.start, mode.end),
432
+ text: mode.text,
433
+ onInput: (text: string) => {
434
+ liveInput.current = text;
435
+ setMode({ ...mode, text });
436
+ },
437
+ onSave: () => dispatch({ t: "saveCompose" }),
438
+ onCancel: () => dispatch({ t: "closeOverlay" }),
439
+ }
440
+ : null;
441
+
442
+ const registerLine = (key: string, renderable: TextRenderable | null, meta: Omit<PlanLineRef, "renderable">): void => {
443
+ if (renderable) lineRefs.current.set(key, { renderable, ...meta });
444
+ else lineRefs.current.delete(key);
445
+ };
446
+
447
+ const onLineActivate = (dispIdx: number): void => {
448
+ // releasing a drag-selection lands here too; a live selection is not a click
449
+ if (renderer?.hasSelection) return;
450
+ setCursor(dispIdx);
451
+ const annotationId = marks.get(dispIdx)?.[0]?.annotationId;
452
+ if (annotationId) selectCardFromDocument(annotationId);
453
+ };
454
+
455
+ const onEditRequest = (): void => {
456
+ if (readOnly) return controller.setStatus("observer - read-only");
457
+ if (resolved) return controller.setStatus("review submitted - read-only");
458
+ controller.edit();
459
+ };
460
+
438
461
  return (
439
462
  <box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: T.bg }}>
440
463
  <box style={{ height: 1, backgroundColor: T.panel, paddingLeft: 1, flexDirection: "row" }}>
@@ -450,12 +473,48 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
450
473
  {isDiff ? (
451
474
  <DiffPane rows={rows} cursor={cursor} annotations={s.annotations} focusedAnn={focusedAnn} />
452
475
  ) : (
453
- <PlanPane display={display} marks={marks} cursor={cursor} mode={mode} />
476
+ <PlanPane
477
+ display={display}
478
+ marks={marks}
479
+ cursor={cursor}
480
+ mode={mode}
481
+ session={s}
482
+ editOrphanCount={editOrphanCount}
483
+ compose={composeState}
484
+ registerLine={registerLine}
485
+ onLineActivate={onLineActivate}
486
+ onEditRequest={onEditRequest}
487
+ docScrollRef={docScrollRef}
488
+ />
454
489
  )}
455
- <Rail session={s} focusedAnn={focusedAnn} pendingCount={pendingCount} />
490
+ <Rail
491
+ session={s}
492
+ selectedId={focusedAnn}
493
+ resolvedIds={isDiff ? null : resolvedIds}
494
+ railTab={railTab}
495
+ pendingCount={pendingCount}
496
+ cardEdit={
497
+ mode.m === "railEdit"
498
+ ? {
499
+ id: mode.id,
500
+ text: mode.text,
501
+ onInput: (text: string) => {
502
+ liveInput.current = text;
503
+ setMode({ m: "railEdit", id: mode.id, text });
504
+ },
505
+ onSave: () => dispatch({ t: "saveCompose" }),
506
+ onCancel: () => dispatch({ t: "closeOverlay" }),
507
+ }
508
+ : null
509
+ }
510
+ onTab={setRailTab}
511
+ onSelectCard={selectCardFromRail}
512
+ onActivateCard={openCardEdit}
513
+ railScrollRef={railScrollRef}
514
+ />
456
515
  </box>
457
- {mode.m === "compose" ? (
458
- <ComposeBar mode={mode} quote={isDiff ? (rows[mode.dispIdx]?.text ?? "") : displayText(display[mode.dispIdx]!).slice(mode.start, mode.end)} onChange={(text) => { liveInput.current = text; setMode({ ...mode, text }); }} />
516
+ {mode.m === "compose" && isDiff ? (
517
+ <ComposeBar mode={mode} quote={rows[mode.dispIdx]?.text ?? ""} onChange={(text) => { liveInput.current = text; setMode({ ...mode, text }); }} />
459
518
  ) : mode.m === "submit" ? (
460
519
  <SubmitBar mode={mode} pendingCount={pendingCount} onChange={(summary) => { liveInput.current = summary; setMode({ ...mode, summary }); }} />
461
520
  ) : (
@@ -465,7 +524,11 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
465
524
  ? "observer - read-only · j/k move · n/p annotations · q quit"
466
525
  : mode.m === "span"
467
526
  ? "span · l/h grow/shrink · w/b slide · $ end · c comment · s suggest · esc"
468
- : "j/k move · v span · c comment · s suggest · x cut · e edit · n/p annotations · ⏎ submit · q quit"}
527
+ : mode.m === "compose" || mode.m === "railEdit"
528
+ ? "typing · ⏎ save · esc cancel"
529
+ : focusedAnn !== undefined
530
+ ? "card · e edit · x Cut · n/p cards · esc deselect · ⏎ submit"
531
+ : "j/k move · v span · drag selects · c comment · s suggest · x cut · e edit · n/p annotations · ⏎ submit · q quit"}
469
532
  </text>
470
533
  </box>
471
534
  )}
@@ -475,44 +538,83 @@ export function App({ home, sessionId, readOnly = false, onExit }: AppProps): Re
475
538
 
476
539
  // ── panes ─────────────────────────────────────
477
540
 
541
+ interface ComposeState {
542
+ kind: "comment" | "suggestion";
543
+ dispIdx: number;
544
+ quote: string;
545
+ text: string;
546
+ onInput: (text: string) => void;
547
+ onSave: () => void;
548
+ onCancel: () => void;
549
+ }
550
+
478
551
  function PlanPane({
479
552
  display,
480
553
  marks,
481
554
  cursor,
482
555
  mode,
556
+ session,
557
+ editOrphanCount,
558
+ compose,
559
+ registerLine,
560
+ onLineActivate,
561
+ onEditRequest,
562
+ docScrollRef,
483
563
  }: {
484
564
  display: DisplayBlock[];
485
- marks: Map<number, { start: number; end: number; role: StyleRun["role"]; annotationId?: string }[]>;
565
+ marks: Map<number, Mark[]>;
486
566
  cursor: number;
487
567
  mode: Mode;
568
+ session: ReviewSession;
569
+ editOrphanCount: number;
570
+ compose: ComposeState | null;
571
+ registerLine: (key: string, renderable: TextRenderable | null, meta: Omit<PlanLineRef, "renderable">) => void;
572
+ onLineActivate: (dispIdx: number) => void;
573
+ onEditRequest: () => void;
574
+ docScrollRef: React.RefObject<ScrollBoxRenderable | null>;
488
575
  }): React.ReactNode {
489
576
  const width = 76;
490
- return (
491
- <scrollbox style={{ flexGrow: 1, paddingLeft: 2, paddingTop: 1 }} focused={false}>
492
- {display.map((d, i) => {
493
- const isCursor = i === cursor;
494
- const gap = topGap(display[i - 1], d);
495
- if (d.kind === "code") {
496
- return (
497
- <CodeBlock
498
- key={i}
499
- block={d}
500
- isCursor={isCursor}
501
- gap={gap}
502
- annotated={(marks.get(i) ?? []).length > 0}
503
- />
504
- );
505
- }
506
- const blockMarks = [...(marks.get(i) ?? [])];
507
- if (mode.m === "span" && mode.span.dispIdx === i) {
508
- blockMarks.push({ start: mode.span.start, end: mode.span.end, role: "kspan" });
509
- }
510
- const runs = overlayMarks(blockRuns(d, true), blockMarks);
511
- const lines = wrapRuns(runs, width - marker(d).length);
512
- return (
513
- <box key={i} style={{ flexDirection: "column", marginTop: gap }}>
514
- {lines.map((line, li) => (
515
- <text key={li} bg={isCursor ? T.cursorBg : undefined}>
577
+ const children: React.ReactNode[] = [];
578
+ for (let i = 0; i < display.length; i++) {
579
+ const d = display[i]!;
580
+ const isCursor = i === cursor;
581
+ const gap = topGap(display[i - 1], d);
582
+ if (d.kind === "code") {
583
+ children.push(
584
+ <CodeBlock key={i} id={`plan-block-${i}`} block={d} isCursor={isCursor} gap={gap} annotated={(marks.get(i) ?? []).length > 0} />,
585
+ );
586
+ } else {
587
+ const blockMarks = [...(marks.get(i) ?? [])];
588
+ if (mode.m === "span" && mode.span.dispIdx === i) {
589
+ blockMarks.push({ start: mode.span.start, end: mode.span.end, role: "kspan" });
590
+ }
591
+ // the compose anchor stays painted selection-style while the box is open
592
+ if (mode.m === "compose" && mode.dispIdx === i) {
593
+ blockMarks.push({ start: mode.start, end: mode.end, role: "kspan" });
594
+ }
595
+ const runs = overlayMarks(blockRuns(d, true), blockMarks);
596
+ const lines = wrapRuns(runs, width - marker(d).length);
597
+ const prefixColumns = 2 + marker(d).length;
598
+ children.push(
599
+ <box key={i} id={`plan-block-${i}`} style={{ flexDirection: "column", marginTop: gap }}>
600
+ {lines.map((line, li) => {
601
+ const positioned = line.filter((run) => run.start !== null);
602
+ const lineStart = positioned.length ? positioned[0]!.start : null;
603
+ const lastPositioned = positioned[positioned.length - 1];
604
+ const textLength =
605
+ lineStart !== null && lastPositioned ? lastPositioned.start! + lastPositioned.text.length - lineStart : 0;
606
+ return (
607
+ <text
608
+ key={li}
609
+ bg={isCursor ? T.cursorBg : undefined}
610
+ selectable
611
+ selectionBg={T.accent}
612
+ selectionFg={T.accentInk}
613
+ ref={(renderable: TextRenderable | null) =>
614
+ registerLine(`${i}:${li}`, renderable, { dispIdx: i, lineIndex: li, lineStart, prefixColumns, textLength })
615
+ }
616
+ onMouseUp={() => onLineActivate(i)}
617
+ >
516
618
  <span fg={isCursor ? T.accent : T.textDim}>{li === 0 ? cursorGlyph(isCursor) : " "}</span>
517
619
  <span fg={T.textDim}>{li === 0 ? marker(d) : " ".repeat(marker(d).length)}</span>
518
620
  {line.map((r, ri) => (
@@ -522,20 +624,70 @@ function PlanPane({
522
624
  ))}
523
625
  {li === 0 && d.type !== "same" ? <span fg={tagColor(d)}> [{tagLabel(d)}]</span> : null}
524
626
  </text>
525
- ))}
526
- </box>
527
- );
528
- })}
529
- </scrollbox>
627
+ );
628
+ })}
629
+ </box>,
630
+ );
631
+ }
632
+ if (compose && compose.dispIdx === i) {
633
+ children.push(
634
+ <AnnotationCard
635
+ key={`compose-${i}`}
636
+ kind={compose.kind}
637
+ quote={compose.quote}
638
+ draft={{ text: compose.text, onInput: compose.onInput, onSave: compose.onSave, onCancel: compose.onCancel }}
639
+ />,
640
+ );
641
+ }
642
+ }
643
+ return (
644
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
645
+ <SheetHeader session={session} onEditRequest={onEditRequest} />
646
+ {editOrphanCount > 0 ? (
647
+ <box style={{ height: 1, backgroundColor: T.markCommentBg, paddingLeft: 2 }}>
648
+ <text fg={T.red}>
649
+ {editOrphanCount} annotation{editOrphanCount === 1 ? "" : "s"} no longer match - the passage was removed.
650
+ </text>
651
+ </box>
652
+ ) : null}
653
+ <scrollbox ref={docScrollRef} style={{ flexGrow: 1, paddingLeft: 2, paddingTop: 1 }} focused={false}>
654
+ {children}
655
+ </scrollbox>
656
+ </box>
657
+ );
658
+ }
659
+
660
+ /** Sheet chrome: submitted-by + revision delta left, the Edit word-button right. */
661
+ function SheetHeader({ session, onEditRequest }: { session: ReviewSession; onEditRequest: () => void }): React.ReactNode {
662
+ const revisionCount = session.revisions.length;
663
+ const previous = revisionCount > 1 ? session.revisions[revisionCount - 2] : undefined;
664
+ const delta = previous ? revisionDelta(previous.content, session.artifact.content) : null;
665
+ return (
666
+ <box style={{ height: 1, flexDirection: "row", paddingLeft: 2, paddingRight: 1 }}>
667
+ <text fg={T.textDim}>
668
+ submitted by <span fg={T.textMuted}>{session.artifact.meta.agent ?? "unknown"}</span> · revision {revisionCount}
669
+ {delta ? (
670
+ <span fg={T.green}>
671
+ {" "}· v{revisionCount - 1}→v{revisionCount} +{delta.added} -{delta.removed}
672
+ </span>
673
+ ) : null}
674
+ </text>
675
+ <box style={{ flexGrow: 1 }} />
676
+ <box onMouseUp={onEditRequest}>
677
+ <text fg={T.textDim}> Edit </text>
678
+ </box>
679
+ </box>
530
680
  );
531
681
  }
532
682
 
533
683
  function CodeBlock({
684
+ id,
534
685
  block,
535
686
  isCursor,
536
687
  gap,
537
688
  annotated,
538
689
  }: {
690
+ id: string;
539
691
  block: DisplayBlock;
540
692
  isCursor: boolean;
541
693
  gap: number;
@@ -556,7 +708,7 @@ function CodeBlock({
556
708
  }, [content, lang]);
557
709
  const lines: CodeToken[][] = tokens ?? content.split("\n").map((l) => [{ content: l }]);
558
710
  return (
559
- <box style={{ flexDirection: "column", marginTop: gap }}>
711
+ <box id={id} style={{ flexDirection: "column", marginTop: gap }}>
560
712
  <text>
561
713
  <span fg={isCursor ? T.accent : T.textDim}>{cursorGlyph(isCursor)}</span>
562
714
  <span fg={T.textDim}>{lang ?? "code"}</span>
@@ -600,47 +752,193 @@ function topGap(prev: DisplayBlock | undefined, cur: DisplayBlock): number {
600
752
  return tightPair ? 0 : 1;
601
753
  }
602
754
 
755
+ // ── the rail ──────────────────────────────────
756
+
757
+ interface CardEditState {
758
+ id: string;
759
+ text: string;
760
+ onInput: (text: string) => void;
761
+ onSave: () => void;
762
+ onCancel: () => void;
763
+ }
764
+
603
765
  function Rail({
604
766
  session,
605
- focusedAnn,
767
+ selectedId,
768
+ resolvedIds,
769
+ railTab,
606
770
  pendingCount,
771
+ cardEdit,
772
+ onTab,
773
+ onSelectCard,
774
+ onActivateCard,
775
+ railScrollRef,
607
776
  }: {
608
777
  session: ReviewSession;
609
- focusedAnn?: string;
778
+ selectedId?: string;
779
+ /** Ids whose anchor resolved; null = orphan display off (diff view). */
780
+ resolvedIds: Set<string> | null;
781
+ railTab: RailTab;
610
782
  pendingCount: number;
783
+ cardEdit: CardEditState | null;
784
+ onTab: (tab: RailTab) => void;
785
+ onSelectCard: (id: string) => void;
786
+ onActivateCard: (id: string) => void;
787
+ railScrollRef: React.RefObject<ScrollBoxRenderable | null>;
611
788
  }): React.ReactNode {
612
789
  return (
613
790
  <box style={{ width: 34, backgroundColor: T.panel, flexDirection: "column", paddingLeft: 1, paddingTop: 1 }}>
614
- <text fg={T.textDim}>REVIEW ({pendingCount})</text>
615
- <text fg={T.textDim}>{session.workingCopy !== undefined ? "± plan edits → one diff" : " no direct edits"}</text>
791
+ <box style={{ height: 1, flexDirection: "row" }}>
792
+ <box onMouseUp={() => onTab("review")}>
793
+ <text fg={railTab === "review" ? T.accent : T.textDim}>Review ({pendingCount})</text>
794
+ </box>
795
+ <text fg={T.textDim}>{" "}</text>
796
+ <box onMouseUp={() => onTab("agent")}>
797
+ <text fg={railTab === "agent" ? T.accent : T.textDim}>Agent</text>
798
+ </box>
799
+ </box>
616
800
  <text> </text>
617
- {session.annotations.length === 0 ? (
618
- <text fg={T.textDim}>no annotations yet</text>
801
+ {railTab === "agent" ? (
802
+ <AgentTab session={session} />
619
803
  ) : (
620
- session.annotations.map((a) => <AnnCard key={a.id} a={a} focused={a.id === focusedAnn} />)
804
+ <>
805
+ {session.workingCopy !== undefined ? <text fg={T.textDim}>± plan edits → one diff</text> : null}
806
+ {session.annotations.length === 0 ? (
807
+ <text fg={T.textDim}>no annotations yet</text>
808
+ ) : (
809
+ <scrollbox ref={railScrollRef} style={{ flexGrow: 1 }} focused={false}>
810
+ {session.annotations.map((annotation) => (
811
+ <AnnotationCard
812
+ key={annotation.id}
813
+ kind={annotation.kind}
814
+ quote={annotation.anchor.quote}
815
+ saved={{
816
+ body: annotation.body,
817
+ selected: annotation.id === selectedId,
818
+ orphan: resolvedIds !== null && !resolvedIds.has(annotation.id),
819
+ blocking: annotationBlocking(annotation),
820
+ editing:
821
+ cardEdit && cardEdit.id === annotation.id
822
+ ? { text: cardEdit.text, onInput: cardEdit.onInput, onSave: cardEdit.onSave, onCancel: cardEdit.onCancel }
823
+ : null,
824
+ onMouseUp: () =>
825
+ annotation.id === selectedId ? onActivateCard(annotation.id) : onSelectCard(annotation.id),
826
+ }}
827
+ />
828
+ ))}
829
+ </scrollbox>
830
+ )}
831
+ <box style={{ flexGrow: 1 }} />
832
+ <text fg={session.status === "resolved" ? T.green : T.accent}>
833
+ {session.status === "resolved" ? `resolved: ${session.verdict!.kind.replace("_", " ")}` : `Submit review (${pendingCount}) ⏎`}
834
+ </text>
835
+ </>
621
836
  )}
622
- <box style={{ flexGrow: 1 }} />
623
- <text fg={session.status === "resolved" ? T.green : T.accent}>
624
- {session.status === "resolved" ? `resolved: ${session.verdict!.kind.replace("_", " ")}` : `Submit review (${pendingCount}) ⏎`}
625
- </text>
626
837
  </box>
627
838
  );
628
839
  }
629
840
 
630
- function AnnCard({ a, focused }: { a: Annotation; focused: boolean }): React.ReactNode {
631
- const color = a.kind === "suggestion" ? T.green : T.accent;
841
+ /** Agent tab: who submitted, where the session stands, which revision. */
842
+ function AgentTab({ session }: { session: ReviewSession }): React.ReactNode {
843
+ return (
844
+ <box style={{ flexDirection: "column", flexGrow: 1 }}>
845
+ <text fg={T.textMuted}>{session.artifact.meta.agent ?? "unknown"}</text>
846
+ <text fg={T.textDim}>status: {session.status}</text>
847
+ <text fg={T.textDim}>revision {session.revisions.length}</text>
848
+ </box>
849
+ );
850
+ }
851
+
852
+ /**
853
+ * The composer and the rail card are one component in two modes: draft props
854
+ * attached (bordered box inline in the document flow) or saved props attached
855
+ * (rail card, optionally editing its body in place). Compose, saved, and
856
+ * re-edit all share this rendering path.
857
+ */
858
+ function AnnotationCard({
859
+ kind,
860
+ quote,
861
+ draft,
862
+ saved,
863
+ }: {
864
+ kind: string;
865
+ quote: string;
866
+ draft?: { text: string; onInput: (text: string) => void; onSave: () => void; onCancel: () => void };
867
+ saved?: {
868
+ body: string;
869
+ selected: boolean;
870
+ orphan: boolean;
871
+ blocking: boolean;
872
+ editing: { text: string; onInput: (text: string) => void; onSave: () => void; onCancel: () => void } | null;
873
+ onMouseUp: () => void;
874
+ };
875
+ }): React.ReactNode {
876
+ const color = kind === "suggestion" ? T.green : T.accent;
877
+ if (draft) {
878
+ const verb = kind === "suggestion" ? "suggest replacement for" : "comment on";
879
+ return (
880
+ <box
881
+ style={{
882
+ height: composeBoxHeight(),
883
+ marginLeft: 2,
884
+ marginRight: 2,
885
+ border: true,
886
+ borderStyle: "rounded",
887
+ borderColor: color,
888
+ backgroundColor: T.elevated,
889
+ flexDirection: "column",
890
+ paddingLeft: 1,
891
+ }}
892
+ title={` ${verb} "${truncate(quote, 40)}" `}
893
+ >
894
+ <input focused value={draft.text} onInput={draft.onInput} placeholder="write a note..." />
895
+ <SaveCancelRow onSave={draft.onSave} onCancel={draft.onCancel} />
896
+ </box>
897
+ );
898
+ }
899
+ const card = saved!;
632
900
  return (
633
- <box style={{ flexDirection: "column", marginBottom: 1 }}>
634
- <text fg={focused ? T.text : color}>
635
- {focused ? "▸ " : " "}
636
- {a.kind.toUpperCase()}
901
+ <box
902
+ style={{ flexDirection: "column", marginBottom: 1, backgroundColor: card.selected ? T.elevated : undefined }}
903
+ onMouseUp={card.onMouseUp}
904
+ >
905
+ <text fg={card.selected ? T.text : color}>
906
+ {card.selected ? "▸ " : " "}
907
+ {kind.toUpperCase()}
908
+ {card.blocking ? <span fg={T.red}> · BLOCKING</span> : null}
909
+ <span fg={T.textDim}>{card.orphan ? " · ORPHANED" : " · pending"}</span>
637
910
  </text>
638
- <text fg={T.textDim}> “{truncate(a.anchor.quote, 26)}”</text>
639
- <text fg={T.textMuted}> {truncate(a.body, 28)}</text>
911
+ <text fg={T.textDim}> "{truncate(quote, 26)}"</text>
912
+ {card.editing ? (
913
+ <>
914
+ <input focused value={card.editing.text} onInput={card.editing.onInput} />
915
+ <SaveCancelRow onSave={card.editing.onSave} onCancel={card.editing.onCancel} />
916
+ </>
917
+ ) : (
918
+ <text fg={card.orphan ? T.textDim : T.textMuted}> {truncate(card.body, 28)}</text>
919
+ )}
920
+ </box>
921
+ );
922
+ }
923
+
924
+ function SaveCancelRow({ onSave, onCancel }: { onSave: () => void; onCancel: () => void }): React.ReactNode {
925
+ return (
926
+ <box style={{ flexDirection: "row", height: 1 }}>
927
+ <box style={{ backgroundColor: T.accent, marginRight: 2 }} onMouseUp={onSave}>
928
+ <text fg={T.accentInk}> Save ⏎ </text>
929
+ </box>
930
+ <box onMouseUp={onCancel}>
931
+ <text fg={T.textDim}> Cancel esc </text>
932
+ </box>
640
933
  </box>
641
934
  );
642
935
  }
643
936
 
937
+ /** Forward-compatible: open annotation kinds may carry a blocking flag. */
938
+ function annotationBlocking(annotation: Annotation): boolean {
939
+ return (annotation as Annotation & { blocking?: boolean }).blocking === true;
940
+ }
941
+
644
942
  function Inbox({ inbox, cursor }: { inbox: ReviewSession[]; cursor: number }): React.ReactNode {
645
943
  return (
646
944
  <box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: T.bg, padding: 1 }}>
@@ -662,6 +960,7 @@ function Inbox({ inbox, cursor }: { inbox: ReviewSession[]; cursor: number }): R
662
960
  );
663
961
  }
664
962
 
963
+ /** Bottom compose bar - the diff view still composes here. */
665
964
  function ComposeBar({
666
965
  mode,
667
966
  quote,