@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 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
- return (_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: session.harness.padEnd(9) }), _jsx("span", { fg: FAINT, children: session.id.slice(0, 8) }), _jsx("span", { fg: color, children: ` ${session.status.padEnd(10)}` }), _jsx("span", { fg: FAINT, children: age.padEnd(9) }), annotation !== undefined && annotation.openComments > 0 ? (_jsx("span", { fg: MUTED, children: `${annotation.openComments} open ` })) : null, _jsx("span", { fg: MUTED, children: (session.label ?? "").slice(0, 40) })] }) }));
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 keepRowVisible = (scroll, index) => {
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 (index < scroll.scrollTop)
191
- scroll.scrollTo(index);
192
- else if (index + 1 > scroll.scrollTop + viewH)
193
- scroll.scrollTo(index + 1 - viewH);
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
- keepRowVisible(sessionScrollRef.current, sessionIndex);
250
- }, [sessionIndex]);
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
- openPicker(null);
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
- : `session — ${selectedSession.session.harness} ${selectedSession.session.id.slice(0, 8)}`;
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
- : picker !== null
585
- ? " ↑↓ move · enter start · esc cancel"
586
- : focus === "projects"
587
- ? " ↑↓ move · enter/l sessions · ⇥ panes · r refresh · q quit"
588
- : " ↑↓ move · enter attach/resume · n new · v review · e rename · o web · h/⇥ projects · q quit";
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)))) })) : 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) => {
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.includes(0x1d)) {
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.harness} · ${dim(match.id.slice(0, 8))}${detachHint()}`);
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.harness} · ${dim(session.id.slice(0, 8))} · ${alreadyLive ? "already live" : "restoring"}`);
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"] [--model <id>] [--effort low|medium|high|xhigh|max]
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; a quoted
2268
- prompt becomes its first message (and names the session),
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" || arg === "--model" || arg === "--effort" || arg === "--base") {
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.13.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",