@sealant/mend 0.1.0 → 0.1.1

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/dist/review.js ADDED
@@ -0,0 +1,559 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
2
+ import { createHash } from "node:crypto";
3
+ import { RGBA, SyntaxStyle, } from "@opentui/core";
4
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react";
5
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
6
+ import { useEffect, useMemo, useRef, useState } from "react";
7
+ import { assembleReviewInstruction, buildReviewFiles, visibleWhitespace, } from "./review-model.js";
8
+ import { commentRange, deliverReview } from "./review-workflow.js";
9
+ import { LIVE_STATUSES } from "./shared.js";
10
+ import { openUrl } from "./terminal.js";
11
+ import { ADD_WASH, AMBER, BG, COBALT, DELETE_WASH, FAINT, GREEN, INK, INK_2, MUTED, PANEL, RED, RULE, SYNTAX_FUNCTION, SYNTAX_KEYWORD, SYNTAX_NUMBER, SYNTAX_STRING, WASH, } from "./tui-theme.js";
12
+ const REVIEW_KEY = (changeId) => ["review", changeId];
13
+ const EDITOR_KEY_BINDINGS = [{ name: "return", ctrl: true, action: "submit" }];
14
+ const syntaxStyle = SyntaxStyle.fromStyles({
15
+ keyword: { fg: RGBA.fromHex(SYNTAX_KEYWORD) },
16
+ string: { fg: RGBA.fromHex(SYNTAX_STRING) },
17
+ comment: { fg: RGBA.fromHex(FAINT), italic: true },
18
+ number: { fg: RGBA.fromHex(SYNTAX_NUMBER) },
19
+ function: { fg: RGBA.fromHex(SYNTAX_FUNCTION) },
20
+ variable: { fg: RGBA.fromHex(INK) },
21
+ default: { fg: RGBA.fromHex(INK) },
22
+ });
23
+ const truncate = (text, width) => {
24
+ const oneLine = text.replaceAll(/\s+/g, " ").trim();
25
+ if (width <= 1)
26
+ return "";
27
+ return oneLine.length <= width ? oneLine : `${oneLine.slice(0, width - 1)}…`;
28
+ };
29
+ const fetchReview = async (ctx, changeId) => {
30
+ const wire = await ctx.api("GET", `/changes/${changeId}/diff`);
31
+ const [comments, tour, passes, followUp, sessionDetail] = await Promise.all([
32
+ ctx.api("GET", `/changes/${changeId}/comments`),
33
+ ctx.api("GET", `/changes/${changeId}/tour`),
34
+ ctx.api("GET", `/changes/${changeId}/passes`),
35
+ ctx.api("GET", `/sessions/${wire.change.sessionId}/follow-up`),
36
+ ctx.api("GET", `/sessions/${wire.change.sessionId}`),
37
+ ]);
38
+ return {
39
+ wire,
40
+ files: buildReviewFiles(wire.diff, wire.files),
41
+ comments,
42
+ tour,
43
+ passes,
44
+ followUp,
45
+ sessionStatus: sessionDetail.session.status,
46
+ };
47
+ };
48
+ const commentPriority = (comment) => comment.state === "draft" ? 0 : comment.state === "open" ? 1 : 2;
49
+ const orderedComments = (comments) => comments.toSorted((a, b) => {
50
+ const byState = commentPriority(a) - commentPriority(b);
51
+ return byState === 0 ? b.createdAt.localeCompare(a.createdAt) : byState;
52
+ });
53
+ const stateColor = (state) => state === "draft" || state === "open" ? AMBER : state === "addressed" ? INK_2 : FAINT;
54
+ const stateGlyph = (state) => (state === "open" ? "●" : "○");
55
+ const anchorOf = (comment) => comment.file === null
56
+ ? "change"
57
+ : `${comment.file}${comment.line === null ? "" : `:${comment.line}${comment.endLine === null || comment.endLine === comment.line ? "" : `-${comment.endLine}`}`}`;
58
+ const passFact = (pass) => {
59
+ const label = pass.kind === "suggest" ? "suggestions" : pass.kind;
60
+ if (pass.status === "running")
61
+ return { text: `● ${label} running`, color: COBALT };
62
+ if (pass.status === "failed")
63
+ return { text: `● ${label} failed`, color: RED };
64
+ return {
65
+ text: `● ${label} observed · ${pass.findings ?? 0} ${pass.kind === "tour" ? "stops" : "drafts"}`,
66
+ color: GREEN,
67
+ };
68
+ };
69
+ const FileRow = ({ file, selected }) => (_jsxs("box", { height: 2, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: selected ? COBALT : FAINT, children: selected ? "▌ " : " " }), _jsx("span", { fg: INK, children: file.path }), file.binary ? _jsx("span", { fg: AMBER, children: " \u00B7 binary" }) : null, file.likelyGenerated ? _jsx("span", { fg: FAINT, children: " \u00B7 inferred \u00B7 likely generated" }) : null] }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { children: " " }), _jsxs("span", { fg: GREEN, children: ["+", file.additions] }), _jsx("span", { fg: FAINT, children: " " }), _jsxs("span", { fg: RED, children: ["\u2212", file.deletions] }), _jsxs("span", { fg: FAINT, children: [" ", "\u00B7 ", file.status, " \u00B7 ", file.hunks.length, " hunks"] })] })] }));
70
+ const CommentRow = ({ comment, selected, }) => (_jsxs("box", { height: 2, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: selected ? COBALT : FAINT, children: selected ? "▌ " : " " }), _jsxs("span", { fg: stateColor(comment.state), children: [stateGlyph(comment.state), " ", comment.state] }), _jsxs("span", { fg: FAINT, children: [" \u00B7 ", anchorOf(comment)] })] }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { children: " " }), _jsx("span", { fg: comment.authorKind === "mend" ? COBALT : MUTED, children: comment.authorKind === "mend" ? "Mend · " : "You · " }), _jsx("span", { fg: INK, children: truncate(comment.body, 27) })] })] }));
71
+ const Description = ({ data, tourIndex, stale, width, height, }) => {
72
+ const passFacts = data.passes.map(passFact);
73
+ const tourStop = data.tour?.stops[tourIndex] ?? null;
74
+ const stopEvidence = tourStop?.evidence[0] ?? null;
75
+ return (_jsxs("box", { height: height, border: true, borderStyle: "rounded", borderColor: stale ? AMBER : data.tour === null ? RULE : COBALT, title: " change description ", titleAlignment: "left", backgroundColor: PANEL, flexShrink: 0, flexDirection: "column", paddingX: 1, children: [_jsx("text", { height: 1, bg: "transparent", children: data.tour === null ? (_jsx("span", { fg: INK, children: "No description yet \u2014 t composes one from the diff and session record." })) : (_jsxs(_Fragment, { children: [_jsx("span", { fg: COBALT, children: "Mend uses inference \u00B7 " }), stale ? _jsx("span", { fg: AMBER, children: "diff changed since \u00B7 " }) : null, _jsx("span", { fg: INK, children: truncate(data.tour.summary, Math.max(20, width - 50)) })] })) }), height >= 5 && data.tour !== null ? (_jsx("text", { height: 1, bg: "transparent", children: tourStop === null ? (_jsxs("span", { fg: FAINT, children: ["inferred approach \u00B7 ", truncate(data.tour.approach ?? "No tour stops yet.", width - 24)] })) : (_jsxs(_Fragment, { children: [_jsxs("span", { fg: COBALT, children: ["stop ", tourIndex + 1, "/", data.tour.stops.length, " \u00B7 ", tourStop.title] }), _jsxs("span", { fg: FAINT, children: [tourStop.file === null
76
+ ? " · change"
77
+ : ` · ${tourStop.file}${tourStop.line === null ? "" : `:${tourStop.line}`}`, tourStop.grounded ? " · direct record" : " · inferred reading"] })] })) })) : null, height >= 6 && tourStop !== null ? (_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: COBALT, children: tourStop.grounded ? "record-grounded narration · " : "inferred narration · " }), _jsx("span", { fg: MUTED, children: truncate(tourStop.narration, Math.max(20, width - 24)) })] })) : null, height >= 7 && tourStop !== null ? (_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: stopEvidence === null ? AMBER : COBALT, children: stopEvidence === null
78
+ ? "no direct evidence · "
79
+ : `evidence seq ${stopEvidence.sequence} · ` }), _jsx("span", { fg: MUTED, children: truncate(stopEvidence?.excerpt ?? "this stop is labeled inferred", Math.max(20, width - 26)) })] })) : null, _jsxs("text", { height: 1, bg: "transparent", children: [passFacts.length === 0 ? (_jsx("span", { fg: FAINT, children: "Mend has not read this change." })) : (passFacts.map((fact, index) => (_jsxs("span", { children: [index > 0 ? _jsx("span", { fg: FAINT, children: " \u00B7 " }) : null, _jsx("span", { fg: fact.color, children: fact.text })] }, `${fact.text}-${index}`)))), data.followUp?.status === "pending" ? _jsx("span", { fg: AMBER, children: " \u00B7 follow-up pending" }) : null] })] }));
80
+ };
81
+ const CompactTourEvidence = ({ stop, index, total, width, }) => {
82
+ const evidence = stop.evidence[0] ?? null;
83
+ return (_jsxs("box", { height: 5, border: true, borderStyle: "rounded", borderColor: stop.grounded ? COBALT : RULE, title: ` tour stop ${index + 1}/${total} · ${stop.grounded ? "direct record" : "inferred reading"} `, titleAlignment: "left", backgroundColor: PANEL, flexShrink: 0, flexDirection: "column", paddingX: 1, children: [_jsx("text", { height: 1, fg: INK, bg: "transparent", children: truncate(stop.narration, Math.max(12, width - 6)) }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: evidence === null ? AMBER : COBALT, children: evidence === null ? "no direct record link · " : `seq ${evidence.sequence} · ` }), _jsx("span", { fg: MUTED, children: truncate(evidence?.excerpt ?? "the narration is an inferred reading", Math.max(12, width - 26)) })] }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: ", previous stop \u00B7 . next stop" })] }));
84
+ };
85
+ const EvidenceCard = ({ comment, width, }) => (_jsxs("box", { height: Math.min(6, 4 + Math.min(2, comment.evidence.length)), border: true, borderStyle: "rounded", borderColor: comment.state === "draft" ? AMBER : RULE, title: ` ${anchorOf(comment)} · ${comment.authorKind === "mend" ? "Mend" : "You"} `, titleAlignment: "left", backgroundColor: PANEL, flexShrink: 0, flexDirection: "column", paddingX: 1, children: [_jsx("text", { height: 1, fg: INK, bg: "transparent", children: truncate(comment.body, Math.max(12, width - 6)) }), comment.suggestion === null ? null : (_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: COBALT, children: "proposed \u00B7 " }), _jsx("span", { fg: MUTED, children: truncate(comment.suggestion, Math.max(12, width - 17)) })] })), comment.evidence.slice(0, 2).map((evidence) => (_jsxs("text", { height: 1, bg: "transparent", children: [_jsxs("span", { fg: FAINT, children: ["seq ", evidence.sequence, " \u00B7 "] }), _jsx("span", { fg: MUTED, children: truncate(evidence.excerpt, Math.max(12, width - 18)) })] }, `${evidence.sequence}-${evidence.excerpt}`)))] }));
86
+ const EditorPanel = ({ editor, refValue, busy, onSubmit, }) => {
87
+ const title = editor.kind === "send"
88
+ ? " send review to session · edit before sending "
89
+ : ` comment · ${editor.file === null ? "change" : `${editor.file}:${editor.line ?? ""}`} `;
90
+ return (_jsxs("box", { border: true, borderStyle: "rounded", borderColor: COBALT, title: title, titleAlignment: "left", backgroundColor: BG, height: editor.kind === "send" ? 16 : 8, flexShrink: 0, flexDirection: "column", children: [_jsx("textarea", { ref: refValue, focused: true, initialValue: editor.kind === "send" ? editor.instruction : "", placeholder: editor.kind === "send" ? "Review instruction…" : "What should change, and why?", backgroundColor: BG, focusedBackgroundColor: BG, textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, keyBindings: [...EDITOR_KEY_BINDINGS], flexGrow: 1, onSubmit: onSubmit }), _jsx("text", { height: 1, bg: "transparent", children: _jsxs("span", { fg: FAINT, children: [" ctrl+enter ", busy ? "working…" : "save", " \u00B7 esc cancel"] }) })] }));
91
+ };
92
+ const errorMessage = (error) => error instanceof Error ? error.message : error === null ? "" : String(error);
93
+ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQuit, }) {
94
+ const queryClient = useQueryClient();
95
+ const { width, height } = useTerminalDimensions();
96
+ const wide = width >= 100;
97
+ const descriptionHeight = height >= 30 ? 7 : 5;
98
+ const { data, failureReason, isFetching } = useQuery({
99
+ queryKey: REVIEW_KEY(changeId),
100
+ queryFn: () => fetchReview(ctx, changeId),
101
+ retry: true,
102
+ retryDelay: 1000,
103
+ });
104
+ const [focus, setFocus] = useState("diff");
105
+ const [fileIndex, setFileIndex] = useState(0);
106
+ const [anchorIndex, setAnchorIndex] = useState(0);
107
+ const [commentIndex, setCommentIndex] = useState(0);
108
+ const [tourIndex, setTourIndex] = useState(0);
109
+ const [view, setView] = useState("unified");
110
+ const [wrap, setWrap] = useState(false);
111
+ const [showWhitespace, setShowWhitespace] = useState(false);
112
+ const [rangeStart, setRangeStart] = useState(null);
113
+ const [editor, setEditor] = useState(null);
114
+ const [busy, setBusy] = useState(false);
115
+ const [status, setStatus] = useState("");
116
+ const [checkpointState, setCheckpointState] = useState("recording");
117
+ const lockRef = useRef(false);
118
+ const editorRef = useRef(null);
119
+ const diffRef = useRef(null);
120
+ const diffScrollRef = useRef(null);
121
+ const fileScrollRef = useRef(null);
122
+ const commentScrollRef = useRef(null);
123
+ const checkpointedRef = useRef(false);
124
+ const files = useMemo(() => {
125
+ const order = {
126
+ added: 0,
127
+ modified: 1,
128
+ renamed: 2,
129
+ deleted: 3,
130
+ };
131
+ return (data?.files ?? []).toSorted((left, right) => order[left.status] - order[right.status] || left.path.localeCompare(right.path));
132
+ }, [data?.files]);
133
+ const selectedFile = files[Math.min(fileIndex, Math.max(0, files.length - 1))] ?? null;
134
+ const anchors = useMemo(() => selectedFile?.lines.filter((line) => line.commentable) ?? [], [selectedFile]);
135
+ const selectedAnchor = anchors[Math.min(anchorIndex, Math.max(0, anchors.length - 1))] ?? null;
136
+ const comments = useMemo(() => orderedComments(data?.comments ?? []), [data?.comments]);
137
+ const selectedComment = comments[Math.min(commentIndex, Math.max(0, comments.length - 1))] ?? null;
138
+ const lineComment = selectedFile === null || selectedAnchor === null
139
+ ? null
140
+ : (comments.find((comment) => comment.file === selectedFile.path &&
141
+ comment.line !== null &&
142
+ selectedAnchor.newLine !== null &&
143
+ comment.line <= selectedAnchor.newLine &&
144
+ (comment.endLine ?? comment.line) >= selectedAnchor.newLine) ?? null);
145
+ const detailComment = focus === "comments" ? selectedComment : lineComment;
146
+ const tourStops = data?.tour?.stops ?? [];
147
+ const activeTourIndex = Math.min(tourIndex, Math.max(0, tourStops.length - 1));
148
+ const activeTourStop = tourStops[activeTourIndex] ?? null;
149
+ const tourStale = useMemo(() => data?.tour === null || data?.tour === undefined
150
+ ? false
151
+ : createHash("sha256").update(data.wire.diff).digest("hex") !== data.tour.diffDigest, [data?.tour, data?.wire.diff]);
152
+ const refresh = () => {
153
+ void queryClient.invalidateQueries({ queryKey: REVIEW_KEY(changeId) });
154
+ };
155
+ const recordReviewCheckpoint = () => {
156
+ if (checkpointedRef.current)
157
+ return;
158
+ checkpointedRef.current = true;
159
+ setCheckpointState("recording");
160
+ void ctx
161
+ .api("POST", `/sessions/${session.id}/checkpoints`, { trigger: "review-open" })
162
+ .then(() => setCheckpointState("recorded"))
163
+ .catch((error) => {
164
+ checkpointedRef.current = false;
165
+ setCheckpointState("failed");
166
+ setStatus(`Checkpoint not recorded · ${errorMessage(error)} · K retries`);
167
+ });
168
+ };
169
+ useEffect(recordReviewCheckpoint, [ctx, session.id]);
170
+ useEffect(() => {
171
+ fileScrollRef.current?.scrollTo(Math.max(0, fileIndex * 2 - 2));
172
+ }, [fileIndex]);
173
+ useEffect(() => {
174
+ commentScrollRef.current?.scrollTo(Math.max(0, commentIndex * 2 - 2));
175
+ }, [commentIndex]);
176
+ useEffect(() => {
177
+ if (selectedAnchor === null)
178
+ return;
179
+ const row = view === "unified" ? selectedAnchor.unifiedRow : selectedAnchor.splitRow;
180
+ diffScrollRef.current?.scrollTo(Math.max(0, row - 3));
181
+ }, [selectedAnchor, view]);
182
+ useEffect(() => {
183
+ // Split can align a deletion and addition on one row; preserve their
184
+ // semantic colors there. Unified has one fact per row, so cobalt can mark
185
+ // the active comment anchor without lying about either side.
186
+ if (view !== "unified" || selectedAnchor === null || diffRef.current === null)
187
+ return;
188
+ const start = rangeStart === null ? selectedAnchor.newLine : rangeStart;
189
+ const selectedLine = selectedAnchor.newLine;
190
+ const highlighted = start === null || selectedLine === null
191
+ ? [selectedAnchor]
192
+ : anchors.filter((anchor) => anchor.newLine !== null &&
193
+ anchor.newLine >= Math.min(start, selectedLine) &&
194
+ anchor.newLine <= Math.max(start, selectedLine));
195
+ for (const anchor of highlighted) {
196
+ diffRef.current.setLineColor(anchor.unifiedRow, { gutter: COBALT, content: WASH });
197
+ }
198
+ return () => {
199
+ if (diffRef.current === null)
200
+ return;
201
+ for (const anchor of highlighted) {
202
+ const content = anchor.kind === "addition"
203
+ ? ADD_WASH
204
+ : anchor.kind === "deletion"
205
+ ? DELETE_WASH
206
+ : "transparent";
207
+ diffRef.current.setLineColor(anchor.unifiedRow, {
208
+ gutter: "transparent",
209
+ content,
210
+ });
211
+ }
212
+ };
213
+ }, [anchors, rangeStart, selectedAnchor, view]);
214
+ const moveFile = (delta) => {
215
+ if (files.length === 0)
216
+ return;
217
+ setFileIndex((current) => {
218
+ const next = Math.max(0, Math.min(files.length - 1, current + delta));
219
+ if (next !== current) {
220
+ setAnchorIndex(0);
221
+ setRangeStart(null);
222
+ }
223
+ return next;
224
+ });
225
+ };
226
+ const moveAnchor = (delta) => {
227
+ if (anchors.length === 0)
228
+ return;
229
+ setAnchorIndex((current) => Math.max(0, Math.min(anchors.length - 1, current + delta)));
230
+ };
231
+ const navigateToAnchor = (file, line) => {
232
+ if (file === null)
233
+ return;
234
+ const nextFileIndex = files.findIndex((candidate) => candidate.path === file);
235
+ if (nextFileIndex < 0)
236
+ return;
237
+ const nextFile = files[nextFileIndex];
238
+ const nextAnchorIndex = line === null
239
+ ? 0
240
+ : (nextFile?.lines.findIndex((candidate) => candidate.newLine !== null && candidate.newLine >= line) ?? -1);
241
+ setFileIndex(nextFileIndex);
242
+ setAnchorIndex(Math.max(0, nextAnchorIndex));
243
+ setRangeStart(null);
244
+ };
245
+ const selectComment = (index) => {
246
+ const next = comments[index];
247
+ if (next === undefined)
248
+ return;
249
+ setCommentIndex(index);
250
+ navigateToAnchor(next.file, next.line);
251
+ };
252
+ const moveComment = (delta) => {
253
+ if (comments.length === 0)
254
+ return;
255
+ const current = Math.min(commentIndex, comments.length - 1);
256
+ selectComment(Math.max(0, Math.min(comments.length - 1, current + delta)));
257
+ };
258
+ const moveTour = (delta) => {
259
+ if (tourStops.length === 0)
260
+ return;
261
+ const current = Math.min(tourIndex, tourStops.length - 1);
262
+ const nextIndex = Math.max(0, Math.min(tourStops.length - 1, current + delta));
263
+ const stop = tourStops[nextIndex];
264
+ setTourIndex(nextIndex);
265
+ if (stop !== undefined)
266
+ navigateToAnchor(stop.file, stop.line);
267
+ };
268
+ const moveHunk = (delta) => {
269
+ if (selectedFile === null || selectedAnchor === null)
270
+ return;
271
+ const target = Math.max(0, Math.min(selectedFile.hunks.length - 1, selectedAnchor.hunk + delta));
272
+ const next = anchors.findIndex((anchor) => anchor.hunk === target);
273
+ if (next >= 0)
274
+ setAnchorIndex(next);
275
+ };
276
+ const setWorking = (value) => {
277
+ lockRef.current = value;
278
+ setBusy(value);
279
+ };
280
+ const runAction = async (label, action, completed = `${label} · requested`) => {
281
+ setWorking(true);
282
+ setStatus(`${label}…`);
283
+ try {
284
+ await action();
285
+ setStatus(completed);
286
+ refresh();
287
+ }
288
+ catch (error) {
289
+ setStatus(errorMessage(error));
290
+ }
291
+ finally {
292
+ setWorking(false);
293
+ }
294
+ };
295
+ const openCommentEditor = (wholeChange) => {
296
+ if (wholeChange) {
297
+ setEditor({ kind: "comment", file: null, line: null, endLine: null });
298
+ return;
299
+ }
300
+ if (selectedFile?.status === "deleted" && selectedAnchor === null) {
301
+ setStatus("Deleted-side anchors are not persisted · use C for a change comment");
302
+ return;
303
+ }
304
+ if (selectedFile === null || selectedAnchor?.newLine === null || selectedAnchor === null) {
305
+ setStatus("No new-side line selected");
306
+ return;
307
+ }
308
+ const range = commentRange(rangeStart, selectedAnchor.newLine);
309
+ setEditor({
310
+ kind: "comment",
311
+ file: selectedFile.path,
312
+ line: range.line,
313
+ endLine: range.endLine === range.line ? null : range.endLine,
314
+ });
315
+ setRangeStart(null);
316
+ };
317
+ const openSendEditor = () => {
318
+ if (data === undefined)
319
+ return;
320
+ const sendable = data.comments.filter((comment) => comment.state === "open" && comment.sentToSessionId === null);
321
+ if (sendable.length === 0) {
322
+ setStatus("No unsent open comments — accept a draft or write a comment first");
323
+ return;
324
+ }
325
+ setEditor({
326
+ kind: "send",
327
+ instruction: assembleReviewInstruction(data.wire.change, sendable),
328
+ });
329
+ };
330
+ const submitEditor = async () => {
331
+ if (editor === null || lockRef.current)
332
+ return;
333
+ const value = editorRef.current?.plainText.trim() ?? "";
334
+ if (value === "") {
335
+ setStatus("Write something before saving");
336
+ return;
337
+ }
338
+ setWorking(true);
339
+ try {
340
+ if (editor.kind === "comment") {
341
+ await ctx.api("POST", `/changes/${changeId}/comments`, {
342
+ file: editor.file,
343
+ line: editor.line,
344
+ endLine: editor.endLine,
345
+ body: value,
346
+ });
347
+ setStatus(editor.file === null ? "Change comment saved" : "Inline comment saved");
348
+ }
349
+ else {
350
+ await ctx.api("POST", `/sessions/${session.id}/follow-up`, { instruction: value });
351
+ setStatus("Follow-up saved · y delivers it and relaunches the same session");
352
+ }
353
+ setEditor(null);
354
+ refresh();
355
+ }
356
+ catch (error) {
357
+ setStatus(errorMessage(error));
358
+ }
359
+ finally {
360
+ setWorking(false);
361
+ }
362
+ };
363
+ const updateComment = (state) => {
364
+ if (selectedComment === null)
365
+ return;
366
+ void runAction(`${state} ${anchorOf(selectedComment)}`, () => ctx.api("POST", `/changes/${changeId}/comments/${selectedComment.id}/state`, { state }));
367
+ };
368
+ const deliverAndRelaunch = () => {
369
+ if (data?.followUp?.status !== "pending") {
370
+ setStatus("No pending follow-up — s assembles the open review comments first");
371
+ return;
372
+ }
373
+ if (LIVE_STATUSES.has(data.sessionStatus)) {
374
+ setStatus("The session is live — deliver the pending follow-up once it settles");
375
+ return;
376
+ }
377
+ const instruction = data.followUp.instruction;
378
+ void runAction("Delivering review and relaunching the same session", () => deliverReview(ctx.api, session, instruction), "Review delivered · the same session is running · diff updates live");
379
+ };
380
+ const focusOrder = wide
381
+ ? ["files", "diff", "comments"]
382
+ : ["diff", "comments"];
383
+ const cycleFocus = (backwards) => {
384
+ const index = focusOrder.indexOf(focus);
385
+ const direction = backwards ? -1 : 1;
386
+ const next = (index + direction + focusOrder.length) % focusOrder.length;
387
+ const nextFocus = focusOrder[next] ?? "diff";
388
+ if (nextFocus === "comments" && selectedComment !== null) {
389
+ navigateToAnchor(selectedComment.file, selectedComment.line);
390
+ }
391
+ setFocus(nextFocus);
392
+ };
393
+ useKeyboard((key) => {
394
+ if (key.ctrl && key.name === "c")
395
+ return onQuit();
396
+ if (editor !== null) {
397
+ if (key.name === "escape" && !lockRef.current)
398
+ setEditor(null);
399
+ return;
400
+ }
401
+ if (lockRef.current)
402
+ return;
403
+ if (key.name === "q")
404
+ return onQuit();
405
+ if (key.name === "escape" || key.name === "backspace" || key.name === "h")
406
+ return onBack();
407
+ if (key.name === "tab")
408
+ return cycleFocus(key.shift);
409
+ if (key.name === "r") {
410
+ setStatus("Refreshing live change…");
411
+ return refresh();
412
+ }
413
+ if (key.name === "k" && key.shift) {
414
+ if (checkpointState !== "failed")
415
+ return;
416
+ recordReviewCheckpoint();
417
+ return;
418
+ }
419
+ if (key.name === "o") {
420
+ openUrl(`${ctx.config.url}/changes/${changeId}`);
421
+ setStatus("Opened web review");
422
+ return;
423
+ }
424
+ if (key.name === "c" && key.shift)
425
+ return openCommentEditor(true);
426
+ if (key.name === "s")
427
+ return openSendEditor();
428
+ if (key.name === "y")
429
+ return deliverAndRelaunch();
430
+ if (key.name === ",")
431
+ return moveTour(-1);
432
+ if (key.name === ".")
433
+ return moveTour(1);
434
+ if (key.name === "t") {
435
+ return void runAction(data?.tour === null ? "Compose description and tour" : "Recompose tour", () => ctx.api("POST", `/changes/${changeId}/tour`, {}));
436
+ }
437
+ if (key.name === "m") {
438
+ return void runAction("Mend reads the change", () => ctx.api("POST", `/changes/${changeId}/read`, {}));
439
+ }
440
+ if (key.name === "g") {
441
+ return void runAction("Draft concrete suggestions", () => ctx.api("POST", `/changes/${changeId}/suggest`, {}));
442
+ }
443
+ if (focus === "files") {
444
+ if (key.name === "j" || key.name === "down")
445
+ return moveFile(1);
446
+ if (key.name === "k" || key.name === "up")
447
+ return moveFile(-1);
448
+ if (key.name === "return" || key.name === "linefeed" || key.name === "l") {
449
+ return setFocus("diff");
450
+ }
451
+ return;
452
+ }
453
+ if (focus === "comments") {
454
+ if (key.name === "j" || key.name === "down")
455
+ return moveComment(1);
456
+ if (key.name === "k" || key.name === "up")
457
+ return moveComment(-1);
458
+ if (key.name === "a" && selectedComment?.state === "draft")
459
+ return updateComment("open");
460
+ if (key.name === "a" && selectedComment?.state === "open") {
461
+ return updateComment("addressed");
462
+ }
463
+ if (key.name === "x")
464
+ return updateComment("dismissed");
465
+ if (key.name === "u" && selectedComment !== null)
466
+ return updateComment("open");
467
+ if (key.name === "return" || key.name === "linefeed" || key.name === "l") {
468
+ if (selectedComment !== null)
469
+ navigateToAnchor(selectedComment.file, selectedComment.line);
470
+ return setFocus("diff");
471
+ }
472
+ return;
473
+ }
474
+ if (key.name === "j" || key.name === "down")
475
+ return moveAnchor(1);
476
+ if (key.name === "k" || key.name === "up")
477
+ return moveAnchor(-1);
478
+ if (key.name === "n")
479
+ return moveFile(1);
480
+ if (key.name === "p")
481
+ return moveFile(-1);
482
+ if (key.name === "]")
483
+ return moveHunk(1);
484
+ if (key.name === "[")
485
+ return moveHunk(-1);
486
+ if (key.name === "c")
487
+ return openCommentEditor(false);
488
+ if (key.name === "v") {
489
+ if (selectedAnchor?.newLine === null || selectedAnchor === null) {
490
+ setStatus("Select a new-side line before starting a range");
491
+ return;
492
+ }
493
+ setRangeStart((current) => (current === null ? selectedAnchor.newLine : null));
494
+ setStatus(rangeStart === null ? `Range starts at line ${selectedAnchor.newLine}` : "Range cleared");
495
+ return;
496
+ }
497
+ if (key.name === "d" && wide)
498
+ return setView((current) => (current === "unified" ? "split" : "unified"));
499
+ if (key.name === "w")
500
+ return setWrap((current) => !current);
501
+ if (key.name === "z")
502
+ return setShowWhitespace((current) => !current);
503
+ });
504
+ if (data === undefined) {
505
+ return (_jsx("box", { flexGrow: 1, backgroundColor: BG, alignItems: "center", justifyContent: "center", children: _jsx("text", { bg: "transparent", children: _jsx("span", { fg: failureReason === null ? COBALT : AMBER, children: failureReason === null
506
+ ? "Loading the live change…"
507
+ : `${errorMessage(failureReason)} · retrying` }) }) }));
508
+ }
509
+ const additions = data.wire.files.reduce((sum, file) => sum + file.additions, 0);
510
+ const deletions = data.wire.files.reduce((sum, file) => sum + file.deletions, 0);
511
+ const openCount = data.comments.filter((comment) => comment.state === "open").length;
512
+ const draftCount = data.comments.filter((comment) => comment.state === "draft").length;
513
+ const displayRows = selectedFile === null
514
+ ? 1
515
+ : view === "unified"
516
+ ? Math.max(1, selectedFile.unifiedRows)
517
+ : Math.max(1, selectedFile.splitRows);
518
+ const displayPatch = selectedFile === null || !showWhitespace
519
+ ? (selectedFile?.patch ?? "")
520
+ : visibleWhitespace(selectedFile.patch);
521
+ const footer = editor !== null
522
+ ? " ctrl+enter save · esc cancel"
523
+ : focus === "files"
524
+ ? " files · ↑↓/jk move · enter review · tab pane · esc back · q quit"
525
+ : focus === "comments"
526
+ ? " comments · ↑↓/jk move · enter anchor · a accept/address · x dismiss · u reopen · tab pane"
527
+ : ` diff · ↑↓/jk lines · n/p files · [/ ] hunks · v range${rangeStart === null ? "" : `:${rangeStart}`} · c inline · C change · d layout · w wrap · z whitespace`;
528
+ const sidebar = wide ? (_jsxs("box", { width: 36, flexShrink: 0, flexDirection: "column", backgroundColor: BG, children: [_jsx("box", { border: true, borderStyle: "rounded", borderColor: focus === "files" ? COBALT : RULE, title: ` files · ${files.length} `, titleAlignment: "left", backgroundColor: PANEL, flexGrow: 1, minHeight: 6, children: _jsx("scrollbox", { ref: fileScrollRef, focused: focus === "files", flexGrow: 1, minHeight: 0, style: {
529
+ rootOptions: { backgroundColor: PANEL, border: false },
530
+ wrapperOptions: { backgroundColor: PANEL },
531
+ viewportOptions: { backgroundColor: PANEL },
532
+ contentOptions: { backgroundColor: PANEL },
533
+ }, children: files.map((file, index) => (_jsxs("box", { flexShrink: 0, flexDirection: "column", children: [index === 0 || files[index - 1]?.status !== file.status ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: ` ${file.status}` })) : null, _jsx(FileRow, { file: file, selected: index === fileIndex })] }, file.path))) }) }), _jsx("box", { border: true, borderStyle: "rounded", borderColor: focus === "comments" ? COBALT : RULE, title: ` comments · ${openCount} open${draftCount > 0 ? ` · ${draftCount} draft` : ""} `, titleAlignment: "left", backgroundColor: PANEL, flexGrow: 1, minHeight: 6, children: _jsx("scrollbox", { ref: commentScrollRef, focused: focus === "comments", flexGrow: 1, minHeight: 0, style: {
534
+ rootOptions: { backgroundColor: PANEL, border: false },
535
+ wrapperOptions: { backgroundColor: PANEL },
536
+ viewportOptions: { backgroundColor: PANEL },
537
+ contentOptions: { backgroundColor: PANEL },
538
+ }, children: comments.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " no review comments yet" })) : (comments.map((comment, index) => (_jsx(CommentRow, { comment: comment, selected: index === commentIndex }, comment.id)))) }) })] })) : null;
539
+ return (_jsxs("box", { flexGrow: 1, flexDirection: "column", backgroundColor: BG, children: [_jsxs("box", { height: 2, flexShrink: 0, flexDirection: "column", backgroundColor: BG, children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: INK, children: " mend" }), _jsx("span", { fg: FAINT, children: " / " }), _jsx("span", { fg: MUTED, children: projectName }), _jsx("span", { fg: FAINT, children: " / " }), _jsx("span", { fg: COBALT, children: "review" }), _jsxs("span", { fg: FAINT, children: [" ", "\u00B7 ", session.harness, " ", session.id.slice(0, 8)] }), isFetching ? _jsx("span", { fg: COBALT, children: " \u00B7 syncing" }) : null, _jsx("span", { fg: checkpointState === "failed" ? AMBER : FAINT, children: checkpointState === "recording"
540
+ ? " · checkpointing"
541
+ : checkpointState === "failed"
542
+ ? " · checkpoint incomplete"
543
+ : " · checkpoint recorded" })] }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsxs("span", { fg: FAINT, children: [" ", data.wire.change.branch.replace(/^mend\/session\//, "session ")] }), _jsxs("span", { fg: FAINT, children: [" \u00B7 worktree vs ", data.wire.change.baseSha.slice(0, 12), " \u00B7 "] }), _jsxs("span", { fg: MUTED, children: [files.length, " files"] }), _jsxs("span", { fg: GREEN, children: [" +", additions] }), _jsxs("span", { fg: RED, children: [" \u2212", deletions] }), _jsxs("span", { fg: AMBER, children: [" \u00B7 ", openCount, " open"] })] })] }), _jsx(Description, { data: data, tourIndex: activeTourIndex, stale: tourStale, width: width, height: descriptionHeight }), _jsxs("box", { flexGrow: 1, minHeight: 0, flexDirection: "row", backgroundColor: BG, children: [sidebar, _jsxs("box", { flexGrow: 1, minWidth: 0, minHeight: 0, flexDirection: "column", backgroundColor: BG, children: [_jsx("box", { border: true, borderStyle: "rounded", borderColor: focus === "diff" ? COBALT : RULE, title: selectedFile === null
544
+ ? " diff "
545
+ : ` ${selectedFile.path} · ${view}${selectedFile.likelyGenerated ? " · inferred likely generated" : ""}${showWhitespace ? " · whitespace" : ""}${wrap ? " · wrapped" : ""} `, titleAlignment: "left", backgroundColor: PANEL, flexGrow: 1, minHeight: 0, children: selectedFile === null || selectedFile.patch === "" || selectedFile.binary ? (_jsx("box", { flexGrow: 1, alignItems: "center", justifyContent: "center", children: _jsx("text", { fg: FAINT, bg: "transparent", children: files.length === 0
546
+ ? "The worktree matches its base — nothing to review yet."
547
+ : selectedFile?.binary === true
548
+ ? "Binary change — text diff unavailable; statistics remain reviewable."
549
+ : "This file has no renderable text patch." }) })) : (_jsx("scrollbox", { ref: diffScrollRef, focused: focus === "diff", flexGrow: 1, minHeight: 0, scrollX: true, style: {
550
+ rootOptions: { backgroundColor: PANEL, border: false },
551
+ wrapperOptions: { backgroundColor: PANEL },
552
+ viewportOptions: { backgroundColor: PANEL },
553
+ contentOptions: { backgroundColor: PANEL },
554
+ }, children: _jsx("diff", { ref: diffRef, diff: displayPatch, view: view, syncScroll: true, ...(selectedFile.filetype === undefined
555
+ ? {}
556
+ : { filetype: selectedFile.filetype }), syntaxStyle: syntaxStyle, wrapMode: wrap ? "word" : "none", showLineNumbers: true, fg: INK, lineNumberFg: FAINT, lineNumberBg: "transparent", contextBg: "transparent", contextContentBg: "transparent", addedBg: ADD_WASH, addedContentBg: ADD_WASH, removedBg: DELETE_WASH, removedContentBg: DELETE_WASH, addedSignColor: GREEN, removedSignColor: RED, addedLineNumberBg: "transparent", removedLineNumberBg: "transparent", selectionBg: WASH, selectionFg: INK, width: "100%", height: displayRows, flexShrink: 0 }, `${selectedFile.path}-${view}-${wrap}-${showWhitespace}`) })) }), focus === "comments" && detailComment !== null ? (_jsx(EvidenceCard, { comment: detailComment, width: wide ? width - 38 : width })) : height < 30 && activeTourStop !== null ? (_jsx(CompactTourEvidence, { stop: activeTourStop, index: activeTourIndex, total: tourStops.length, width: width })) : detailComment !== null ? (_jsx(EvidenceCard, { comment: detailComment, width: wide ? width - 38 : width })) : null] })] }), editor === null ? null : (_jsx(EditorPanel, { editor: editor, refValue: editorRef, busy: busy, onSubmit: () => void submitEditor() })), _jsx("text", { height: 1, fg: status === "" ? FAINT : AMBER, bg: "transparent", children: status === ""
557
+ ? ` m read · g suggest · t tour · ,/. tour stops · s draft review${data.followUp?.status === "pending" ? " · y deliver & relaunch" : ""} · o web · r refresh`
558
+ : ` ${status}` }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: footer })] }));
559
+ }
package/dist/shared.js ADDED
@@ -0,0 +1,48 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import * as path from "node:path";
3
+ /**
4
+ * Facts both entry points need: how each harness launches and resumes, which
5
+ * session statuses mean "live", and how a cwd resolves to an adopted project.
6
+ * Kept dependency-free so main.ts stays runnable on plain Node >= 22.
7
+ */
8
+ export const HARNESS_COMMANDS = {
9
+ codex: ["codex"],
10
+ claude: ["claude"],
11
+ opencode: ["opencode"],
12
+ };
13
+ /** How each harness takes an instruction as its opening prompt. */
14
+ export const CONTINUE_COMMANDS = {
15
+ codex: (instruction) => ["codex", instruction],
16
+ claude: (instruction) => ["claude", instruction],
17
+ opencode: (instruction) => ["opencode", "run", instruction],
18
+ };
19
+ export const LIVE_STATUSES = new Set([
20
+ "starting",
21
+ "running",
22
+ "waiting",
23
+ "idle",
24
+ ]);
25
+ /** The cwd's adopted project: adopted-from-here first, then a basename match. */
26
+ export const matchProjectByCwd = (projects, cwd) => {
27
+ const byOrigin = projects.find((p) => p.originUrl !== null && cwd.startsWith(p.originUrl));
28
+ const byName = projects.find((p) => p.name === path.basename(cwd));
29
+ return byOrigin ?? byName;
30
+ };
31
+ /** The repository root of `cwd`, or null when it is not inside a git repo. */
32
+ export const gitTopLevel = (cwd) => {
33
+ const result = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd, encoding: "utf8" });
34
+ return result.status === 0 ? result.stdout.trim() : null;
35
+ };
36
+ /**
37
+ * The store's name charset is /^[a-z0-9][a-z0-9._-]{0,63}$/ (server-enforced).
38
+ * Derived defaults (a directory called "Mend") get normalized instead of
39
+ * bounced; an explicit --name is sent as typed so the server's rule teaches.
40
+ */
41
+ export const normalizeProjectName = (raw) => {
42
+ const slug = raw
43
+ .toLowerCase()
44
+ .replace(/[^a-z0-9._-]+/g, "-")
45
+ .replace(/^[^a-z0-9]+/, "")
46
+ .replace(/-+$/, "");
47
+ return slug === "" ? "project" : slug.slice(0, 64);
48
+ };
@@ -0,0 +1,8 @@
1
+ import { spawn } from "node:child_process";
2
+ /** Open a Mend web surface without keeping the terminal process alive. */
3
+ export const openUrl = (target) => {
4
+ const opener = process.platform === "darwin" ? "open" : "xdg-open";
5
+ const child = spawn(opener, [target], { detached: true, stdio: "ignore" });
6
+ child.on("error", () => undefined);
7
+ child.unref();
8
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Evidence Review's canonical dark tokens, transcribed from
3
+ * packages/ui/src/styles/globals.css because a terminal cannot consume CSS
4
+ * custom properties. Component code imports semantic names from here; this
5
+ * file is the CLI's single color authority.
6
+ */
7
+ export const BG = "#1c1c1f";
8
+ export const PANEL = "#232327";
9
+ export const INK = "#eceae6";
10
+ export const INK_2 = "#c2c0bd";
11
+ export const MUTED = "#9a978f";
12
+ export const FAINT = "#76746d";
13
+ export const RULE = "#2e2c28";
14
+ export const COBALT = "#5781ea";
15
+ export const WASH = "#1a2238";
16
+ export const AMBER = "#d9a93a";
17
+ export const GREEN = "#5faf78";
18
+ export const RED = "#e0726a";
19
+ export const ADD_WASH = "#29312f";
20
+ export const DELETE_WASH = "#342a2d";
21
+ /** Syntax colors are centralized too; they are subordinate to semantic UI color. */
22
+ export const SYNTAX_KEYWORD = "#c8a0e8";
23
+ export const SYNTAX_STRING = "#8fc49f";
24
+ export const SYNTAX_NUMBER = "#dfbd78";
25
+ export const SYNTAX_FUNCTION = "#8eb7ef";