@sealant/mend 0.21.1 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +133 -6
  2. package/dist/chunk-YPM6TYQR.js +27888 -0
  3. package/dist/dashboard-LR5LHV7O.js +2951 -0
  4. package/dist/main.js +6917 -2580
  5. package/man/mend-accounts.1 +1 -1
  6. package/man/mend-adopt.1 +4 -4
  7. package/man/mend-attach.1 +1 -1
  8. package/man/mend-codex.1 +1 -1
  9. package/man/mend-completions.1 +1 -1
  10. package/man/mend-connect.1 +1 -1
  11. package/man/mend-continue.1 +1 -1
  12. package/man/mend-doctor.1 +1 -1
  13. package/man/mend-dotfiles-sync.1 +1 -1
  14. package/man/mend-dotfiles.1 +1 -1
  15. package/man/mend-env-cluster.1 +1 -1
  16. package/man/mend-env-load.1 +1 -1
  17. package/man/mend-env-show.1 +1 -1
  18. package/man/mend-help.1 +1 -1
  19. package/man/mend-keys-init.1 +1 -1
  20. package/man/mend-keys-mode.1 +1 -1
  21. package/man/mend-keys-share.1 +1 -1
  22. package/man/mend-keys-show.1 +1 -1
  23. package/man/mend-login.1 +1 -1
  24. package/man/mend-logout.1 +1 -1
  25. package/man/mend-man.1 +1 -1
  26. package/man/mend-pair.1 +1 -1
  27. package/man/mend-projects.1 +1 -1
  28. package/man/mend-refresh.1 +1 -1
  29. package/man/mend-rejoin.1 +1 -1
  30. package/man/mend-resume.1 +1 -1
  31. package/man/mend-run.1 +1 -1
  32. package/man/mend-server-logs.1 +15 -0
  33. package/man/mend-server-restart.1 +15 -0
  34. package/man/mend-server-setup.1 +64 -0
  35. package/man/mend-server-start.1 +15 -0
  36. package/man/mend-server-status.1 +11 -0
  37. package/man/mend-server-stop.1 +11 -0
  38. package/man/mend-server-upgrade.1 +30 -0
  39. package/man/mend-server.1 +11 -0
  40. package/man/mend-service-add.1 +1 -1
  41. package/man/mend-service-connect.1 +1 -1
  42. package/man/mend-service-init.1 +1 -1
  43. package/man/mend-service-list.1 +1 -1
  44. package/man/mend-service-logs.1 +1 -1
  45. package/man/mend-service-restart.1 +1 -1
  46. package/man/mend-service-run.1 +1 -1
  47. package/man/mend-service-stop.1 +1 -1
  48. package/man/mend-sessions.1 +1 -1
  49. package/man/mend-shell.1 +1 -1
  50. package/man/mend-skills-push.1 +1 -1
  51. package/man/mend-skills.1 +1 -1
  52. package/man/mend-ssh-setup.1 +6 -3
  53. package/man/mend-ssh.1 +1 -1
  54. package/man/mend-stop.1 +1 -1
  55. package/man/mend-ui.1 +1 -1
  56. package/man/mend-version.1 +1 -1
  57. package/man/mend-worktrees.1 +1 -1
  58. package/man/mend.1 +26 -2
  59. package/package.json +6 -2
  60. package/dist/agent-share.js +0 -150
  61. package/dist/clipboard.js +0 -73
  62. package/dist/dashboard-model.js +0 -308
  63. package/dist/dashboard.js +0 -1200
  64. package/dist/doctor.js +0 -233
  65. package/dist/dotfiles.js +0 -90
  66. package/dist/env.js +0 -17
  67. package/dist/help.js +0 -839
  68. package/dist/login.js +0 -240
  69. package/dist/pair.js +0 -83
  70. package/dist/review-model.js +0 -212
  71. package/dist/review-workflow.js +0 -8
  72. package/dist/review.js +0 -690
  73. package/dist/service-init.js +0 -227
  74. package/dist/shared.js +0 -338
  75. package/dist/skills.js +0 -134
  76. package/dist/ssh-setup.js +0 -181
  77. package/dist/terminal.js +0 -8
  78. package/dist/tui-theme.js +0 -41
  79. package/dist/version.js +0 -48
  80. package/dist/workbench-events.js +0 -72
