@sealant/mend 0.9.0 → 0.10.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.
- package/README.md +17 -9
- package/dist/dashboard.js +397 -282
- package/dist/login.js +240 -0
- package/dist/main.js +106 -129
- package/dist/review.js +172 -87
- package/dist/shared.js +8 -0
- package/dist/tui-theme.js +33 -22
- package/dist/workbench-events.js +71 -0
- package/package.json +1 -1
package/dist/review.js
CHANGED
|
@@ -2,12 +2,13 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/reac
|
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { RGBA, SyntaxStyle, } from "@opentui/core";
|
|
4
4
|
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
5
|
-
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
6
6
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
7
7
|
import { assembleReviewInstruction, buildReviewFiles, visibleWhitespace, } from "./review-model.js";
|
|
8
8
|
import { commentRange, deliverReview } from "./review-workflow.js";
|
|
9
|
+
import { isPendingId, pendingId } from "./shared.js";
|
|
9
10
|
import { openUrl } from "./terminal.js";
|
|
10
|
-
import { ADD_WASH,
|
|
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";
|
|
11
12
|
const REVIEW_KEY = (changeId) => ["review", changeId];
|
|
12
13
|
const EDITOR_KEY_BINDINGS = [{ name: "return", ctrl: true, action: "submit" }];
|
|
13
14
|
const syntaxStyle = SyntaxStyle.fromStyles({
|
|
@@ -66,44 +67,43 @@ const orderedComments = (comments) => comments.toSorted((a, b) => {
|
|
|
66
67
|
const byState = commentPriority(a) - commentPriority(b);
|
|
67
68
|
return byState === 0 ? b.createdAt.localeCompare(a.createdAt) : byState;
|
|
68
69
|
});
|
|
69
|
-
const stateColor = (state) => state === "draft" || state === "open" ?
|
|
70
|
-
const stateGlyph = (state) => (state === "open" ? "●" : "○");
|
|
70
|
+
const stateColor = (state) => state === "draft" || state === "open" ? INK : state === "addressed" ? INK_2 : FAINT;
|
|
71
71
|
const anchorOf = (comment) => comment.file === null
|
|
72
72
|
? "change"
|
|
73
73
|
: `${comment.file}${comment.line === null ? "" : `:${comment.line}${comment.endLine === null || comment.endLine === comment.line ? "" : `-${comment.endLine}`}`}`;
|
|
74
74
|
const passFact = (pass) => {
|
|
75
75
|
const label = pass.kind === "suggest" ? "suggestions" : pass.kind;
|
|
76
76
|
if (pass.status === "running")
|
|
77
|
-
return { text:
|
|
77
|
+
return { text: `${label} running`, color: INK_2 };
|
|
78
78
|
if (pass.status === "failed")
|
|
79
|
-
return { text:
|
|
79
|
+
return { text: `${label} failed`, color: RED };
|
|
80
80
|
return {
|
|
81
|
-
text:
|
|
82
|
-
color:
|
|
81
|
+
text: `${label} observed · ${pass.findings ?? 0} ${pass.kind === "tour" ? "stops" : "drafts"}`,
|
|
82
|
+
color: MUTED,
|
|
83
83
|
};
|
|
84
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:
|
|
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 ? "▌ " : " " }),
|
|
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
87
|
const Description = ({ data, tourIndex, stale, width, height, }) => {
|
|
88
88
|
const passFacts = data.passes.map(passFact);
|
|
89
89
|
const tourStop = data.tour?.stops[tourIndex] ?? null;
|
|
90
90
|
const stopEvidence = tourStop?.evidence[0] ?? null;
|
|
91
|
-
return (_jsxs("box", { height: height, border: true, borderStyle: "rounded", borderColor:
|
|
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
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:
|
|
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
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:
|
|
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
96
|
};
|
|
97
97
|
const CompactTourEvidence = ({ stop, index, total, width, }) => {
|
|
98
98
|
const evidence = stop.evidence[0] ?? null;
|
|
99
|
-
return (_jsxs("box", { height: 5, border: true, borderStyle: "rounded", borderColor:
|
|
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
100
|
};
|
|
101
|
-
const EvidenceCard = ({ comment, width, }) => (_jsxs("box", { height: Math.min(6, 4 + Math.min(2, comment.evidence.length)), border: true, borderStyle: "rounded", borderColor:
|
|
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
102
|
const EditorPanel = ({ editor, refValue, busy, onSubmit, }) => {
|
|
103
103
|
const title = editor.kind === "send"
|
|
104
104
|
? " send review to session · edit before sending "
|
|
105
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:
|
|
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
107
|
};
|
|
108
108
|
const errorMessage = (error) => error instanceof Error ? error.message : error === null ? "" : String(error);
|
|
109
109
|
export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQuit, }) {
|
|
@@ -167,6 +167,68 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
|
|
|
167
167
|
const refresh = () => {
|
|
168
168
|
void queryClient.invalidateQueries({ queryKey: REVIEW_KEY(changeId) });
|
|
169
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
|
+
});
|
|
170
232
|
useEffect(() => {
|
|
171
233
|
fileScrollRef.current?.scrollTo(Math.max(0, fileIndex * 2 - 2));
|
|
172
234
|
}, [fileIndex]);
|
|
@@ -317,7 +379,9 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
|
|
|
317
379
|
const openSendEditor = () => {
|
|
318
380
|
if (data === undefined)
|
|
319
381
|
return;
|
|
320
|
-
|
|
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));
|
|
321
385
|
if (sendable.length === 0) {
|
|
322
386
|
setStatus("No unsent open comments — accept a draft or write a comment first");
|
|
323
387
|
return;
|
|
@@ -337,58 +401,74 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
|
|
|
337
401
|
setStatus("Write something before saving");
|
|
338
402
|
return;
|
|
339
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.
|
|
340
460
|
setWorking(true);
|
|
341
461
|
try {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
endLine <= candidate.newStart + candidate.newLines - 1) ?? null);
|
|
353
|
-
if (editor.file !== null && (anchorFile === undefined || hunk === null)) {
|
|
354
|
-
throw new Error("The selected line is outside the pinned Review anchor.");
|
|
355
|
-
}
|
|
356
|
-
await ctx.api("POST", `/changes/${changeId}/reviews/${data.sliceId}/comments`, {
|
|
357
|
-
target: editor.file === null
|
|
358
|
-
? {
|
|
359
|
-
oldPath: null,
|
|
360
|
-
newPath: null,
|
|
361
|
-
side: null,
|
|
362
|
-
startLine: null,
|
|
363
|
-
endLine: null,
|
|
364
|
-
hunkContextHash: null,
|
|
365
|
-
}
|
|
366
|
-
: {
|
|
367
|
-
oldPath: anchorFile?.oldPath ?? null,
|
|
368
|
-
newPath: anchorFile?.newPath ?? null,
|
|
369
|
-
side: "new",
|
|
370
|
-
startLine: editor.line,
|
|
371
|
-
endLine,
|
|
372
|
-
hunkContextHash: hunk?.contextHash ?? null,
|
|
373
|
-
},
|
|
374
|
-
body: value,
|
|
375
|
-
});
|
|
376
|
-
setStatus(editor.file === null ? "Change comment saved" : "Inline comment saved");
|
|
377
|
-
}
|
|
378
|
-
else {
|
|
379
|
-
if (data === undefined)
|
|
380
|
-
throw new Error("The pinned Review is not available.");
|
|
381
|
-
await deliverReview(ctx.api, session.id, {
|
|
382
|
-
reviewSliceId: data.sliceId,
|
|
383
|
-
checkpointAId: data.checkpointAId,
|
|
384
|
-
checkpointBId: data.checkpointBId,
|
|
385
|
-
diffDigest: data.diffDigest,
|
|
386
|
-
commentIds: editor.commentIds,
|
|
387
|
-
instruction: value,
|
|
388
|
-
idempotencyKey: editor.idempotencyKey,
|
|
389
|
-
});
|
|
390
|
-
setStatus("Follow-up delivery requested · retry keeps the same run");
|
|
391
|
-
}
|
|
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");
|
|
392
472
|
setEditor(null);
|
|
393
473
|
refresh();
|
|
394
474
|
}
|
|
@@ -402,7 +482,12 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
|
|
|
402
482
|
const updateComment = (state) => {
|
|
403
483
|
if (selectedComment === null)
|
|
404
484
|
return;
|
|
405
|
-
|
|
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 });
|
|
406
491
|
};
|
|
407
492
|
const deliverAndRelaunch = () => {
|
|
408
493
|
const followUp = data?.followUp ?? null;
|
|
@@ -548,7 +633,7 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
|
|
|
548
633
|
return setShowWhitespace((current) => !current);
|
|
549
634
|
});
|
|
550
635
|
if (data === undefined) {
|
|
551
|
-
return (_jsx("box", { flexGrow: 1, backgroundColor:
|
|
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
|
|
552
637
|
? "Loading the live change…"
|
|
553
638
|
: `${errorMessage(failureReason)} · retrying` }) }) }));
|
|
554
639
|
}
|
|
@@ -571,35 +656,35 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
|
|
|
571
656
|
? " comments · ↑↓/jk move · enter anchor · a accept/address · x dismiss · u reopen · tab pane"
|
|
572
657
|
: ` diff · ↑↓/jk lines · n/p files · [/ ] hunks · v range${rangeStart === null ? "" : `:${rangeStart}`} · c inline · C change · d layout · w wrap · z whitespace`
|
|
573
658
|
: " ctrl+enter save · esc cancel";
|
|
574
|
-
const sidebar = wide ? (_jsxs("box", { width: 36, flexShrink: 0, flexDirection: "column", backgroundColor:
|
|
575
|
-
rootOptions: { backgroundColor:
|
|
576
|
-
wrapperOptions: { backgroundColor:
|
|
577
|
-
viewportOptions: { backgroundColor:
|
|
578
|
-
contentOptions: { backgroundColor:
|
|
579
|
-
}, 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:
|
|
580
|
-
rootOptions: { backgroundColor:
|
|
581
|
-
wrapperOptions: { backgroundColor:
|
|
582
|
-
viewportOptions: { backgroundColor:
|
|
583
|
-
contentOptions: { backgroundColor:
|
|
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" },
|
|
584
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;
|
|
585
|
-
return (_jsxs("box", { flexGrow: 1, flexDirection: "column", backgroundColor:
|
|
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"
|
|
586
671
|
? " · checkpointing"
|
|
587
672
|
: checkpointState === "failed"
|
|
588
673
|
? " · checkpoint incomplete"
|
|
589
|
-
: " · 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:
|
|
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
|
|
590
675
|
? " diff "
|
|
591
|
-
: ` ${selectedFile.path} · ${view}${selectedFile.likelyGenerated ? " · inferred likely generated" : ""}${showWhitespace ? " · whitespace" : ""}${wrap ? " · wrapped" : ""} `, titleAlignment: "left", backgroundColor:
|
|
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
|
|
592
677
|
? "The worktree matches its base — nothing to review yet."
|
|
593
678
|
: selectedFile?.binary === true
|
|
594
679
|
? "Binary change — text diff unavailable; statistics remain reviewable."
|
|
595
680
|
: "This file has no renderable text patch." }) })) : (_jsx("scrollbox", { ref: diffScrollRef, focused: focus === "diff", flexGrow: 1, minHeight: 0, scrollX: true, style: {
|
|
596
|
-
rootOptions: { backgroundColor:
|
|
597
|
-
wrapperOptions: { backgroundColor:
|
|
598
|
-
viewportOptions: { backgroundColor:
|
|
599
|
-
contentOptions: { backgroundColor:
|
|
681
|
+
rootOptions: { backgroundColor: "transparent", border: false },
|
|
682
|
+
wrapperOptions: { backgroundColor: "transparent" },
|
|
683
|
+
viewportOptions: { backgroundColor: "transparent" },
|
|
684
|
+
contentOptions: { backgroundColor: "transparent" },
|
|
600
685
|
}, children: _jsx("diff", { ref: diffRef, diff: displayPatch, view: view, syncScroll: true, ...(selectedFile.filetype === undefined
|
|
601
686
|
? {}
|
|
602
|
-
: { 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 :
|
|
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 === ""
|
|
603
688
|
? ` m read · g suggest · t tour · ,/. tour stops · s draft review${data.followUp?.status === "pending" ? " · y deliver & relaunch" : ""} · o web · r refresh`
|
|
604
689
|
: ` ${status}` }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: footer })] }));
|
|
605
690
|
}
|
package/dist/shared.js
CHANGED
|
@@ -20,6 +20,14 @@ export const CONTINUE_COMMANDS = {
|
|
|
20
20
|
};
|
|
21
21
|
/** Mirror of @mend/domain/workbench harness-launch.ts — the CLI ships dependency-free. */
|
|
22
22
|
export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
23
|
+
/**
|
|
24
|
+
* Ids of optimistic cache rows the server has not named yet — a session still
|
|
25
|
+
* provisioning, a review comment still saving. Anything that would send such
|
|
26
|
+
* an id to the server checks here first and waits instead.
|
|
27
|
+
*/
|
|
28
|
+
export const PENDING_ID_PREFIX = "pending:";
|
|
29
|
+
export const pendingId = () => `${PENDING_ID_PREFIX}${crypto.randomUUID()}`;
|
|
30
|
+
export const isPendingId = (id) => id.startsWith(PENDING_ID_PREFIX);
|
|
23
31
|
const LAUNCH_ERROR = {
|
|
24
32
|
project: null,
|
|
25
33
|
prompt: null,
|
package/dist/tui-theme.js
CHANGED
|
@@ -1,25 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* The TUI's theme: a drawn interface with NO painted background — the
|
|
3
|
+
* terminal's own ground shows through everything, and the borders alone
|
|
4
|
+
* draw the panes. The only fills left are the ones that state something:
|
|
5
|
+
* the selection row and the diff add/delete washes. Chrome is near-mono;
|
|
6
|
+
* color appears only where it is a fact. Component code imports semantic
|
|
7
|
+
* names from here; this file is the CLI's single color authority.
|
|
6
8
|
*/
|
|
7
|
-
|
|
8
|
-
export const
|
|
9
|
-
|
|
10
|
-
export const INK_2 = "#
|
|
11
|
-
|
|
12
|
-
export const
|
|
13
|
-
|
|
14
|
-
export const
|
|
15
|
-
|
|
16
|
-
export const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
9
|
+
/** Primary text. */
|
|
10
|
+
export const INK = "#eeece8";
|
|
11
|
+
/** Secondary text. */
|
|
12
|
+
export const INK_2 = "#c9c7c2";
|
|
13
|
+
/** Tertiary text — labels, meta. */
|
|
14
|
+
export const MUTED = "#a3a09a";
|
|
15
|
+
/** The quietest legible step; anything darker reads as broken. */
|
|
16
|
+
export const FAINT = "#807d76";
|
|
17
|
+
/** Hairlines and panel borders — visible, not vanishing. */
|
|
18
|
+
export const RULE = "#37374a";
|
|
19
|
+
/**
|
|
20
|
+
* Interaction and selection — the ONE accent, and the only place chrome is
|
|
21
|
+
* allowed color: the focused pane border and the selection gutter bar.
|
|
22
|
+
*/
|
|
23
|
+
export const COBALT = "#6f96f5";
|
|
24
|
+
/** The selection wash: a neutral lifted row — visible, not colorful. */
|
|
25
|
+
export const WASH = "#2c2c35";
|
|
26
|
+
export const AMBER = "#e2ab42";
|
|
27
|
+
export const GREEN = "#69c189";
|
|
28
|
+
export const RED = "#e87f76";
|
|
29
|
+
/** Diff line washes — strong enough to scan, never louder than the ink. */
|
|
30
|
+
export const ADD_WASH = "#20362a";
|
|
31
|
+
export const DELETE_WASH = "#3c2b2e";
|
|
21
32
|
/** Syntax colors are centralized too; they are subordinate to semantic UI color. */
|
|
22
|
-
export const SYNTAX_KEYWORD = "#
|
|
23
|
-
export const SYNTAX_STRING = "#
|
|
24
|
-
export const SYNTAX_NUMBER = "#
|
|
25
|
-
export const SYNTAX_FUNCTION = "#
|
|
33
|
+
export const SYNTAX_KEYWORD = "#cda6f2";
|
|
34
|
+
export const SYNTAX_STRING = "#98cfa8";
|
|
35
|
+
export const SYNTAX_NUMBER = "#e6c37e";
|
|
36
|
+
export const SYNTAX_FUNCTION = "#94bdf7";
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dashboard's side of `/api/events` (the same SSE stream the web app
|
|
3
|
+
* reads): parse frames out of the byte stream, drop the `: ping` heartbeats,
|
|
4
|
+
* and map each pointer event to the query families it can actually stale —
|
|
5
|
+
* mirroring apps/web/src/lib/workbench-events.ts, reduced to the TUI's two
|
|
6
|
+
* cache families. Pure functions; dashboard.tsx owns the socket and the
|
|
7
|
+
* QueryClient.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* An incremental SSE frame splitter: feed decoded text chunks, get back the
|
|
11
|
+
* `data:` payloads of every frame completed so far. Comment-only frames (the
|
|
12
|
+
* heartbeat) produce nothing.
|
|
13
|
+
*/
|
|
14
|
+
export const createSseParser = () => {
|
|
15
|
+
let buffer = "";
|
|
16
|
+
return {
|
|
17
|
+
push: (chunk) => {
|
|
18
|
+
buffer += chunk;
|
|
19
|
+
const payloads = [];
|
|
20
|
+
for (;;) {
|
|
21
|
+
const boundary = buffer.indexOf("\n\n");
|
|
22
|
+
if (boundary === -1)
|
|
23
|
+
break;
|
|
24
|
+
const frame = buffer.slice(0, boundary);
|
|
25
|
+
buffer = buffer.slice(boundary + 2);
|
|
26
|
+
const data = frame
|
|
27
|
+
.split("\n")
|
|
28
|
+
.filter((line) => line.startsWith("data:"))
|
|
29
|
+
.map((line) => line.slice("data:".length).replace(/^ /, ""))
|
|
30
|
+
.join("\n");
|
|
31
|
+
if (data !== "")
|
|
32
|
+
payloads.push(data);
|
|
33
|
+
}
|
|
34
|
+
return payloads;
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Which families one event payload stales. `session-progress` is deliberately
|
|
40
|
+
* nothing — it fires per record line while an agent works, and the dashboard
|
|
41
|
+
* renders no live lines; treating it as a pointer refetched the entire
|
|
42
|
+
* workbench on a 250ms loop for as long as a session was busy. Unknown types
|
|
43
|
+
* are also nothing, like the web: every state change the TUI renders emits
|
|
44
|
+
* one of the types below, and `r` remains the manual override.
|
|
45
|
+
*/
|
|
46
|
+
export const eventFamilies = (payload) => {
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(payload);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
if (typeof parsed !== "object" || parsed === null || !("type" in parsed))
|
|
55
|
+
return [];
|
|
56
|
+
switch (parsed.type) {
|
|
57
|
+
case "project":
|
|
58
|
+
case "session-process":
|
|
59
|
+
return ["workbench"];
|
|
60
|
+
// Session facts feed both surfaces: rows and annotations on the
|
|
61
|
+
// dashboard; status and the follow-up banner inside a review.
|
|
62
|
+
case "session":
|
|
63
|
+
case "agent-conversation":
|
|
64
|
+
// A comment moves the review AND the dashboard's open-comment counts.
|
|
65
|
+
case "session-change":
|
|
66
|
+
case "review-comment":
|
|
67
|
+
return ["workbench", "review"];
|
|
68
|
+
default:
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sealant/mend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "The mend CLI: adopt a repository into Mend's store, launch your coding agent in a recorded session worktree, detach and reattach from any terminal",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|