@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/package.json +5 -5
- package/src/App.plan-review.test.tsx +259 -0
- package/src/App.test.tsx +2 -2
- package/src/App.tsx +691 -392
- package/src/keymap.test.ts +270 -0
- package/src/keymap.ts +173 -0
- package/src/run.ts +3 -1
- package/src/session-controller.ts +398 -0
- package/src/view.ts +57 -5
package/src/App.tsx
CHANGED
|
@@ -1,44 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The cueloop TUI (#22):
|
|
3
|
-
*
|
|
4
|
-
* bar. Thin renderer over
|
|
5
|
-
*
|
|
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, {
|
|
9
|
-
import { useKeyboard } from "@opentui/react";
|
|
10
|
-
import {
|
|
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
|
-
|
|
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 {
|
|
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,
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
41
|
-
import {
|
|
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
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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 [
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
}
|
|
113
|
-
|
|
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
|
|
156
|
-
const
|
|
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,
|
|
162
|
-
[session, display,
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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 (
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if (
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
return
|
|
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
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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={
|
|
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
|
-
:
|
|
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,
|
|
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
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
615
|
-
|
|
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
|
-
{
|
|
618
|
-
<
|
|
801
|
+
{railTab === "agent" ? (
|
|
802
|
+
<AgentTab session={session} />
|
|
619
803
|
) : (
|
|
620
|
-
|
|
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
|
-
|
|
631
|
-
|
|
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
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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}>
|
|
639
|
-
|
|
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,
|