package/dist/review.js DELETED
@@ -1,690 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
2
- import { createHash, randomUUID } from "node:crypto";
3
- import { RGBA, SyntaxStyle, } from "@opentui/core";
4
- import { useKeyboard, useTerminalDimensions } from "@opentui/react";
5
- import { useMutation, 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 { isPendingId, pendingId } from "./shared.js";
10
- import { openUrl } from "./terminal.js";
11
- import { ADD_WASH, COBALT, DELETE_WASH, FAINT, GREEN, INK, INK_2, MUTED, 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 opened = await ctx.api("POST", `/changes/${changeId}/reviews/open`, {
31
- idempotencyKey: `cli-review:${changeId}`,
32
- });
33
- const pinned = await ctx.api("GET", `/changes/${changeId}/reviews/${opened.slice.id}/diff`);
34
- const wire = {
35
- change: pinned.change,
36
- diff: pinned.patch,
37
- files: pinned.files.map((file) => ({
38
- path: file.newPath ?? file.oldPath ?? "unknown path",
39
- additions: file.additions,
40
- deletions: file.deletions,
41
- })),
42
- };
43
- const [comments, tour, passes, followUp, sessionDetail] = await Promise.all([
44
- ctx.api("GET", `/changes/${changeId}/comments`),
45
- ctx.api("GET", `/changes/${changeId}/tour`),
46
- ctx.api("GET", `/changes/${changeId}/passes`),
47
- ctx.api("GET", `/sessions/${wire.change.sessionId}/follow-up`),
48
- ctx.api("GET", `/sessions/${wire.change.sessionId}`),
49
- ]);
50
- return {
51
- wire,
52
- sliceId: pinned.slice.id,
53
- checkpointAId: pinned.checkpointA.id,
54
- checkpointBId: pinned.checkpointB.id,
55
- diffDigest: pinned.slice.diffDigest,
56
- anchorFiles: pinned.anchorFiles,
57
- files: buildReviewFiles(wire.diff, wire.files),
58
- comments,
59
- tour,
60
- passes,
61
- followUp,
62
- sessionStatus: sessionDetail.session.status,
63
- };
64
- };
65
- const commentPriority = (comment) => comment.state === "draft" ? 0 : comment.state === "open" ? 1 : 2;
66
- const orderedComments = (comments) => comments.toSorted((a, b) => {
67
- const byState = commentPriority(a) - commentPriority(b);
68
- return byState === 0 ? b.createdAt.localeCompare(a.createdAt) : byState;
69
- });
70
- const stateColor = (state) => state === "draft" || state === "open" ? INK : state === "addressed" ? INK_2 : FAINT;
71
- const anchorOf = (comment) => comment.file === null
72
- ? "change"
73
- : `${comment.file}${comment.line === null ? "" : `:${comment.line}${comment.endLine === null || comment.endLine === comment.line ? "" : `-${comment.endLine}`}`}`;
74
- const passFact = (pass) => {
75
- const label = pass.kind === "suggest" ? "suggestions" : pass.kind;
76
- if (pass.status === "running")
77
- return { text: `${label} running`, color: INK_2 };
78
- if (pass.status === "failed")
79
- return { text: `${label} failed`, color: RED };
80
- return {
81
- text: `${label} observed · ${pass.findings ?? 0} ${pass.kind === "tour" ? "stops" : "drafts"}`,
82
- color: MUTED,
83
- };
84
- };
85
- 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: MUTED, 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"] })] })] }));
86
- 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 ? "▌ " : " " }), _jsx("span", { fg: stateColor(comment.state), children: comment.state }), _jsxs("span", { fg: FAINT, children: [" \u00B7 ", anchorOf(comment)] })] }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { children: " " }), _jsx("span", { fg: MUTED, children: comment.authorKind === "mend" ? "Mend · " : "You · " }), _jsx("span", { fg: INK, children: truncate(comment.body, 27) })] })] }));
87
- const Description = ({ data, tourIndex, stale, width, height, }) => {
88
- const passFacts = data.passes.map(passFact);
89
- const tourStop = data.tour?.stops[tourIndex] ?? null;
90
- const stopEvidence = tourStop?.evidence[0] ?? null;
91
- return (_jsxs("box", { height: height, border: true, borderStyle: "rounded", borderColor: RULE, title: " change description ", titleAlignment: "left", backgroundColor: "transparent", 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: MUTED, children: "Mend uses inference \u00B7 " }), stale ? _jsx("span", { fg: MUTED, 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: INK_2, children: ["stop ", tourIndex + 1, "/", data.tour.stops.length, " \u00B7 ", tourStop.title] }), _jsxs("span", { fg: FAINT, children: [tourStop.file === null
92
- ? " · change"
93
- : ` · ${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: MUTED, 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: MUTED, children: stopEvidence === null
94
- ? "no direct evidence · "
95
- : `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: INK_2, children: " \u00B7 follow-up pending" }) : null] })] }));
96
- };
97
- const CompactTourEvidence = ({ stop, index, total, width, }) => {
98
- const evidence = stop.evidence[0] ?? null;
99
- return (_jsxs("box", { height: 5, border: true, borderStyle: "rounded", borderColor: RULE, title: ` tour stop ${index + 1}/${total} · ${stop.grounded ? "direct record" : "inferred reading"} `, titleAlignment: "left", backgroundColor: "transparent", 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: MUTED, 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" })] }));
100
- };
101
- const EvidenceCard = ({ comment, width, }) => (_jsxs("box", { height: Math.min(6, 4 + Math.min(2, comment.evidence.length)), border: true, borderStyle: "rounded", borderColor: RULE, title: ` ${anchorOf(comment)} · ${comment.authorKind === "mend" ? "Mend" : "You"} `, titleAlignment: "left", backgroundColor: "transparent", 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: MUTED, 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}`)))] }));
102
- const EditorPanel = ({ editor, refValue, busy, onSubmit, }) => {
103
- const title = editor.kind === "send"
104
- ? " send review to session · edit before sending "
105
- : ` comment · ${editor.file === null ? "change" : `${editor.file}:${editor.line ?? ""}`} `;
106
- return (_jsxs("box", { border: true, borderStyle: "rounded", borderColor: COBALT, title: title, titleAlignment: "left", backgroundColor: "transparent", height: editor.kind === "send" ? 16 : 8, flexShrink: 0, flexDirection: "column", children: [_jsx("textarea", { ref: refValue, focused: true, initialValue: editor.kind === "send" ? editor.instruction : (editor.initialBody ?? ""), placeholder: editor.kind === "send" ? "Review instruction…" : "What should change, and why?", backgroundColor: "transparent", focusedBackgroundColor: "transparent", 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"] }) })] }));
107
- };
108
- const errorMessage = (error) => error instanceof Error ? error.message : error === null ? "" : String(error);
109
- export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQuit, }) {
110
- const queryClient = useQueryClient();
111
- const { width, height } = useTerminalDimensions();
112
- const wide = width >= 100;
113
- const descriptionHeight = height >= 30 ? 7 : 5;
114
- const { data, failureReason, isFetching } = useQuery({
115
- queryKey: REVIEW_KEY(changeId),
116
- queryFn: () => fetchReview(ctx, changeId),
117
- retry: true,
118
- retryDelay: 1000,
119
- });
120
- const [focus, setFocus] = useState("diff");
121
- const [fileIndex, setFileIndex] = useState(0);
122
- const [anchorIndex, setAnchorIndex] = useState(0);
123
- const [commentIndex, setCommentIndex] = useState(0);
124
- const [tourIndex, setTourIndex] = useState(0);
125
- const [view, setView] = useState("unified");
126
- const [wrap, setWrap] = useState(false);
127
- const [showWhitespace, setShowWhitespace] = useState(false);
128
- const [rangeStart, setRangeStart] = useState(null);
129
- const [editor, setEditor] = useState(null);
130
- const [busy, setBusy] = useState(false);
131
- const [status, setStatus] = useState("");
132
- const checkpointState = data === undefined ? (isFetching ? "recording" : "failed") : "recorded";
133
- const lockRef = useRef(false);
134
- const editorRef = useRef(null);
135
- const diffRef = useRef(null);
136
- const diffScrollRef = useRef(null);
137
- const fileScrollRef = useRef(null);
138
- const commentScrollRef = useRef(null);
139
- const files = useMemo(() => {
140
- const order = {
141
- added: 0,
142
- modified: 1,
143
- renamed: 2,
144
- deleted: 3,
145
- };
146
- return (data?.files ?? []).toSorted((left, right) => order[left.status] - order[right.status] || left.path.localeCompare(right.path));
147
- }, [data?.files]);
148
- const selectedFile = files[Math.min(fileIndex, Math.max(0, files.length - 1))] ?? null;
149
- const anchors = useMemo(() => selectedFile?.lines.filter((line) => line.commentable) ?? [], [selectedFile]);
150
- const selectedAnchor = anchors[Math.min(anchorIndex, Math.max(0, anchors.length - 1))] ?? null;
151
- const comments = useMemo(() => orderedComments(data?.comments ?? []), [data?.comments]);
152
- const selectedComment = comments[Math.min(commentIndex, Math.max(0, comments.length - 1))] ?? null;
153
- const lineComment = selectedFile === null || selectedAnchor === null
154
- ? null
155
- : (comments.find((comment) => comment.file === selectedFile.path &&
156
- comment.line !== null &&
157
- selectedAnchor.newLine !== null &&
158
- comment.line <= selectedAnchor.newLine &&
159
- (comment.endLine ?? comment.line) >= selectedAnchor.newLine) ?? null);
160
- const detailComment = focus === "comments" ? selectedComment : lineComment;
161
- const tourStops = data?.tour?.stops ?? [];
162
- const activeTourIndex = Math.min(tourIndex, Math.max(0, tourStops.length - 1));
163
- const activeTourStop = tourStops[activeTourIndex] ?? null;
164
- const tourStale = useMemo(() => data?.tour === null || data?.tour === undefined
165
- ? false
166
- : createHash("sha256").update(data.wire.diff).digest("hex") !== data.tour.diffDigest, [data?.tour, data?.wire.diff]);
167
- const refresh = () => {
168
- void queryClient.invalidateQueries({ queryKey: REVIEW_KEY(changeId) });
169
- };
170
- /** Converge on the server's truth once, after the LAST in-flight mutation. */
171
- const settleRefresh = () => {
172
- if (queryClient.isMutating() === 1)
173
- refresh();
174
- };
175
- const patchReview = (f) => {
176
- const current = queryClient.getQueryData(REVIEW_KEY(changeId));
177
- if (current !== undefined)
178
- queryClient.setQueryData(REVIEW_KEY(changeId), f(current));
179
- };
180
- // A state flip (accept, address, dismiss, reopen) lands in the list at the
181
- // keystroke — triage never waits on a round trip. An error refetches the
182
- // truth back; the settled refetch reconciles everything else.
183
- const commentStateMutation = useMutation({
184
- mutationFn: (vars) => ctx.api("POST", `/changes/${changeId}/comments/${vars.commentId}/state`, {
185
- state: vars.state,
186
- }),
187
- onMutate: async (vars) => {
188
- await queryClient.cancelQueries({ queryKey: REVIEW_KEY(changeId) });
189
- patchReview((current) => ({
190
- ...current,
191
- comments: current.comments.map((comment) => comment.id === vars.commentId ? { ...comment, state: vars.state } : comment),
192
- }));
193
- },
194
- onError: (error) => {
195
- setStatus(errorMessage(error));
196
- refresh();
197
- },
198
- onSettled: settleRefresh,
199
- });
200
- // A new comment appears in the list the moment ctrl+enter lands and the
201
- // editor closes; a failed save reopens the editor with the text intact.
202
- const commentCreateMutation = useMutation({
203
- mutationFn: (vars) => ctx.api("POST", `/changes/${changeId}/reviews/${vars.sliceId}/comments`, {
204
- target: vars.target,
205
- body: vars.body,
206
- }),
207
- onMutate: async (vars) => {
208
- await queryClient.cancelQueries({ queryKey: REVIEW_KEY(changeId) });
209
- patchReview((current) => ({
210
- ...current,
211
- comments: [vars.optimistic, ...current.comments],
212
- }));
213
- setEditor(null);
214
- setStatus(vars.optimistic.file === null ? "Change comment added" : "Inline comment added");
215
- },
216
- onError: (error, vars) => {
217
- patchReview((current) => ({
218
- ...current,
219
- comments: current.comments.filter((comment) => comment.id !== vars.optimistic.id),
220
- }));
221
- setEditor({
222
- kind: "comment",
223
- file: vars.optimistic.file,
224
- line: vars.optimistic.line,
225
- endLine: vars.optimistic.endLine,
226
- initialBody: vars.body,
227
- });
228
- setStatus(errorMessage(error));
229
- },
230
- onSettled: settleRefresh,
231
- });
232
- useEffect(() => {
233
- fileScrollRef.current?.scrollTo(Math.max(0, fileIndex * 2 - 2));
234
- }, [fileIndex]);
235
- useEffect(() => {
236
- commentScrollRef.current?.scrollTo(Math.max(0, commentIndex * 2 - 2));
237
- }, [commentIndex]);
238
- useEffect(() => {
239
- if (selectedAnchor === null)
240
- return;
241
- const row = view === "unified" ? selectedAnchor.unifiedRow : selectedAnchor.splitRow;
242
- diffScrollRef.current?.scrollTo(Math.max(0, row - 3));
243
- }, [selectedAnchor, view]);
244
- useEffect(() => {
245
- // Split can align a deletion and addition on one row; preserve their
246
- // semantic colors there. Unified has one fact per row, so cobalt can mark
247
- // the active comment anchor without lying about either side.
248
- if (view !== "unified" || selectedAnchor === null || diffRef.current === null)
249
- return;
250
- const start = rangeStart === null ? selectedAnchor.newLine : rangeStart;
251
- const selectedLine = selectedAnchor.newLine;
252
- const highlighted = start === null || selectedLine === null
253
- ? [selectedAnchor]
254
- : anchors.filter((anchor) => anchor.newLine !== null &&
255
- anchor.newLine >= Math.min(start, selectedLine) &&
256
- anchor.newLine <= Math.max(start, selectedLine));
257
- for (const anchor of highlighted) {
258
- diffRef.current.setLineColor(anchor.unifiedRow, { gutter: COBALT, content: WASH });
259
- }
260
- return () => {
261
- if (diffRef.current === null)
262
- return;
263
- for (const anchor of highlighted) {
264
- const content = anchor.kind === "addition"
265
- ? ADD_WASH
266
- : anchor.kind === "deletion"
267
- ? DELETE_WASH
268
- : "transparent";
269
- diffRef.current.setLineColor(anchor.unifiedRow, {
270
- gutter: "transparent",
271
- content,
272
- });
273
- }
274
- };
275
- }, [anchors, rangeStart, selectedAnchor, view]);
276
- const moveFile = (delta) => {
277
- if (files.length === 0)
278
- return;
279
- setFileIndex((current) => {
280
- const next = Math.max(0, Math.min(files.length - 1, current + delta));
281
- if (next !== current) {
282
- setAnchorIndex(0);
283
- setRangeStart(null);
284
- }
285
- return next;
286
- });
287
- };
288
- const moveAnchor = (delta) => {
289
- if (anchors.length === 0)
290
- return;
291
- setAnchorIndex((current) => Math.max(0, Math.min(anchors.length - 1, current + delta)));
292
- };
293
- const navigateToAnchor = (file, line) => {
294
- if (file === null)
295
- return;
296
- const nextFileIndex = files.findIndex((candidate) => candidate.path === file);
297
- if (nextFileIndex < 0)
298
- return;
299
- const nextFile = files[nextFileIndex];
300
- const nextAnchorIndex = line === null
301
- ? 0
302
- : (nextFile?.lines.findIndex((candidate) => candidate.newLine !== null && candidate.newLine >= line) ?? -1);
303
- setFileIndex(nextFileIndex);
304
- setAnchorIndex(Math.max(0, nextAnchorIndex));
305
- setRangeStart(null);
306
- };
307
- const selectComment = (index) => {
308
- const next = comments[index];
309
- if (next === undefined)
310
- return;
311
- setCommentIndex(index);
312
- navigateToAnchor(next.file, next.line);
313
- };
314
- const moveComment = (delta) => {
315
- if (comments.length === 0)
316
- return;
317
- const current = Math.min(commentIndex, comments.length - 1);
318
- selectComment(Math.max(0, Math.min(comments.length - 1, current + delta)));
319
- };
320
- const moveTour = (delta) => {
321
- if (tourStops.length === 0)
322
- return;
323
- const current = Math.min(tourIndex, tourStops.length - 1);
324
- const nextIndex = Math.max(0, Math.min(tourStops.length - 1, current + delta));
325
- const stop = tourStops[nextIndex];
326
- setTourIndex(nextIndex);
327
- if (stop !== undefined)
328
- navigateToAnchor(stop.file, stop.line);
329
- };
330
- const moveHunk = (delta) => {
331
- if (selectedFile === null || selectedAnchor === null)
332
- return;
333
- const target = Math.max(0, Math.min(selectedFile.hunks.length - 1, selectedAnchor.hunk + delta));
334
- const next = anchors.findIndex((anchor) => anchor.hunk === target);
335
- if (next >= 0)
336
- setAnchorIndex(next);
337
- };
338
- const setWorking = (value) => {
339
- lockRef.current = value;
340
- setBusy(value);
341
- };
342
- const runAction = async (label, action, completed = `${label} · requested`) => {
343
- setWorking(true);
344
- setStatus(`${label}…`);
345
- try {
346
- await action();
347
- setStatus(completed);
348
- refresh();
349
- }
350
- catch (error) {
351
- setStatus(errorMessage(error));
352
- }
353
- finally {
354
- setWorking(false);
355
- }
356
- };
357
- const openCommentEditor = (wholeChange) => {
358
- if (wholeChange) {
359
- setEditor({ kind: "comment", file: null, line: null, endLine: null });
360
- return;
361
- }
362
- if (selectedFile?.status === "deleted" && selectedAnchor === null) {
363
- setStatus("Deleted-side anchors are not persisted · use C for a change comment");
364
- return;
365
- }
366
- if (selectedFile === null || selectedAnchor?.newLine === null || selectedAnchor === null) {
367
- setStatus("No new-side line selected");
368
- return;
369
- }
370
- const range = commentRange(rangeStart, selectedAnchor.newLine);
371
- setEditor({
372
- kind: "comment",
373
- file: selectedFile.path,
374
- line: range.line,
375
- endLine: range.endLine === range.line ? null : range.endLine,
376
- });
377
- setRangeStart(null);
378
- };
379
- const openSendEditor = () => {
380
- if (data === undefined)
381
- return;
382
- // A pending id is an optimistic row the server has not named yet — it
383
- // cannot ride a follow-up until the settle refetch replaces it.
384
- const sendable = data.comments.filter((comment) => comment.state === "open" && comment.sentToSessionId === null && !isPendingId(comment.id));
385
- if (sendable.length === 0) {
386
- setStatus("No unsent open comments — accept a draft or write a comment first");
387
- return;
388
- }
389
- setEditor({
390
- kind: "send",
391
- instruction: assembleReviewInstruction(data.wire.change, sendable),
392
- commentIds: sendable.map((comment) => comment.id),
393
- idempotencyKey: `cli-follow-up:${data.sliceId}:${randomUUID()}`,
394
- });
395
- };
396
- const submitEditor = async () => {
397
- if (editor === null || lockRef.current)
398
- return;
399
- const value = editorRef.current?.plainText.trim() ?? "";
400
- if (value === "") {
401
- setStatus("Write something before saving");
402
- return;
403
- }
404
- if (data === undefined) {
405
- setStatus("The pinned Review is not available.");
406
- return;
407
- }
408
- if (editor.kind === "comment") {
409
- const anchorFile = data.anchorFiles.find((file) => (file.newPath ?? file.oldPath) === editor.file);
410
- const endLine = editor.endLine ?? editor.line;
411
- const hunk = editor.line === null || endLine === null
412
- ? null
413
- : (anchorFile?.hunks.find((candidate) => candidate.newLines > 0 &&
414
- editor.line !== null &&
415
- editor.line >= candidate.newStart &&
416
- endLine <= candidate.newStart + candidate.newLines - 1) ?? null);
417
- if (editor.file !== null && (anchorFile === undefined || hunk === null)) {
418
- setStatus("The selected line is outside the pinned Review anchor.");
419
- return;
420
- }
421
- commentCreateMutation.mutate({
422
- sliceId: data.sliceId,
423
- body: value,
424
- target: editor.file === null
425
- ? {
426
- oldPath: null,
427
- newPath: null,
428
- side: null,
429
- startLine: null,
430
- endLine: null,
431
- hunkContextHash: null,
432
- }
433
- : {
434
- oldPath: anchorFile?.oldPath ?? null,
435
- newPath: anchorFile?.newPath ?? null,
436
- side: "new",
437
- startLine: editor.line,
438
- endLine,
439
- hunkContextHash: hunk?.contextHash ?? null,
440
- },
441
- optimistic: {
442
- id: pendingId(),
443
- file: editor.file,
444
- line: editor.line,
445
- endLine: editor.endLine,
446
- authorKind: "reviewer",
447
- authorName: "You",
448
- body: value,
449
- suggestion: null,
450
- state: "open",
451
- evidence: [],
452
- sentToSessionId: null,
453
- createdAt: new Date().toISOString(),
454
- },
455
- });
456
- return;
457
- }
458
- // Delivery stays pessimistic: it starts a run against the session, and
459
- // pretending it happened before the server agreed would fake a fact.
460
- setWorking(true);
461
- try {
462
- await deliverReview(ctx.api, session.id, {
463
- reviewSliceId: data.sliceId,
464
- checkpointAId: data.checkpointAId,
465
- checkpointBId: data.checkpointBId,
466
- diffDigest: data.diffDigest,
467
- commentIds: editor.commentIds,
468
- instruction: value,
469
- idempotencyKey: editor.idempotencyKey,
470
- });
471
- setStatus("Follow-up delivery requested · retry keeps the same run");
472
- setEditor(null);
473
- refresh();
474
- }
475
- catch (error) {
476
- setStatus(errorMessage(error));
477
- }
478
- finally {
479
- setWorking(false);
480
- }
481
- };
482
- const updateComment = (state) => {
483
- if (selectedComment === null)
484
- return;
485
- if (isPendingId(selectedComment.id)) {
486
- setStatus("That comment is still saving — one moment");
487
- return;
488
- }
489
- setStatus(`${state} · ${anchorOf(selectedComment)}`);
490
- commentStateMutation.mutate({ commentId: selectedComment.id, state });
491
- };
492
- const deliverAndRelaunch = () => {
493
- const followUp = data?.followUp ?? null;
494
- if (followUp === null || followUp.status === "delivered" || followUp.status === "superseded") {
495
- setStatus("No retryable follow-up — s assembles the open Review comments first");
496
- return;
497
- }
498
- if (followUp.reviewSliceId === null ||
499
- followUp.checkpointAId === null ||
500
- followUp.checkpointBId === null ||
501
- followUp.diffDigest === null ||
502
- followUp.idempotencyKey === null) {
503
- setStatus("Legacy follow-up · recreate it from a pinned Review before delivery");
504
- return;
505
- }
506
- const input = {
507
- reviewSliceId: followUp.reviewSliceId,
508
- checkpointAId: followUp.checkpointAId,
509
- checkpointBId: followUp.checkpointBId,
510
- diffDigest: followUp.diffDigest,
511
- commentIds: followUp.commentIds,
512
- instruction: followUp.instruction,
513
- idempotencyKey: followUp.idempotencyKey,
514
- };
515
- void runAction("Delivering the persisted Review bundle", () => deliverReview(ctx.api, session.id, input), "Delivery reconciled · the same idempotency key names one run");
516
- };
517
- const focusOrder = wide
518
- ? ["files", "diff", "comments"]
519
- : ["diff", "comments"];
520
- const cycleFocus = (backwards) => {
521
- const index = focusOrder.indexOf(focus);
522
- const direction = backwards ? -1 : 1;
523
- const next = (index + direction + focusOrder.length) % focusOrder.length;
524
- const nextFocus = focusOrder[next] ?? "diff";
525
- if (nextFocus === "comments" && selectedComment !== null) {
526
- navigateToAnchor(selectedComment.file, selectedComment.line);
527
- }
528
- setFocus(nextFocus);
529
- };
530
- useKeyboard((key) => {
531
- if (key.ctrl && key.name === "c")
532
- return onQuit();
533
- if (editor !== null) {
534
- if (key.name === "escape" && !lockRef.current)
535
- setEditor(null);
536
- return;
537
- }
538
- if (lockRef.current)
539
- return;
540
- if (key.name === "q")
541
- return onQuit();
542
- if (key.name === "escape" || key.name === "backspace" || key.name === "h")
543
- return onBack();
544
- if (key.name === "tab")
545
- return cycleFocus(key.shift);
546
- if (key.name === "r") {
547
- setStatus("Refreshing live change…");
548
- return refresh();
549
- }
550
- if (key.name === "o") {
551
- openUrl(`${ctx.config.url}/changes/${changeId}`);
552
- setStatus("Opened web review");
553
- return;
554
- }
555
- if (key.name === "c" && key.shift)
556
- return openCommentEditor(true);
557
- if (key.name === "s")
558
- return openSendEditor();
559
- if (key.name === "y")
560
- return deliverAndRelaunch();
561
- if (key.name === ",")
562
- return moveTour(-1);
563
- if (key.name === ".")
564
- return moveTour(1);
565
- if (key.name === "t") {
566
- return void runAction(data?.tour === null ? "Compose description and tour" : "Recompose tour", () => ctx.api("POST", `/changes/${changeId}/tour`, {}));
567
- }
568
- if (key.name === "m") {
569
- return void runAction("Mend reads the change", () => ctx.api("POST", `/changes/${changeId}/read`, {}));
570
- }
571
- if (key.name === "g") {
572
- return void runAction("Draft concrete suggestions", () => ctx.api("POST", `/changes/${changeId}/suggest`, {}));
573
- }
574
- if (focus === "files") {
575
- if (key.name === "j" || key.name === "down")
576
- return moveFile(1);
577
- if (key.name === "k" || key.name === "up")
578
- return moveFile(-1);
579
- if (key.name === "return" || key.name === "linefeed" || key.name === "l") {
580
- return setFocus("diff");
581
- }
582
- return;
583
- }
584
- if (focus === "comments") {
585
- if (key.name === "j" || key.name === "down")
586
- return moveComment(1);
587
- if (key.name === "k" || key.name === "up")
588
- return moveComment(-1);
589
- if (key.name === "a" && selectedComment?.state === "draft")
590
- return updateComment("open");
591
- if (key.name === "a" && selectedComment?.state === "open") {
592
- return updateComment("addressed");
593
- }
594
- if (key.name === "x")
595
- return updateComment("dismissed");
596
- if (key.name === "u" && selectedComment !== null)
597
- return updateComment("open");
598
- if (key.name === "return" || key.name === "linefeed" || key.name === "l") {
599
- if (selectedComment !== null)
600
- navigateToAnchor(selectedComment.file, selectedComment.line);
601
- return setFocus("diff");
602
- }
603
- return;
604
- }
605
- if (key.name === "j" || key.name === "down")
606
- return moveAnchor(1);
607
- if (key.name === "k" || key.name === "up")
608
- return moveAnchor(-1);
609
- if (key.name === "n")
610
- return moveFile(1);
611
- if (key.name === "p")
612
- return moveFile(-1);
613
- if (key.name === "]")
614
- return moveHunk(1);
615
- if (key.name === "[")
616
- return moveHunk(-1);
617
- if (key.name === "c")
618
- return openCommentEditor(false);
619
- if (key.name === "v") {
620
- if (selectedAnchor?.newLine === null || selectedAnchor === null) {
621
- setStatus("Select a new-side line before starting a range");
622
- return;
623
- }
624
- setRangeStart((current) => (current === null ? selectedAnchor.newLine : null));
625
- setStatus(rangeStart === null ? `Range starts at line ${selectedAnchor.newLine}` : "Range cleared");
626
- return;
627
- }
628
- if (key.name === "d" && wide)
629
- return setView((current) => (current === "unified" ? "split" : "unified"));
630
- if (key.name === "w")
631
- return setWrap((current) => !current);
632
- if (key.name === "z")
633
- return setShowWhitespace((current) => !current);
634
- });
635
- if (data === undefined) {
636
- return (_jsx("box", { flexGrow: 1, backgroundColor: "transparent", alignItems: "center", justifyContent: "center", children: _jsx("text", { bg: "transparent", children: _jsx("span", { fg: failureReason === null ? INK_2 : MUTED, children: failureReason === null
637
- ? "Loading the live change…"
638
- : `${errorMessage(failureReason)} · retrying` }) }) }));
639
- }
640
- const additions = data.wire.files.reduce((sum, file) => sum + file.additions, 0);
641
- const deletions = data.wire.files.reduce((sum, file) => sum + file.deletions, 0);
642
- const openCount = data.comments.filter((comment) => comment.state === "open").length;
643
- const draftCount = data.comments.filter((comment) => comment.state === "draft").length;
644
- const displayRows = selectedFile === null
645
- ? 1
646
- : view === "unified"
647
- ? Math.max(1, selectedFile.unifiedRows)
648
- : Math.max(1, selectedFile.splitRows);
649
- const displayPatch = selectedFile === null || !showWhitespace
650
- ? (selectedFile?.patch ?? "")
651
- : visibleWhitespace(selectedFile.patch);
652
- const footer = editor === null
653
- ? focus === "files"
654
- ? " files · ↑↓/jk move · enter review · tab pane · esc back · q quit"
655
- : focus === "comments"
656
- ? " comments · ↑↓/jk move · enter anchor · a accept/address · x dismiss · u reopen · tab pane"
657
- : ` diff · ↑↓/jk lines · n/p files · [/ ] hunks · v range${rangeStart === null ? "" : `:${rangeStart}`} · c inline · C change · d layout · w wrap · z whitespace`
658
- : " ctrl+enter save · esc cancel";
659
- const sidebar = wide ? (_jsxs("box", { width: 36, flexShrink: 0, flexDirection: "column", backgroundColor: "transparent", children: [_jsx("box", { border: true, borderStyle: "rounded", borderColor: focus === "files" ? COBALT : RULE, title: ` files · ${files.length} `, titleAlignment: "left", backgroundColor: "transparent", flexGrow: 1, minHeight: 6, children: _jsx("scrollbox", { ref: fileScrollRef, focused: focus === "files", flexGrow: 1, minHeight: 0, style: {
660
- rootOptions: { backgroundColor: "transparent", border: false },
661
- wrapperOptions: { backgroundColor: "transparent" },
662
- viewportOptions: { backgroundColor: "transparent" },
663
- contentOptions: { backgroundColor: "transparent" },
664
- }, 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: "transparent", flexGrow: 1, minHeight: 6, children: _jsx("scrollbox", { ref: commentScrollRef, focused: focus === "comments", flexGrow: 1, minHeight: 0, style: {
665
- rootOptions: { backgroundColor: "transparent", border: false },
666
- wrapperOptions: { backgroundColor: "transparent" },
667
- viewportOptions: { backgroundColor: "transparent" },
668
- contentOptions: { backgroundColor: "transparent" },
669
- }, 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;
670
- return (_jsxs("box", { flexGrow: 1, flexDirection: "column", backgroundColor: "transparent", children: [_jsxs("box", { height: 2, flexShrink: 0, flexDirection: "column", backgroundColor: "transparent", 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: INK, children: "review" }), _jsxs("span", { fg: FAINT, children: [" ", "\u00B7 ", session.harness, " ", session.id.slice(0, 8)] }), isFetching ? _jsx("span", { fg: FAINT, children: " \u00B7 syncing" }) : null, _jsx("span", { fg: checkpointState === "failed" ? MUTED : FAINT, children: checkpointState === "recording"
671
- ? " · checkpointing"
672
- : checkpointState === "failed"
673
- ? " · checkpoint incomplete"
674
- : " · 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: MUTED, 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: "transparent", children: [sidebar, _jsxs("box", { flexGrow: 1, minWidth: 0, minHeight: 0, flexDirection: "column", backgroundColor: "transparent", children: [_jsx("box", { border: true, borderStyle: "rounded", borderColor: focus === "diff" ? COBALT : RULE, title: selectedFile === null
675
- ? " diff "
676
- : ` ${selectedFile.path} · ${view}${selectedFile.likelyGenerated ? " · inferred likely generated" : ""}${showWhitespace ? " · whitespace" : ""}${wrap ? " · wrapped" : ""} `, titleAlignment: "left", backgroundColor: "transparent", 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
677
- ? "The worktree matches its base — nothing to review yet."
678
- : selectedFile?.binary === true
679
- ? "Binary change — text diff unavailable; statistics remain reviewable."
680
- : "This file has no renderable text patch." }) })) : (_jsx("scrollbox", { ref: diffScrollRef, focused: focus === "diff", flexGrow: 1, minHeight: 0, scrollX: true, style: {
681
- rootOptions: { backgroundColor: "transparent", border: false },
682
- wrapperOptions: { backgroundColor: "transparent" },
683
- viewportOptions: { backgroundColor: "transparent" },
684
- contentOptions: { backgroundColor: "transparent" },
685
- }, children: _jsx("diff", { ref: diffRef, diff: displayPatch, view: view, syncScroll: true, ...(selectedFile.filetype === undefined
686
- ? {}
687
- : { 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 ? null : (_jsx(EvidenceCard, { comment: detailComment, width: wide ? width - 38 : width }))] })] }), editor === null ? null : (_jsx(EditorPanel, { editor: editor, refValue: editorRef, busy: busy, onSubmit: () => void submitEditor() })), _jsx("text", { height: 1, fg: status === "" ? FAINT : INK_2, bg: "transparent", children: status === ""
688
- ? ` m read · g suggest · t tour · ,/. tour stops · s draft review${data.followUp?.status === "pending" ? " · y deliver & relaunch" : ""} · o web · r refresh`
689
- : ` ${status}` }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: footer })] }));
690
- }