@sealant/mend 0.13.0 → 0.14.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/dist/dashboard.js +104 -20
- package/dist/main.js +43 -8
- package/dist/shared.js +33 -1
- package/package.json +1 -1
package/dist/dashboard.js
CHANGED
|
@@ -5,7 +5,7 @@ import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient
|
|
|
5
5
|
import { useEffect, useRef, useState } from "react";
|
|
6
6
|
import { reviewTargetForSession } from "./review-workflow.js";
|
|
7
7
|
import { ReviewScreen } from "./review.js";
|
|
8
|
-
import { cwdFacts, HARNESS_COMMANDS, isPendingId, LIVE_STATUSES, matchProjectByCwd, pendingId, } from "./shared.js";
|
|
8
|
+
import { cwdFacts, HARNESS_COMMANDS, isPendingId, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, pendingId, } from "./shared.js";
|
|
9
9
|
import { openUrl } from "./terminal.js";
|
|
10
10
|
import { COBALT, FAINT, INK, INK_2, MUTED, RED, RULE, WASH } from "./tui-theme.js";
|
|
11
11
|
import { createSseParser, eventFamilies } from "./workbench-events.js";
|
|
@@ -138,11 +138,23 @@ const paneScrollStyle = {
|
|
|
138
138
|
contentOptions: { backgroundColor: "transparent" },
|
|
139
139
|
};
|
|
140
140
|
const ProjectRow = ({ item, selected, nameWidth, }) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { fg: INK, children: item.project.name.padEnd(nameWidth) }), _jsx("span", { fg: item.total === 0 ? FAINT : MUTED, children: ` ${item.total}` }), item.live > 0 ? _jsx("span", { fg: MUTED, children: ` · ${item.live} live` }) : null, item.open > 0 ? _jsx("span", { fg: MUTED, children: ` · ${item.open}` }) : null] }) }));
|
|
141
|
+
/**
|
|
142
|
+
* The worktree IS the session's identity (one worktree per session): its
|
|
143
|
+
* branch, with the noisy default prefix receded. What you are working on
|
|
144
|
+
* leads every row; the harness is a fact about it, not its name.
|
|
145
|
+
*/
|
|
146
|
+
const worktreeName = (branch) => branch.startsWith("mend/") ? branch.slice("mend/".length) : branch;
|
|
147
|
+
/**
|
|
148
|
+
* One worktree, grouped: the session line leads with the worktree name, and
|
|
149
|
+
* everything live inside it — its Services — hangs underneath. The child
|
|
150
|
+
* rows are facts, not targets: selection stays on the worktree line.
|
|
151
|
+
*/
|
|
141
152
|
const SessionRow = ({ item, selected, }) => {
|
|
142
|
-
const { session, annotation } = item;
|
|
153
|
+
const { session, annotation, services } = item;
|
|
143
154
|
const color = STATUS_COLOR[session.status] ?? MUTED;
|
|
144
155
|
const age = timeAgo(session.createdAt).replace(" ago", "");
|
|
145
|
-
|
|
156
|
+
const worktree = worktreeName(session.branch);
|
|
157
|
+
return (_jsxs("box", { flexShrink: 0, flexDirection: "column", backgroundColor: "transparent", children: [_jsx("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { fg: INK, children: worktree.slice(0, 30).padEnd(31) }), _jsx("span", { fg: color, children: session.status.padEnd(10) }), _jsx("span", { fg: FAINT, children: age.padEnd(9) }), _jsx("span", { fg: MUTED, children: session.harness.padEnd(10) }), annotation !== undefined && annotation.openComments > 0 ? (_jsx("span", { fg: MUTED, children: `${annotation.openComments} open ` })) : null, _jsx("span", { fg: FAINT, children: (session.label ?? "").slice(0, 40) })] }) }), services.map((service) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: FAINT, children: " └ " }), _jsx("span", { fg: service.status === "reachable" ? INK_2 : MUTED, children: `${service.label ?? service.id.slice(0, 6)} :${service.workspacePort ?? "?"}${service.protocol === "udp" ? "u" : ""}→${service.hostPort ?? "?"}` }), _jsx("span", { fg: FAINT, children: ` ${service.status}` })] }) }, service.id)))] }));
|
|
146
158
|
};
|
|
147
159
|
const HarnessRow = ({ item, selected, }) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { fg: INK, children: item.label.padEnd(12) }), _jsx("span", { fg: FAINT, children: item.hint })] }) }));
|
|
148
160
|
/**
|
|
@@ -183,15 +195,16 @@ const StatusLine = ({ busy, busyStarted, status, }) => {
|
|
|
183
195
|
};
|
|
184
196
|
const errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
185
197
|
/** Keep a 1-line-per-row selection inside its scrollbox's real viewport. */
|
|
186
|
-
const
|
|
198
|
+
const keepSpanVisible = (scroll, top, height) => {
|
|
187
199
|
if (scroll === null)
|
|
188
200
|
return;
|
|
189
201
|
const viewH = Math.max(1, scroll.viewport.height);
|
|
190
|
-
if (
|
|
191
|
-
scroll.scrollTo(
|
|
192
|
-
else if (
|
|
193
|
-
scroll.scrollTo(
|
|
202
|
+
if (top < scroll.scrollTop)
|
|
203
|
+
scroll.scrollTo(top);
|
|
204
|
+
else if (top + height > scroll.scrollTop + viewH)
|
|
205
|
+
scroll.scrollTo(top + height - viewH);
|
|
194
206
|
};
|
|
207
|
+
const keepRowVisible = (scroll, index) => keepSpanVisible(scroll, index, 1);
|
|
195
208
|
const PROJECTS_PANE_WIDTH = 30;
|
|
196
209
|
const App = ({ ctx, onQuit }) => {
|
|
197
210
|
const renderer = useRenderer();
|
|
@@ -212,6 +225,11 @@ const App = ({ ctx, onQuit }) => {
|
|
|
212
225
|
const [busyStarted, setBusyStarted] = useState(0);
|
|
213
226
|
const [status, setStatus] = useState(null);
|
|
214
227
|
const [editing, setEditing] = useState(null);
|
|
228
|
+
/** Session id a stop is armed against; the second press fires it. */
|
|
229
|
+
const [stopArmed, setStopArmed] = useState(null);
|
|
230
|
+
/** The new-session flow asks the worktree's name FIRST, then the harness. */
|
|
231
|
+
const [naming, setNaming] = useState(null);
|
|
232
|
+
const [pendingName, setPendingName] = useState(null);
|
|
215
233
|
const [reviewing, setReviewing] = useState(null);
|
|
216
234
|
/** Set synchronously around attach/launch so keystrokes can't double-fire. */
|
|
217
235
|
const lockRef = useRef(false);
|
|
@@ -246,8 +264,17 @@ const App = ({ ctx, onQuit }) => {
|
|
|
246
264
|
keepRowVisible(projectScrollRef.current, projectIndex);
|
|
247
265
|
}, [projectIndex]);
|
|
248
266
|
useEffect(() => {
|
|
249
|
-
|
|
250
|
-
|
|
267
|
+
// Rows are worktree groups (a session line plus its service children), so
|
|
268
|
+
// the scroll target is the group's y offset, not its index.
|
|
269
|
+
let top = 0;
|
|
270
|
+
for (const [index, item] of sessionItems.entries()) {
|
|
271
|
+
if (index === sessionIndex)
|
|
272
|
+
break;
|
|
273
|
+
top += 1 + item.services.length;
|
|
274
|
+
}
|
|
275
|
+
const selectedHeight = 1 + (sessionItems[sessionIndex]?.services.length ?? 0);
|
|
276
|
+
keepSpanVisible(sessionScrollRef.current, top, selectedHeight);
|
|
277
|
+
}, [sessionIndex, sessionItems]);
|
|
251
278
|
const attachFlow = async (session) => {
|
|
252
279
|
const short = session.id.slice(0, 8);
|
|
253
280
|
lockRef.current = true;
|
|
@@ -313,6 +340,7 @@ const App = ({ ctx, onQuit }) => {
|
|
|
313
340
|
const session = await ctx.api("POST", `/projects/${vars.projectId}/sessions`, {
|
|
314
341
|
harness: vars.harness,
|
|
315
342
|
label: null,
|
|
343
|
+
name: vars.name,
|
|
316
344
|
base: null,
|
|
317
345
|
});
|
|
318
346
|
await ctx.api("POST", `/sessions/${session.id}/launch`, { argv });
|
|
@@ -324,7 +352,7 @@ const App = ({ ctx, onQuit }) => {
|
|
|
324
352
|
id: vars.pendingKey,
|
|
325
353
|
harness: vars.harness,
|
|
326
354
|
label: null,
|
|
327
|
-
branch: "provisioning…"
|
|
355
|
+
branch: vars.name === null ? "provisioning…" : `mend/${vars.name}`,
|
|
328
356
|
baseSha: "",
|
|
329
357
|
baseRef: null,
|
|
330
358
|
status: "starting",
|
|
@@ -389,9 +417,48 @@ const App = ({ ctx, onQuit }) => {
|
|
|
389
417
|
},
|
|
390
418
|
onSettled: settleRefetch,
|
|
391
419
|
});
|
|
420
|
+
// Stops are explicit — and armed: the first press names what a second press
|
|
421
|
+
// will stop; moving the selection re-arms against the newly selected row.
|
|
422
|
+
const stopMutation = useMutation({
|
|
423
|
+
mutationFn: (session) => ctx.api("POST", `/sessions/${session.id}/stop`),
|
|
424
|
+
onMutate: async (session) => {
|
|
425
|
+
await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
|
|
426
|
+
patchWorkbench((current) => mapWorkbenchSessions(current, (row) => row.id === session.id ? { ...row, status: "stopped" } : row));
|
|
427
|
+
say(`stopped · ${worktreeName(session.branch)} — the record and review remain`);
|
|
428
|
+
},
|
|
429
|
+
onError: (error) => {
|
|
430
|
+
say(errorText(error));
|
|
431
|
+
refetch();
|
|
432
|
+
},
|
|
433
|
+
onSettled: settleRefetch,
|
|
434
|
+
});
|
|
435
|
+
const armStop = () => {
|
|
436
|
+
const item = selectedSession;
|
|
437
|
+
if (item === null || isPendingId(item.session.id))
|
|
438
|
+
return;
|
|
439
|
+
if (!LIVE_STATUSES.has(item.session.status)) {
|
|
440
|
+
say("nothing to stop — the session is settled");
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (stopArmed === item.session.id) {
|
|
444
|
+
setStopArmed(null);
|
|
445
|
+
stopMutation.mutate(item.session);
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
setStopArmed(item.session.id);
|
|
449
|
+
say(`press again to stop · ${worktreeName(item.session.branch)}`);
|
|
450
|
+
};
|
|
392
451
|
const startSession = (projectId, harness) => {
|
|
393
452
|
setFocus("sessions");
|
|
394
|
-
launchMutation.mutate({ projectId, harness, pendingKey: pendingId() });
|
|
453
|
+
launchMutation.mutate({ projectId, harness, name: pendingName, pendingKey: pendingId() });
|
|
454
|
+
setPendingName(null);
|
|
455
|
+
};
|
|
456
|
+
/** Name first, then the harness: the worktree is the identity being created. */
|
|
457
|
+
const submitWorktreeName = (value) => {
|
|
458
|
+
setNaming(null);
|
|
459
|
+
const slug = normalizeProjectName(value.trim());
|
|
460
|
+
setPendingName(value.trim() === "" ? null : slug);
|
|
461
|
+
openPicker(null);
|
|
395
462
|
};
|
|
396
463
|
const resumeSession = (projectId, session, harness) => {
|
|
397
464
|
setFocus("sessions");
|
|
@@ -475,6 +542,11 @@ const App = ({ ctx, onQuit }) => {
|
|
|
475
542
|
setEditing(null);
|
|
476
543
|
return;
|
|
477
544
|
}
|
|
545
|
+
if (naming !== null) {
|
|
546
|
+
if (key.name === "escape")
|
|
547
|
+
setNaming(null);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
478
550
|
if (picker !== null) {
|
|
479
551
|
switch (key.name) {
|
|
480
552
|
case "down":
|
|
@@ -495,6 +567,10 @@ const App = ({ ctx, onQuit }) => {
|
|
|
495
567
|
return;
|
|
496
568
|
}
|
|
497
569
|
}
|
|
570
|
+
// Shift+K (and x below): stop the selected session. Lowercase k stays
|
|
571
|
+
// vim-up; the shift is the deliberateness the arm-confirm then doubles.
|
|
572
|
+
if (key.shift === true && key.name === "k")
|
|
573
|
+
return armStop();
|
|
498
574
|
switch (key.name) {
|
|
499
575
|
case "q":
|
|
500
576
|
return onQuit();
|
|
@@ -504,6 +580,8 @@ const App = ({ ctx, onQuit }) => {
|
|
|
504
580
|
case "up":
|
|
505
581
|
case "k":
|
|
506
582
|
return moveSelection(-1);
|
|
583
|
+
case "x":
|
|
584
|
+
return armStop();
|
|
507
585
|
case "return":
|
|
508
586
|
case "linefeed":
|
|
509
587
|
return activate();
|
|
@@ -524,8 +602,9 @@ const App = ({ ctx, onQuit }) => {
|
|
|
524
602
|
setFocus(focus === "projects" ? "sessions" : "projects");
|
|
525
603
|
return;
|
|
526
604
|
case "n":
|
|
605
|
+
// The worktree's name comes first; the harness picker follows.
|
|
527
606
|
if (selectedProject !== null)
|
|
528
|
-
|
|
607
|
+
setNaming({ projectId: selectedProject.project.id });
|
|
529
608
|
return;
|
|
530
609
|
case "e": {
|
|
531
610
|
const item = selectedSession;
|
|
@@ -573,7 +652,7 @@ const App = ({ ctx, onQuit }) => {
|
|
|
573
652
|
: `sessions — ${selectedProject.project.name}${selectedProject.live > 0 ? ` · ${selectedProject.live} live` : ""}`;
|
|
574
653
|
const detailTitle = selectedSession === null
|
|
575
654
|
? "session"
|
|
576
|
-
: `
|
|
655
|
+
: `worktree — ${worktreeName(selectedSession.session.branch)} · ${selectedSession.session.harness} ${selectedSession.session.id.slice(0, 8)}`;
|
|
577
656
|
const pickerTitle = picker === null
|
|
578
657
|
? ""
|
|
579
658
|
: picker.session === null
|
|
@@ -581,11 +660,13 @@ const App = ({ ctx, onQuit }) => {
|
|
|
581
660
|
: `resume ${picker.session.id.slice(0, 8)} — pick a harness`;
|
|
582
661
|
const footerText = editing !== null
|
|
583
662
|
? " enter save · esc cancel"
|
|
584
|
-
:
|
|
585
|
-
? "
|
|
586
|
-
:
|
|
587
|
-
? " ↑↓ move · enter
|
|
588
|
-
:
|
|
663
|
+
: naming !== null
|
|
664
|
+
? " enter continue — the harness picker follows · esc cancel"
|
|
665
|
+
: picker !== null
|
|
666
|
+
? " ↑↓ move · enter start · esc cancel"
|
|
667
|
+
: focus === "projects"
|
|
668
|
+
? " ↑↓ move · enter/l sessions · ⇥ panes · r refresh · q quit"
|
|
669
|
+
: " ↑↓ move · enter attach/resume · n new · x stop · v review · e rename · o web · h/⇥ projects · q quit";
|
|
589
670
|
const loadFailure = data === undefined && failureReason !== null
|
|
590
671
|
? failureReason instanceof Error
|
|
591
672
|
? failureReason.message
|
|
@@ -597,7 +678,10 @@ const App = ({ ctx, onQuit }) => {
|
|
|
597
678
|
refetch();
|
|
598
679
|
}, onQuit: onQuit }));
|
|
599
680
|
}
|
|
600
|
-
return (_jsxs("box", { flexGrow: 1, flexDirection: "column", backgroundColor: "transparent", children: [_jsxs("box", { height: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: INK, children: " mend" }), _jsxs("span", { fg: MUTED, children: [" ", projectItems.length, " project", projectItems.length === 1 ? "" : "s"] }), _jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsxs("span", { fg: liveTotal > 0 ? MUTED : FAINT, children: [liveTotal, " live"] })] }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: `${ctx.config.url} ` })] }), _jsxs("box", { flexGrow: 1, flexShrink: 1, minHeight: 0, flexDirection: "row", children: [showProjectsPane ? (_jsx(Pane, { title: "projects", focused: focus === "projects", width: PROJECTS_PANE_WIDTH, children: _jsxs("scrollbox", { ref: projectScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [projectItems.map((item, index) => (_jsx(ProjectRow, { item: item, selected: index === projectIndex, nameWidth: nameWidth }, item.project.id))), data !== undefined && projectItems.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " none adopted yet" })) : null] }) })) : null, _jsx(Pane, { title: sessionsTitle, focused: focus === "sessions", grow: true, children: _jsxs("scrollbox", { ref: sessionScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [sessionItems.map((item, index) => (_jsx(SessionRow, { item: item, selected: index === sessionIndex }, item.session.id))), data !== undefined && sessionItems.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " no sessions yet — n starts one" })) : null, loadFailure !== null ? (_jsx("text", { height: 1, fg: INK_2, bg: "transparent", children: ` ${loadFailure} — retrying` })) : null] }) })] }), picker !== null ? (_jsx(Pane, { title: pickerTitle, focused: true, height: 2 + pickerItems.length, children: pickerItems.map((item, index) => (_jsx(HarnessRow, { item: item, selected: index === pickerIndex }, String(item.harness)))) })) :
|
|
681
|
+
return (_jsxs("box", { flexGrow: 1, flexDirection: "column", backgroundColor: "transparent", children: [_jsxs("box", { height: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: INK, children: " mend" }), _jsxs("span", { fg: MUTED, children: [" ", projectItems.length, " project", projectItems.length === 1 ? "" : "s"] }), _jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsxs("span", { fg: liveTotal > 0 ? MUTED : FAINT, children: [liveTotal, " live"] })] }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: `${ctx.config.url} ` })] }), _jsxs("box", { flexGrow: 1, flexShrink: 1, minHeight: 0, flexDirection: "row", children: [showProjectsPane ? (_jsx(Pane, { title: "projects", focused: focus === "projects", width: PROJECTS_PANE_WIDTH, children: _jsxs("scrollbox", { ref: projectScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [projectItems.map((item, index) => (_jsx(ProjectRow, { item: item, selected: index === projectIndex, nameWidth: nameWidth }, item.project.id))), data !== undefined && projectItems.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " none adopted yet" })) : null] }) })) : null, _jsx(Pane, { title: sessionsTitle, focused: focus === "sessions", grow: true, children: _jsxs("scrollbox", { ref: sessionScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [sessionItems.map((item, index) => (_jsx(SessionRow, { item: item, selected: index === sessionIndex }, item.session.id))), data !== undefined && sessionItems.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " no sessions yet — n starts one" })) : null, loadFailure !== null ? (_jsx("text", { height: 1, fg: INK_2, bg: "transparent", children: ` ${loadFailure} — retrying` })) : null] }) })] }), picker !== null ? (_jsx(Pane, { title: pickerTitle, focused: true, height: 2 + pickerItems.length, children: pickerItems.map((item, index) => (_jsx(HarnessRow, { item: item, selected: index === pickerIndex }, String(item.harness)))) })) : naming !== null ? (_jsx("box", { border: true, borderStyle: "rounded", borderColor: COBALT, title: " new worktree \u2014 name ", titleAlignment: "left", backgroundColor: "transparent", height: 3, flexShrink: 0, children: _jsx("input", { focused: true, value: "", placeholder: "name the worktree, e.g. fix-auth (empty = auto)", backgroundColor: "transparent", focusedBackgroundColor: "transparent", textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onSubmit: (value) => {
|
|
682
|
+
if (typeof value === "string")
|
|
683
|
+
submitWorktreeName(value);
|
|
684
|
+
} }) })) : editing !== null ? (_jsx("box", { border: true, borderStyle: "rounded", borderColor: COBALT, title: ` label — ${editing.harness} ${editing.id.slice(0, 8)} `, titleAlignment: "left", backgroundColor: "transparent", height: 3, flexShrink: 0, children: _jsx("input", { focused: true, value: editing.label ?? "", placeholder: "a few words for what this session is doing (empty clears)", backgroundColor: "transparent", focusedBackgroundColor: "transparent", textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onSubmit: (value) => {
|
|
601
685
|
if (typeof value === "string")
|
|
602
686
|
submitRename(editing, value);
|
|
603
687
|
} }) })) : showDetail ? (_jsx(Pane, { title: detailTitle, focused: false, height: 6, children: _jsx(SessionDetail, { item: selectedSession }) })) : null, _jsx(StatusLine, { busy: busy, busyStarted: busyStarted, status: status }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: footerText })] }));
|
package/dist/main.js
CHANGED
|
@@ -11,7 +11,7 @@ import { formatLoadReport } from "./env.js";
|
|
|
11
11
|
import { loginCommand } from "./login.js";
|
|
12
12
|
import { pairCommand, qrCommand } from "./pair.js";
|
|
13
13
|
import { isComposeFile, proposeFromCompose, proposeFromPackageJson, proposeFromWorkspacePackage, renderMendToml, workspaceGlobs, } from "./service-init.js";
|
|
14
|
-
import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, parseLaunchArgs, } from "./shared.js";
|
|
14
|
+
import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, isDetachChunk, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, parseLaunchArgs, } from "./shared.js";
|
|
15
15
|
import { sshCommand } from "./ssh-setup.js";
|
|
16
16
|
// `$XDG_CONFIG_HOME/mend`, default `~/.config/mend`; a pre-XDG `~/.mend` stays authoritative
|
|
17
17
|
// when it is the only one present (mirrors @mend/store's resolver — the CLI stays dependency-light).
|
|
@@ -245,6 +245,20 @@ const resolvedBackgroundSessions = async (config, project) => {
|
|
|
245
245
|
return true;
|
|
246
246
|
}
|
|
247
247
|
};
|
|
248
|
+
/** One-line TTY ask: the worktree's name comes first — it is the identity being created. */
|
|
249
|
+
const askWorktreeName = async () => {
|
|
250
|
+
if (process.stdin.isTTY !== true || process.stdout.isTTY !== true)
|
|
251
|
+
return null;
|
|
252
|
+
const readline = await import("node:readline/promises");
|
|
253
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
254
|
+
try {
|
|
255
|
+
const answer = (await rl.question(` worktree name ${dim("(enter for auto)")}: `)).trim();
|
|
256
|
+
return answer === "" ? null : normalizeProjectName(answer);
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
rl.close();
|
|
260
|
+
}
|
|
261
|
+
};
|
|
248
262
|
const launch = async (config, harness, args) => {
|
|
249
263
|
const parsed = parseLaunchArgs(args);
|
|
250
264
|
if (parsed.error !== null)
|
|
@@ -268,6 +282,9 @@ const launch = async (config, harness, args) => {
|
|
|
268
282
|
// Say which project the cwd resolved to before anything is created — a
|
|
269
283
|
// wrong guess should be visible here, not discovered in the tree later.
|
|
270
284
|
say(`${green("✓")} project ${project.name} ${dim(`· ${project.defaultBranch}${parsed.project === null ? " · from cwd" : ""}`)}`);
|
|
285
|
+
// The worktree's name comes first, then the session details — it is the
|
|
286
|
+
// identity every list leads with. `mend run` stays scriptable: flag only.
|
|
287
|
+
const worktreeName = harness === "run" ? parsed.name : (parsed.name ?? (await askWorktreeName()));
|
|
271
288
|
const lifecycle = harness === "run"
|
|
272
289
|
? "background"
|
|
273
290
|
: parsed.detach
|
|
@@ -297,6 +314,7 @@ const launch = async (config, harness, args) => {
|
|
|
297
314
|
const session = await api(config, "POST", `/projects/${project.id}/sessions`, {
|
|
298
315
|
harness,
|
|
299
316
|
label: null,
|
|
317
|
+
name: worktreeName,
|
|
300
318
|
base: parsed.base,
|
|
301
319
|
});
|
|
302
320
|
createdSessionId = session.id;
|
|
@@ -423,8 +441,10 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
|
|
|
423
441
|
}
|
|
424
442
|
};
|
|
425
443
|
const onKeys = (data) => {
|
|
426
|
-
if (detachKeyEnabled && data
|
|
427
|
-
// Ctrl+] — detach, leave the session running.
|
|
444
|
+
if (detachKeyEnabled && isDetachChunk(data)) {
|
|
445
|
+
// Ctrl+] — detach, leave the session running. Matched in both its
|
|
446
|
+
// encodings: the inner TUI may have switched the user's terminal onto
|
|
447
|
+
// the kitty keyboard protocol, where the key arrives as CSI-u.
|
|
428
448
|
detached = true;
|
|
429
449
|
ws.close();
|
|
430
450
|
return;
|
|
@@ -463,6 +483,19 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
|
|
|
463
483
|
ws.removeEventListener("close", onClose);
|
|
464
484
|
if (rawModeEnabled)
|
|
465
485
|
process.stdin.setRawMode(false);
|
|
486
|
+
if (options?.readOnly !== true && process.stdout.isTTY === true) {
|
|
487
|
+
// The inner TUI's terminal modes leaked onto OUR terminal through the
|
|
488
|
+
// byte bridge — kitty keyboard protocol, bracketed paste, mouse
|
|
489
|
+
// reporting, alternate screen, hidden cursor. The TUI keeps running
|
|
490
|
+
// remotely; the local terminal must come back to shell sanity, or every
|
|
491
|
+
// keystroke after a detach arrives as CSI-u junk. A reattach replays
|
|
492
|
+
// the session from 0, which re-establishes whatever the TUI had set.
|
|
493
|
+
process.stdout.write("\x1b[<u\x1b[=0;1u" + // pop the kitty keyboard stack, then force flags 0
|
|
494
|
+
"\x1b[?2004l" + // bracketed paste off
|
|
495
|
+
"\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l" + // mouse reporting off
|
|
496
|
+
"\x1b[?1049l" + // leave the alternate screen (no-op when already left)
|
|
497
|
+
"\x1b[?25h");
|
|
498
|
+
}
|
|
466
499
|
process.stdin.pause();
|
|
467
500
|
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)
|
|
468
501
|
ws.close();
|
|
@@ -560,7 +593,7 @@ const attach = async (config, args) => {
|
|
|
560
593
|
const match = sessions.find((s) => s.id.startsWith(prefix));
|
|
561
594
|
if (match === undefined)
|
|
562
595
|
return fail(`no active session matches "${prefix}"`);
|
|
563
|
-
say(`${green("✓")} attaching to ${match.
|
|
596
|
+
say(`${green("✓")} attaching to ${match.branch} · ${match.harness} ${dim(match.id.slice(0, 8))}${detachHint()}`);
|
|
564
597
|
say("");
|
|
565
598
|
await attachOrExit(config, match.id, match.harness);
|
|
566
599
|
exitAfterSessionEnd(config, match.id);
|
|
@@ -2034,7 +2067,7 @@ const rejoinCommand = async (config, args) => {
|
|
|
2034
2067
|
if (session === undefined)
|
|
2035
2068
|
return fail("session selection failed");
|
|
2036
2069
|
const alreadyLive = agentLiveIn(detail, session);
|
|
2037
|
-
say(`${green("✓")} rejoining ${session.
|
|
2070
|
+
say(`${green("✓")} rejoining ${session.branch} · ${session.harness} ${dim(session.id.slice(0, 8))} · ${alreadyLive ? "already live" : "restoring"}`);
|
|
2038
2071
|
say(`${cobalt(" watch")} · ${config.url}/sessions/${session.id}`);
|
|
2039
2072
|
let restored = false;
|
|
2040
2073
|
const currentAgent = detail.annotations.find((annotation) => annotation.sessionId === session.id)?.currentAgent ??
|
|
@@ -2262,10 +2295,12 @@ start
|
|
|
2262
2295
|
mend adopt [source] [--name <name>] [--auth ambient|mend-key|bridge]
|
|
2263
2296
|
adopt a repository into the store (default: cwd; any git
|
|
2264
2297
|
URL — GitHub, GitLab, self-hosted, ssh://, a local path)
|
|
2265
|
-
mend codex|claude|opencode ["prompt"] [--
|
|
2298
|
+
mend codex|claude|opencode ["prompt"] [--name <worktree>] [--model <id>]
|
|
2299
|
+
[--effort low|medium|high|xhigh|max]
|
|
2266
2300
|
[--base <ref>] [--ask] [--fast] [--detach|-d] [--foreground]
|
|
2267
|
-
new session worktree + launch the harness in it;
|
|
2268
|
-
|
|
2301
|
+
new session worktree + launch the harness in it; the
|
|
2302
|
+
worktree's name is asked first (--name skips the ask), a
|
|
2303
|
+
quoted prompt becomes its first message,
|
|
2269
2304
|
--ask restores the harness's permission prompts, --fast
|
|
2270
2305
|
requests priority processing (codex service tier),
|
|
2271
2306
|
--detach launches without attaching (reattach anywhere),
|
package/dist/shared.js
CHANGED
|
@@ -34,6 +34,7 @@ const LAUNCH_ERROR = {
|
|
|
34
34
|
model: null,
|
|
35
35
|
effort: null,
|
|
36
36
|
base: null,
|
|
37
|
+
name: null,
|
|
37
38
|
ask: false,
|
|
38
39
|
fast: false,
|
|
39
40
|
detach: false,
|
|
@@ -56,6 +57,7 @@ export const parseLaunchArgs = (args) => {
|
|
|
56
57
|
let model = null;
|
|
57
58
|
let effort = null;
|
|
58
59
|
let base = null;
|
|
60
|
+
let workName = null;
|
|
59
61
|
let ask = false;
|
|
60
62
|
let fast = false;
|
|
61
63
|
let detach = false;
|
|
@@ -78,7 +80,11 @@ export const parseLaunchArgs = (args) => {
|
|
|
78
80
|
foreground = true;
|
|
79
81
|
continue;
|
|
80
82
|
}
|
|
81
|
-
if (arg === "--project" ||
|
|
83
|
+
if (arg === "--project" ||
|
|
84
|
+
arg === "--model" ||
|
|
85
|
+
arg === "--effort" ||
|
|
86
|
+
arg === "--base" ||
|
|
87
|
+
arg === "--name") {
|
|
82
88
|
const value = flagArgs[index + 1];
|
|
83
89
|
if (value === undefined)
|
|
84
90
|
return { ...LAUNCH_ERROR, error: `${arg} needs a value` };
|
|
@@ -88,6 +94,8 @@ export const parseLaunchArgs = (args) => {
|
|
|
88
94
|
model = value;
|
|
89
95
|
else if (arg === "--effort")
|
|
90
96
|
effort = value;
|
|
97
|
+
else if (arg === "--name")
|
|
98
|
+
workName = normalizeProjectName(value);
|
|
91
99
|
else
|
|
92
100
|
base = value;
|
|
93
101
|
index += 1;
|
|
@@ -121,6 +129,7 @@ export const parseLaunchArgs = (args) => {
|
|
|
121
129
|
model,
|
|
122
130
|
effort,
|
|
123
131
|
base,
|
|
132
|
+
name: workName,
|
|
124
133
|
ask,
|
|
125
134
|
fast,
|
|
126
135
|
detach,
|
|
@@ -129,6 +138,29 @@ export const parseLaunchArgs = (args) => {
|
|
|
129
138
|
error: null,
|
|
130
139
|
};
|
|
131
140
|
};
|
|
141
|
+
/**
|
|
142
|
+
* Whether a raw stdin chunk carries the detach key (Ctrl+]). Two encodings
|
|
143
|
+
* reach us: the legacy control byte 0x1d — and, once an inner TUI (claude)
|
|
144
|
+
* pushes the kitty keyboard protocol through the PTY and the user's own
|
|
145
|
+
* terminal honors it, the CSI-u escape `ESC [ 93 ; 5 u` (`]` is code 93,
|
|
146
|
+
* ctrl is modifier 5), optionally with a kitty event-type suffix. Only press
|
|
147
|
+
* (`:1`) and repeat (`:2`) are intent; a release (`:3`) is not.
|
|
148
|
+
*/
|
|
149
|
+
export const isDetachChunk = (data) => {
|
|
150
|
+
if (data.includes(0x1d))
|
|
151
|
+
return true;
|
|
152
|
+
// latin1 maps bytes 1:1, so the scan sees exactly the wire bytes. A string
|
|
153
|
+
// scan rather than a regex: an escape byte inside a pattern trips lint, and
|
|
154
|
+
// anchoring on the real ESC[ prefix keeps pasted text from ever matching.
|
|
155
|
+
const text = data.toString("latin1");
|
|
156
|
+
const prefix = "\u001b[93;5";
|
|
157
|
+
for (let start = text.indexOf(prefix); start !== -1; start = text.indexOf(prefix, start + 1)) {
|
|
158
|
+
const rest = text.slice(start + prefix.length);
|
|
159
|
+
if (rest.startsWith("u") || rest.startsWith(":1u") || rest.startsWith(":2u"))
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
};
|
|
132
164
|
export const LIVE_STATUSES = new Set([
|
|
133
165
|
"starting",
|
|
134
166
|
"running",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sealant/mend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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",
|