@sealant/mend 0.4.0 → 0.6.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 +54 -0
- package/dist/dashboard.js +2 -2
- package/dist/doctor.js +233 -0
- package/dist/main.js +386 -142
- package/dist/pair.js +83 -0
- package/dist/review-workflow.js +2 -9
- package/dist/review.js +89 -43
- package/dist/shared.js +151 -5
- package/package.json +3 -1
package/dist/pair.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const paint = (code) => (text) => process.stdout.isTTY === true ? `[${code}m${text}[0m` : text;
|
|
2
|
+
const dim = paint("2");
|
|
3
|
+
const green = paint("32");
|
|
4
|
+
const say = (line) => process.stdout.write(`${line}\n`);
|
|
5
|
+
const fail = (message) => {
|
|
6
|
+
process.stderr.write(`mend: ${message}\n`);
|
|
7
|
+
process.exit(1);
|
|
8
|
+
};
|
|
9
|
+
const takeFlagValue = (args, flag) => {
|
|
10
|
+
const at = args.indexOf(flag);
|
|
11
|
+
return at !== -1 && args[at + 1] !== undefined ? String(args[at + 1]) : null;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The code as a human reads it aloud: 8 Crockford characters in two groups. The
|
|
15
|
+
* server compares case-insensitively with dashes stripped, so the dash is purely
|
|
16
|
+
* for the eye and either form can be typed back.
|
|
17
|
+
*/
|
|
18
|
+
export const groupCode = (code) => {
|
|
19
|
+
const bare = code.replace(/[^0-9a-z]/gi, "").toUpperCase();
|
|
20
|
+
return bare.length <= 4 ? bare : `${bare.slice(0, 4)}-${bare.slice(4)}`;
|
|
21
|
+
};
|
|
22
|
+
/** The scanned string. The device reads the base URL and the code out of it, nothing else. */
|
|
23
|
+
export const pairingLink = (url, code) => `mend://pair?u=${encodeURIComponent(url)}&c=${code}`;
|
|
24
|
+
/**
|
|
25
|
+
* Tailscale hands every node an IPv4 in the CGNAT range 100.64.0.0/10 — the same
|
|
26
|
+
* observation packages/api/src/machine.ts makes. A tailnet URL is preferred because
|
|
27
|
+
* it keeps working when the phone leaves the LAN.
|
|
28
|
+
*/
|
|
29
|
+
const isTailnetUrl = (value) => {
|
|
30
|
+
let host;
|
|
31
|
+
try {
|
|
32
|
+
host = new URL(value).hostname;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
const parts = host.split(".").map(Number);
|
|
38
|
+
const [first, second] = parts;
|
|
39
|
+
if (parts.length !== 4 || first === undefined || second === undefined)
|
|
40
|
+
return false;
|
|
41
|
+
return first === 100 && second >= 64 && second <= 127;
|
|
42
|
+
};
|
|
43
|
+
/** `--url` wins; otherwise the tailnet address, otherwise the first candidate the server offered. */
|
|
44
|
+
export const chooseUrl = (urls, override) => override ?? urls.find(isTailnetUrl) ?? urls[0] ?? null;
|
|
45
|
+
/** Whole minutes left, floored at 0; null when the server sent a date this CLI cannot read. */
|
|
46
|
+
export const minutesUntil = (expiresAt, now = Date.now()) => {
|
|
47
|
+
const at = Date.parse(expiresAt);
|
|
48
|
+
if (Number.isNaN(at))
|
|
49
|
+
return null;
|
|
50
|
+
return Math.max(0, Math.round((at - now) / 60_000));
|
|
51
|
+
};
|
|
52
|
+
/** The QR as block characters. Deliberately not gated on isTTY — a piped QR still scans. */
|
|
53
|
+
export const renderQr = async (text) => {
|
|
54
|
+
const { toString: toQrString } = await import("qrcode");
|
|
55
|
+
const options = { type: "terminal", small: true };
|
|
56
|
+
return (await toQrString(text, options)).trimEnd();
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* `mend pair [--url <base url>]`: mint a pairing code and show it. The device names
|
|
60
|
+
* itself when it claims the code, so this side sends nothing but the request.
|
|
61
|
+
*/
|
|
62
|
+
export const pairCommand = async (args, api) => {
|
|
63
|
+
const pairing = await api("POST", "/me/devices/pairings");
|
|
64
|
+
const url = chooseUrl(pairing.urls, takeFlagValue(args, "--url"));
|
|
65
|
+
if (url === null) {
|
|
66
|
+
return fail("no LAN or tailnet address to hand the device — pass one: mend pair --url http://<host>:<port>");
|
|
67
|
+
}
|
|
68
|
+
const minutes = minutesUntil(pairing.expiresAt);
|
|
69
|
+
say(await renderQr(pairingLink(url, pairing.code)));
|
|
70
|
+
say(`${green("✓")} pairing code ${groupCode(pairing.code)}`);
|
|
71
|
+
say(` ${dim("url")} ${url}`);
|
|
72
|
+
say(` ${dim("expires")} ${minutes === null ? pairing.expiresAt : `in ${minutes} min`} ${dim("· one device, once")}`);
|
|
73
|
+
say("");
|
|
74
|
+
say(dim(" scan it in the Mend app, or enter the url and the code there by hand"));
|
|
75
|
+
say(dim(" the app is apps/mobile — build it yourself; it is not published yet"));
|
|
76
|
+
};
|
|
77
|
+
/** `mend qr <text>`: print a terminal QR of anything. Hidden from help; the installer uses it. */
|
|
78
|
+
export const qrCommand = async (args) => {
|
|
79
|
+
const text = args.join(" ").trim();
|
|
80
|
+
if (text === "")
|
|
81
|
+
return fail("usage: mend qr <text>");
|
|
82
|
+
say(await renderQr(text));
|
|
83
|
+
};
|
package/dist/review-workflow.js
CHANGED
|
@@ -1,15 +1,8 @@
|
|
|
1
|
-
import { CONTINUE_COMMANDS } from "./shared.js";
|
|
2
1
|
export const reviewTargetForSession = (session, annotation, projectName) => annotation?.changeId === null || annotation?.changeId === undefined
|
|
3
2
|
? null
|
|
4
3
|
: { session, changeId: annotation.changeId, projectName };
|
|
5
4
|
export const commentRange = (start, current) => start === null
|
|
6
5
|
? { line: current, endLine: null }
|
|
7
6
|
: { line: Math.min(start, current), endLine: Math.max(start, current) };
|
|
8
|
-
/**
|
|
9
|
-
export const deliverReview =
|
|
10
|
-
const build = CONTINUE_COMMANDS[session.harness];
|
|
11
|
-
if (build === undefined)
|
|
12
|
-
throw new Error(`Harness “${session.harness}” has no known relaunch command`);
|
|
13
|
-
await api("POST", `/sessions/${session.id}/follow-up/deliver`, {});
|
|
14
|
-
await api("POST", `/sessions/${session.id}/launch`, { argv: build(instruction) });
|
|
15
|
-
};
|
|
7
|
+
/** One server-owned operation: persist intent, launch, correlate, finalize. */
|
|
8
|
+
export const deliverReview = (api, sessionId, input) => api("POST", `/sessions/${sessionId}/follow-up/deliver`, input);
|
package/dist/review.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
|
|
2
|
-
import { createHash } from "node:crypto";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { RGBA, SyntaxStyle, } from "@opentui/core";
|
|
4
4
|
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
5
5
|
import { 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 { LIVE_STATUSES } from "./shared.js";
|
|
10
9
|
import { openUrl } from "./terminal.js";
|
|
11
10
|
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
11
|
const REVIEW_KEY = (changeId) => ["review", changeId];
|
|
@@ -27,7 +26,19 @@ const truncate = (text, width) => {
|
|
|
27
26
|
return oneLine.length <= width ? oneLine : `${oneLine.slice(0, width - 1)}…`;
|
|
28
27
|
};
|
|
29
28
|
const fetchReview = async (ctx, changeId) => {
|
|
30
|
-
const
|
|
29
|
+
const opened = await ctx.api("POST", `/changes/${changeId}/reviews/open`, {
|
|
30
|
+
idempotencyKey: `cli-review:${changeId}`,
|
|
31
|
+
});
|
|
32
|
+
const pinned = await ctx.api("GET", `/changes/${changeId}/reviews/${opened.slice.id}/diff`);
|
|
33
|
+
const wire = {
|
|
34
|
+
change: pinned.change,
|
|
35
|
+
diff: pinned.patch,
|
|
36
|
+
files: pinned.files.map((file) => ({
|
|
37
|
+
path: file.newPath ?? file.oldPath ?? "unknown path",
|
|
38
|
+
additions: file.additions,
|
|
39
|
+
deletions: file.deletions,
|
|
40
|
+
})),
|
|
41
|
+
};
|
|
31
42
|
const [comments, tour, passes, followUp, sessionDetail] = await Promise.all([
|
|
32
43
|
ctx.api("GET", `/changes/${changeId}/comments`),
|
|
33
44
|
ctx.api("GET", `/changes/${changeId}/tour`),
|
|
@@ -37,6 +48,11 @@ const fetchReview = async (ctx, changeId) => {
|
|
|
37
48
|
]);
|
|
38
49
|
return {
|
|
39
50
|
wire,
|
|
51
|
+
sliceId: pinned.slice.id,
|
|
52
|
+
checkpointAId: pinned.checkpointA.id,
|
|
53
|
+
checkpointBId: pinned.checkpointB.id,
|
|
54
|
+
diffDigest: pinned.slice.diffDigest,
|
|
55
|
+
anchorFiles: pinned.anchorFiles,
|
|
40
56
|
files: buildReviewFiles(wire.diff, wire.files),
|
|
41
57
|
comments,
|
|
42
58
|
tour,
|
|
@@ -113,14 +129,13 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
|
|
|
113
129
|
const [editor, setEditor] = useState(null);
|
|
114
130
|
const [busy, setBusy] = useState(false);
|
|
115
131
|
const [status, setStatus] = useState("");
|
|
116
|
-
const
|
|
132
|
+
const checkpointState = data === undefined ? (isFetching ? "recording" : "failed") : "recorded";
|
|
117
133
|
const lockRef = useRef(false);
|
|
118
134
|
const editorRef = useRef(null);
|
|
119
135
|
const diffRef = useRef(null);
|
|
120
136
|
const diffScrollRef = useRef(null);
|
|
121
137
|
const fileScrollRef = useRef(null);
|
|
122
138
|
const commentScrollRef = useRef(null);
|
|
123
|
-
const checkpointedRef = useRef(false);
|
|
124
139
|
const files = useMemo(() => {
|
|
125
140
|
const order = {
|
|
126
141
|
added: 0,
|
|
@@ -152,21 +167,6 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
|
|
|
152
167
|
const refresh = () => {
|
|
153
168
|
void queryClient.invalidateQueries({ queryKey: REVIEW_KEY(changeId) });
|
|
154
169
|
};
|
|
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
170
|
useEffect(() => {
|
|
171
171
|
fileScrollRef.current?.scrollTo(Math.max(0, fileIndex * 2 - 2));
|
|
172
172
|
}, [fileIndex]);
|
|
@@ -325,6 +325,8 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
|
|
|
325
325
|
setEditor({
|
|
326
326
|
kind: "send",
|
|
327
327
|
instruction: assembleReviewInstruction(data.wire.change, sendable),
|
|
328
|
+
commentIds: sendable.map((comment) => comment.id),
|
|
329
|
+
idempotencyKey: `cli-follow-up:${data.sliceId}:${randomUUID()}`,
|
|
328
330
|
});
|
|
329
331
|
};
|
|
330
332
|
const submitEditor = async () => {
|
|
@@ -338,17 +340,54 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
|
|
|
338
340
|
setWorking(true);
|
|
339
341
|
try {
|
|
340
342
|
if (editor.kind === "comment") {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
343
|
+
if (data === undefined)
|
|
344
|
+
throw new Error("The pinned Review is not available.");
|
|
345
|
+
const anchorFile = data.anchorFiles.find((file) => (file.newPath ?? file.oldPath) === editor.file);
|
|
346
|
+
const endLine = editor.endLine ?? editor.line;
|
|
347
|
+
const hunk = editor.line === null || endLine === null
|
|
348
|
+
? null
|
|
349
|
+
: (anchorFile?.hunks.find((candidate) => candidate.newLines > 0 &&
|
|
350
|
+
editor.line !== null &&
|
|
351
|
+
editor.line >= candidate.newStart &&
|
|
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
|
+
},
|
|
345
374
|
body: value,
|
|
346
375
|
});
|
|
347
376
|
setStatus(editor.file === null ? "Change comment saved" : "Inline comment saved");
|
|
348
377
|
}
|
|
349
378
|
else {
|
|
350
|
-
|
|
351
|
-
|
|
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");
|
|
352
391
|
}
|
|
353
392
|
setEditor(null);
|
|
354
393
|
refresh();
|
|
@@ -366,16 +405,29 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
|
|
|
366
405
|
void runAction(`${state} ${anchorOf(selectedComment)}`, () => ctx.api("POST", `/changes/${changeId}/comments/${selectedComment.id}/state`, { state }));
|
|
367
406
|
};
|
|
368
407
|
const deliverAndRelaunch = () => {
|
|
369
|
-
|
|
370
|
-
|
|
408
|
+
const followUp = data?.followUp ?? null;
|
|
409
|
+
if (followUp === null || followUp.status === "delivered" || followUp.status === "superseded") {
|
|
410
|
+
setStatus("No retryable follow-up — s assembles the open Review comments first");
|
|
371
411
|
return;
|
|
372
412
|
}
|
|
373
|
-
if (
|
|
374
|
-
|
|
413
|
+
if (followUp.reviewSliceId === null ||
|
|
414
|
+
followUp.checkpointAId === null ||
|
|
415
|
+
followUp.checkpointBId === null ||
|
|
416
|
+
followUp.diffDigest === null ||
|
|
417
|
+
followUp.idempotencyKey === null) {
|
|
418
|
+
setStatus("Legacy follow-up · recreate it from a pinned Review before delivery");
|
|
375
419
|
return;
|
|
376
420
|
}
|
|
377
|
-
const
|
|
378
|
-
|
|
421
|
+
const input = {
|
|
422
|
+
reviewSliceId: followUp.reviewSliceId,
|
|
423
|
+
checkpointAId: followUp.checkpointAId,
|
|
424
|
+
checkpointBId: followUp.checkpointBId,
|
|
425
|
+
diffDigest: followUp.diffDigest,
|
|
426
|
+
commentIds: followUp.commentIds,
|
|
427
|
+
instruction: followUp.instruction,
|
|
428
|
+
idempotencyKey: followUp.idempotencyKey,
|
|
429
|
+
};
|
|
430
|
+
void runAction("Delivering the persisted Review bundle", () => deliverReview(ctx.api, session.id, input), "Delivery reconciled · the same idempotency key names one run");
|
|
379
431
|
};
|
|
380
432
|
const focusOrder = wide
|
|
381
433
|
? ["files", "diff", "comments"]
|
|
@@ -410,12 +462,6 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
|
|
|
410
462
|
setStatus("Refreshing live change…");
|
|
411
463
|
return refresh();
|
|
412
464
|
}
|
|
413
|
-
if (key.name === "k" && key.shift) {
|
|
414
|
-
if (checkpointState !== "failed")
|
|
415
|
-
return;
|
|
416
|
-
recordReviewCheckpoint();
|
|
417
|
-
return;
|
|
418
|
-
}
|
|
419
465
|
if (key.name === "o") {
|
|
420
466
|
openUrl(`${ctx.config.url}/changes/${changeId}`);
|
|
421
467
|
setStatus("Opened web review");
|
|
@@ -518,13 +564,13 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
|
|
|
518
564
|
const displayPatch = selectedFile === null || !showWhitespace
|
|
519
565
|
? (selectedFile?.patch ?? "")
|
|
520
566
|
: visibleWhitespace(selectedFile.patch);
|
|
521
|
-
const footer = editor
|
|
522
|
-
?
|
|
523
|
-
: focus === "files"
|
|
567
|
+
const footer = editor === null
|
|
568
|
+
? focus === "files"
|
|
524
569
|
? " files · ↑↓/jk move · enter review · tab pane · esc back · q quit"
|
|
525
570
|
: focus === "comments"
|
|
526
571
|
? " 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
|
|
572
|
+
: ` diff · ↑↓/jk lines · n/p files · [/ ] hunks · v range${rangeStart === null ? "" : `:${rangeStart}`} · c inline · C change · d layout · w wrap · z whitespace`
|
|
573
|
+
: " ctrl+enter save · esc cancel";
|
|
528
574
|
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
575
|
rootOptions: { backgroundColor: PANEL, border: false },
|
|
530
576
|
wrapperOptions: { backgroundColor: PANEL },
|
|
@@ -553,7 +599,7 @@ export function ReviewScreen({ ctx, projectName, session, changeId, onBack, onQu
|
|
|
553
599
|
contentOptions: { backgroundColor: PANEL },
|
|
554
600
|
}, children: _jsx("diff", { ref: diffRef, diff: displayPatch, view: view, syncScroll: true, ...(selectedFile.filetype === undefined
|
|
555
601
|
? {}
|
|
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
|
|
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 : AMBER, bg: "transparent", children: status === ""
|
|
557
603
|
? ` m read · g suggest · t tour · ,/. tour stops · s draft review${data.followUp?.status === "pending" ? " · y deliver & relaunch" : ""} · o web · r refresh`
|
|
558
604
|
: ` ${status}` }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: footer })] }));
|
|
559
605
|
}
|
package/dist/shared.js
CHANGED
|
@@ -18,17 +18,163 @@ export const CONTINUE_COMMANDS = {
|
|
|
18
18
|
claude: (instruction) => ["claude", instruction],
|
|
19
19
|
opencode: (instruction) => ["opencode", "run", instruction],
|
|
20
20
|
};
|
|
21
|
+
/** Mirror of @mend/domain/workbench harness-launch.ts — the CLI ships dependency-free. */
|
|
22
|
+
export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
23
|
+
const LAUNCH_ERROR = {
|
|
24
|
+
project: null,
|
|
25
|
+
prompt: null,
|
|
26
|
+
model: null,
|
|
27
|
+
effort: null,
|
|
28
|
+
base: null,
|
|
29
|
+
ask: false,
|
|
30
|
+
fast: false,
|
|
31
|
+
custom: [],
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* `mend claude|codex|opencode ["prompt"] [--model <id>] [--effort <level>]
|
|
35
|
+
* [--base <ref>] [--ask] [--project <p>]`, plus `mend run … -- <command...>`.
|
|
36
|
+
* The first non-flag positional is the prompt; a second one is an error so a
|
|
37
|
+
* forgotten quote fails loudly instead of launching with half a sentence.
|
|
38
|
+
*/
|
|
39
|
+
export const parseLaunchArgs = (args) => {
|
|
40
|
+
const dashdash = args.indexOf("--");
|
|
41
|
+
const custom = dashdash === -1 ? [] : args.slice(dashdash + 1);
|
|
42
|
+
const flagArgs = dashdash === -1 ? args : args.slice(0, dashdash);
|
|
43
|
+
let project = null;
|
|
44
|
+
let prompt = null;
|
|
45
|
+
let model = null;
|
|
46
|
+
let effort = null;
|
|
47
|
+
let base = null;
|
|
48
|
+
let ask = false;
|
|
49
|
+
let fast = false;
|
|
50
|
+
for (let index = 0; index < flagArgs.length; index += 1) {
|
|
51
|
+
const arg = flagArgs[index] ?? "";
|
|
52
|
+
if (arg === "--ask") {
|
|
53
|
+
ask = true;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (arg === "--fast") {
|
|
57
|
+
fast = true;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (arg === "--project" || arg === "--model" || arg === "--effort" || arg === "--base") {
|
|
61
|
+
const value = flagArgs[index + 1];
|
|
62
|
+
if (value === undefined)
|
|
63
|
+
return { ...LAUNCH_ERROR, error: `${arg} needs a value` };
|
|
64
|
+
if (arg === "--project")
|
|
65
|
+
project = value;
|
|
66
|
+
else if (arg === "--model")
|
|
67
|
+
model = value;
|
|
68
|
+
else if (arg === "--effort")
|
|
69
|
+
effort = value;
|
|
70
|
+
else
|
|
71
|
+
base = value;
|
|
72
|
+
index += 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (arg.startsWith("-")) {
|
|
76
|
+
// Also catches a prompt starting with "-", which the harness would
|
|
77
|
+
// otherwise read as a flag of its own.
|
|
78
|
+
return {
|
|
79
|
+
...LAUNCH_ERROR,
|
|
80
|
+
error: `unknown flag ${arg} — quote the prompt if it starts with "-"`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (prompt !== null) {
|
|
84
|
+
return {
|
|
85
|
+
...LAUNCH_ERROR,
|
|
86
|
+
error: 'one prompt only — quote it: mend claude "fix the auth test"',
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
prompt = arg;
|
|
90
|
+
}
|
|
91
|
+
if (effort !== null && !EFFORT_LEVELS.includes(effort)) {
|
|
92
|
+
return { ...LAUNCH_ERROR, error: `--effort must be one of ${EFFORT_LEVELS.join(", ")}` };
|
|
93
|
+
}
|
|
94
|
+
return { project, prompt, model, effort, base, ask, fast, custom, error: null };
|
|
95
|
+
};
|
|
21
96
|
export const LIVE_STATUSES = new Set([
|
|
22
97
|
"starting",
|
|
23
98
|
"running",
|
|
24
99
|
"waiting",
|
|
25
100
|
"idle",
|
|
26
101
|
]);
|
|
27
|
-
/**
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Whether the session's AGENT is live. Session status is a fold over every process — a session
|
|
104
|
+
* reads `idle` while a shell holds the workspace after its agent ended — so the agent's own row
|
|
105
|
+
* answers when one exists; `starting` is a launch with no row yet.
|
|
106
|
+
*/
|
|
107
|
+
export const agentIsLive = (session, currentAgent) => currentAgent === null
|
|
108
|
+
? LIVE_STATUSES.has(session.status)
|
|
109
|
+
: session.status === "starting" ||
|
|
110
|
+
(currentAgent.exitedAt === null &&
|
|
111
|
+
(currentAgent.status === "starting" || currentAgent.status === "running"));
|
|
112
|
+
/** What an ended agent process's exit says about the work; null while it runs. */
|
|
113
|
+
export const agentOutcome = (currentAgent) => {
|
|
114
|
+
if (currentAgent === null || currentAgent.exitedAt === null)
|
|
115
|
+
return null;
|
|
116
|
+
if (currentAgent.status === "stopped")
|
|
117
|
+
return "stopped";
|
|
118
|
+
if (currentAgent.harness === "shell")
|
|
119
|
+
return "completed";
|
|
120
|
+
return currentAgent.exitCode === null || currentAgent.exitCode === 0 ? "completed" : "failed";
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* A git remote URL reduced to `host/owner/name` so https, ssh, scp-style, and
|
|
124
|
+
* `.git` spellings of the same repository compare equal. Null for anything
|
|
125
|
+
* that is not a URL-ish remote (a local path stays a path).
|
|
126
|
+
*/
|
|
127
|
+
export const normalizeRemoteUrl = (raw) => {
|
|
128
|
+
if (raw === null)
|
|
129
|
+
return null;
|
|
130
|
+
const trimmed = raw.trim();
|
|
131
|
+
if (trimmed === "")
|
|
132
|
+
return null;
|
|
133
|
+
const scp = /^(?:[^@\s/]+@)?([^:/\s]+):(?!\/)(.+)$/.exec(trimmed);
|
|
134
|
+
const url = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/\s:]+)(?::\d+)?\/(.+)$/i.exec(trimmed);
|
|
135
|
+
const parts = url ?? scp;
|
|
136
|
+
if (parts === null)
|
|
137
|
+
return null;
|
|
138
|
+
const [, host, rest] = parts;
|
|
139
|
+
if (host === undefined || rest === undefined)
|
|
140
|
+
return null;
|
|
141
|
+
const repoPath = rest.replace(/\/+$/, "").replace(/\.git$/i, "");
|
|
142
|
+
return `${host.toLowerCase()}/${repoPath.toLowerCase()}`;
|
|
143
|
+
};
|
|
144
|
+
/**
|
|
145
|
+
* The cwd's adopted project, in order: a project adopted from this very
|
|
146
|
+
* path; a project whose origin is the same remote as the cwd repo's origin
|
|
147
|
+
* (normalized, so a GitHub-adopted project matches a clone of it anywhere);
|
|
148
|
+
* a project named like the repository root (through the same normalization
|
|
149
|
+
* adopt uses, so a checkout called "Mend" matches the project "mend").
|
|
150
|
+
*/
|
|
151
|
+
export const matchProjectByCwd = (projects, facts) => {
|
|
152
|
+
const root = facts.repoRoot ?? facts.cwd;
|
|
153
|
+
const byPath = projects.find((p) => p.originUrl !== null &&
|
|
154
|
+
!p.originUrl.includes("://") &&
|
|
155
|
+
(root === p.originUrl ||
|
|
156
|
+
facts.cwd === p.originUrl ||
|
|
157
|
+
facts.cwd.startsWith(`${p.originUrl}/`)));
|
|
158
|
+
if (byPath !== undefined)
|
|
159
|
+
return byPath;
|
|
160
|
+
const remote = normalizeRemoteUrl(facts.originUrl);
|
|
161
|
+
const byRemote = remote === null ? undefined : projects.find((p) => normalizeRemoteUrl(p.originUrl) === remote);
|
|
162
|
+
if (byRemote !== undefined)
|
|
163
|
+
return byRemote;
|
|
164
|
+
const name = normalizeProjectName(path.basename(root));
|
|
165
|
+
return projects.find((p) => p.name === name);
|
|
166
|
+
};
|
|
167
|
+
/** Read the cwd's facts from git once; callers pass them to the matcher. */
|
|
168
|
+
export const cwdFacts = (cwd) => ({
|
|
169
|
+
cwd,
|
|
170
|
+
repoRoot: gitTopLevel(cwd),
|
|
171
|
+
originUrl: gitOriginUrl(cwd),
|
|
172
|
+
});
|
|
173
|
+
/** The cwd repo's origin remote, or null when there is no repo or no origin. */
|
|
174
|
+
export const gitOriginUrl = (cwd) => {
|
|
175
|
+
const result = spawnSync("git", ["remote", "get-url", "origin"], { cwd, encoding: "utf8" });
|
|
176
|
+
const out = result.status === 0 ? result.stdout.trim() : "";
|
|
177
|
+
return out === "" ? null : out;
|
|
32
178
|
};
|
|
33
179
|
/** The repository root of `cwd`, or null when it is not inside a git repo. */
|
|
34
180
|
export const gitTopLevel = (cwd) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sealant/mend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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",
|
|
@@ -32,11 +32,13 @@
|
|
|
32
32
|
"@opentui/core": "^0.5.1",
|
|
33
33
|
"@opentui/react": "^0.5.1",
|
|
34
34
|
"@tanstack/react-query": "^5.101.4",
|
|
35
|
+
"qrcode": "^1.5.4",
|
|
35
36
|
"react": "19.2.3"
|
|
36
37
|
},
|
|
37
38
|
"devDependencies": {
|
|
38
39
|
"@effect/vitest": "4.0.0-beta.98",
|
|
39
40
|
"@types/node": "^22.10.2",
|
|
41
|
+
"@types/qrcode": "^1.5.5",
|
|
40
42
|
"@types/react": "^19.2.0",
|
|
41
43
|
"typescript": "^5.7.2",
|
|
42
44
|
"vitest": "^3.0.5",
|