@sealant/mend 0.13.0 → 0.15.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.
@@ -0,0 +1,202 @@
1
+ import { HARNESS_COMMANDS, LIVE_STATUSES } from "./shared.js";
2
+ /**
3
+ * The worktree IS the session's identity (one worktree per session): its
4
+ * branch, with the noisy default prefix receded. What you are working on
5
+ * leads every row; the harness is a fact about it, not its name.
6
+ */
7
+ export const worktreeName = (branch) => branch.startsWith("mend/") ? branch.slice("mend/".length) : branch;
8
+ /**
9
+ * What to call an UNNAMED worktree: its branch is `mend/session/<uuid>` —
10
+ * noise nobody recognizes — so the auto-name label stands in, and before one
11
+ * lands, the short session id. A named worktree is always its own name.
12
+ */
13
+ export const worktreeDisplayName = (session) => {
14
+ const name = worktreeName(session.branch);
15
+ if (!name.startsWith("session/"))
16
+ return name;
17
+ return session.label ?? `session ${session.id.slice(0, 8)}`;
18
+ };
19
+ /** True when the label already IS the row's identity — don't repeat it. */
20
+ export const labelIsIdentity = (session) => worktreeName(session.branch).startsWith("session/") && session.label !== null;
21
+ export const WORKBENCH_KEY = ["workbench"];
22
+ export const fetchWorkbench = async (ctx) => {
23
+ const projects = await ctx.api("GET", "/projects");
24
+ const [fetched, services] = await Promise.all([
25
+ Promise.all(projects.map((project) => ctx.api("GET", `/projects/${project.id}`))),
26
+ ctx.api("GET", "/services"),
27
+ ]);
28
+ const servicesBySession = new Map();
29
+ for (const service of services) {
30
+ const bucket = servicesBySession.get(service.sessionId) ?? [];
31
+ bucket.push(service);
32
+ servicesBySession.set(service.sessionId, bucket);
33
+ }
34
+ // What lives in each LIVE worktree: the agent and shells come from the
35
+ // session detail (settled sessions have nothing live — no fetch for them).
36
+ const liveSessions = fetched.flatMap((detail) => detail.sessions.filter((session) => LIVE_STATUSES.has(session.status)));
37
+ const detailed = await Promise.all(liveSessions.map(async (session) => {
38
+ try {
39
+ return await ctx.api("GET", `/sessions/${session.id}`);
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }));
45
+ const processesBySession = new Map();
46
+ for (const detail of detailed) {
47
+ if (detail === null)
48
+ continue;
49
+ processesBySession.set(detail.session.id, detail.processes.filter((process) => process.exitedAt === null && process.kind !== "service"));
50
+ }
51
+ return {
52
+ projects,
53
+ details: new Map(fetched.map((detail) => [detail.project.id, detail])),
54
+ servicesBySession,
55
+ processesBySession,
56
+ };
57
+ };
58
+ // ─── optimistic cache surgery: pure Workbench → Workbench ───────────────────
59
+ /** Apply `f` to every session row in every project detail. */
60
+ export const mapWorkbenchSessions = (data, f) => ({
61
+ ...data,
62
+ details: new Map([...data.details].map(([id, detail]) => [id, { ...detail, sessions: detail.sessions.map(f) }])),
63
+ });
64
+ export const mapProjectSessions = (data, projectId, f) => {
65
+ const detail = data.details.get(projectId);
66
+ if (detail === undefined)
67
+ return data;
68
+ const details = new Map(data.details);
69
+ details.set(projectId, { ...detail, sessions: f(detail.sessions) });
70
+ return { ...data, details };
71
+ };
72
+ export const prependSession = (data, projectId, session) => mapProjectSessions(data, projectId, (sessions) => [session, ...sessions]);
73
+ export const replaceSession = (data, projectId, oldId, session) => mapProjectSessions(data, projectId, (sessions) => sessions.map((candidate) => (candidate.id === oldId ? session : candidate)));
74
+ export const removeSession = (data, projectId, sessionId) => mapProjectSessions(data, projectId, (sessions) => sessions.filter((candidate) => candidate.id !== sessionId));
75
+ export const deriveProjects = (data) => (data?.projects ?? []).map((project) => {
76
+ const detail = data?.details.get(project.id);
77
+ const sessions = detail?.sessions ?? [];
78
+ const live = sessions.filter((s) => LIVE_STATUSES.has(s.status)).length;
79
+ const open = (detail?.annotations ?? []).reduce((sum, a) => sum + a.openComments, 0);
80
+ return { project, total: sessions.length, live, open };
81
+ });
82
+ export const bySessionRecency = (a, b) => {
83
+ const aLive = LIVE_STATUSES.has(a.status) ? 1 : 0;
84
+ const bLive = LIVE_STATUSES.has(b.status) ? 1 : 0;
85
+ if (aLive !== bLive)
86
+ return bLive - aLive;
87
+ return b.createdAt.localeCompare(a.createdAt);
88
+ };
89
+ export const toSessionItem = (data, detail) => (session) => ({
90
+ session,
91
+ annotation: detail.annotations.find((a) => a.sessionId === session.id),
92
+ services: data.servicesBySession.get(session.id) ?? [],
93
+ processes: data.processesBySession.get(session.id) ?? [],
94
+ });
95
+ export const deriveWorktrees = (data, projectId) => {
96
+ if (data === undefined || projectId === null)
97
+ return [];
98
+ const detail = data.details.get(projectId);
99
+ if (detail === undefined)
100
+ return [];
101
+ const item = toSessionItem(data, detail);
102
+ const sorted = detail.sessions.toSorted(bySessionRecency);
103
+ const groups = [];
104
+ if (detail.worktrees !== undefined) {
105
+ const claimed = new Set();
106
+ for (const worktree of detail.worktrees) {
107
+ const members = sorted.filter((session) => session.worktreeId === worktree.id);
108
+ for (const member of members)
109
+ claimed.add(member.id);
110
+ groups.push({
111
+ key: worktree.id,
112
+ id: worktree.id,
113
+ name: worktree.name.startsWith("wt-")
114
+ ? (members.find((m) => m.label !== null)?.label ??
115
+ (members[0] === undefined ? worktree.name : `session ${members[0].id.slice(0, 8)}`))
116
+ : worktree.name,
117
+ branch: worktree.branch,
118
+ baseRef: worktree.baseRef,
119
+ createdAt: worktree.createdAt,
120
+ sessions: members.map(item),
121
+ live: members.filter((session) => LIVE_STATUSES.has(session.status)).length,
122
+ annotation: detail.annotations.find((a) => members.some((m) => m.id === a.sessionId)),
123
+ });
124
+ }
125
+ // Optimistic pending rows have no worktree yet — each is its own group.
126
+ for (const session of sorted) {
127
+ if (claimed.has(session.id))
128
+ continue;
129
+ groups.push(pseudoGroup(session, item));
130
+ }
131
+ }
132
+ else {
133
+ for (const session of sorted)
134
+ groups.push(pseudoGroup(session, item));
135
+ }
136
+ return groups.toSorted((a, b) => {
137
+ if (a.live > 0 !== b.live > 0)
138
+ return a.live > 0 ? -1 : 1;
139
+ const aNewest = a.sessions[0]?.session.createdAt ?? a.createdAt;
140
+ const bNewest = b.sessions[0]?.session.createdAt ?? b.createdAt;
141
+ return bNewest.localeCompare(aNewest);
142
+ });
143
+ };
144
+ export const pseudoGroup = (session, item) => ({
145
+ key: session.id,
146
+ id: null,
147
+ name: worktreeDisplayName(session),
148
+ branch: session.branch,
149
+ baseRef: session.baseRef,
150
+ createdAt: session.createdAt,
151
+ sessions: [item(session)],
152
+ live: LIVE_STATUSES.has(session.status) ? 1 : 0,
153
+ annotation: item(session).annotation,
154
+ });
155
+ export const deriveRows = (groups) => groups.flatMap((group) => {
156
+ const only = group.sessions.length === 1 ? group.sessions[0] : undefined;
157
+ if (only !== undefined)
158
+ return [{ kind: "session", group, item: only }];
159
+ return [
160
+ { kind: "worktree", group },
161
+ ...group.sessions.map((item) => ({ kind: "session", group, item })),
162
+ ];
163
+ });
164
+ export const rowKeyOf = (row) => row.kind === "worktree" ? `wt:${row.group.key}` : `s:${row.item.session.id}`;
165
+ /** The picker's rows: resume offers the same harness first, then the crossings. */
166
+ export const deriveHarnesses = (resuming) => {
167
+ if (resuming !== null) {
168
+ const others = Object.keys(HARNESS_COMMANDS).filter((h) => h !== resuming.harness);
169
+ return [
170
+ {
171
+ harness: null,
172
+ label: resuming.harness,
173
+ hint: "same harness — native resume, conversation intact",
174
+ },
175
+ ...others.map((harness) => ({
176
+ harness,
177
+ label: harness,
178
+ hint: harness === "shell"
179
+ ? "a bash in the worktree — resume either agent from inside"
180
+ : "the conversation crosses as a distilled prompt",
181
+ })),
182
+ ];
183
+ }
184
+ return Object.keys(HARNESS_COMMANDS).map((harness) => ({
185
+ harness,
186
+ label: harness,
187
+ hint: harness === "shell"
188
+ ? "a plain bash session — new worktree, recorded"
189
+ : `mend ${harness} — new worktree, recorded session`,
190
+ }));
191
+ };
192
+ /** The worktree's folded status word: waiting wins, then running, then idle. */
193
+ export const foldGroupStatus = (group) => {
194
+ const statuses = group.sessions.map((item) => item.session.status);
195
+ if (statuses.includes("waiting"))
196
+ return "waiting";
197
+ if (statuses.includes("running") || statuses.includes("starting"))
198
+ return "running";
199
+ if (statuses.includes("idle"))
200
+ return "idle";
201
+ return statuses[0] ?? "idle";
202
+ };
package/dist/dashboard.js CHANGED
@@ -3,12 +3,12 @@ import { CliRenderEvents, createCliRenderer } from "@opentui/core";
3
3
  import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
4
4
  import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient, } from "@tanstack/react-query";
5
5
  import { useEffect, useRef, useState } from "react";
6
+ import { deriveHarnesses, deriveProjects, deriveRows, deriveWorktrees, foldGroupStatus, labelIsIdentity, rowKeyOf, worktreeDisplayName, fetchWorkbench, mapWorkbenchSessions, prependSession, removeSession, replaceSession, WORKBENCH_KEY, } from "./dashboard-model.js";
6
7
  import { reviewTargetForSession } from "./review-workflow.js";
7
8
  import { ReviewScreen } from "./review.js";
8
- import { cwdFacts, HARNESS_COMMANDS, isPendingId, LIVE_STATUSES, matchProjectByCwd, pendingId, } from "./shared.js";
9
+ import { cwdFacts, HARNESS_COMMANDS, isPendingId, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, pendingId, } from "./shared.js";
9
10
  import { openUrl } from "./terminal.js";
10
11
  import { COBALT, FAINT, INK, INK_2, MUTED, RED, RULE, WASH } from "./tui-theme.js";
11
- import { createSseParser, eventFamilies } from "./workbench-events.js";
12
12
  // Near-mono on purpose: a status is a word, and only an observed failure
13
13
  // earns color. Live states read at full ink; settled ones recede.
14
14
  const STATUS_COLOR = {
@@ -34,96 +34,7 @@ const timeAgo = (iso) => {
34
34
  return `${hours}h ago`;
35
35
  return `${Math.floor(hours / 24)}d ago`;
36
36
  };
37
- const WORKBENCH_KEY = ["workbench"];
38
- const fetchWorkbench = async (ctx) => {
39
- const projects = await ctx.api("GET", "/projects");
40
- const [fetched, services] = await Promise.all([
41
- Promise.all(projects.map((project) => ctx.api("GET", `/projects/${project.id}`))),
42
- ctx.api("GET", "/services"),
43
- ]);
44
- const servicesBySession = new Map();
45
- for (const service of services) {
46
- const bucket = servicesBySession.get(service.sessionId) ?? [];
47
- bucket.push(service);
48
- servicesBySession.set(service.sessionId, bucket);
49
- }
50
- return {
51
- projects,
52
- details: new Map(fetched.map((detail) => [detail.project.id, detail])),
53
- servicesBySession,
54
- };
55
- };
56
- // ─── optimistic cache surgery: pure Workbench → Workbench ───────────────────
57
- /** Apply `f` to every session row in every project detail. */
58
- const mapWorkbenchSessions = (data, f) => ({
59
- ...data,
60
- details: new Map([...data.details].map(([id, detail]) => [id, { ...detail, sessions: detail.sessions.map(f) }])),
61
- });
62
- const mapProjectSessions = (data, projectId, f) => {
63
- const detail = data.details.get(projectId);
64
- if (detail === undefined)
65
- return data;
66
- const details = new Map(data.details);
67
- details.set(projectId, { ...detail, sessions: f(detail.sessions) });
68
- return { ...data, details };
69
- };
70
- const prependSession = (data, projectId, session) => mapProjectSessions(data, projectId, (sessions) => [session, ...sessions]);
71
- const replaceSession = (data, projectId, oldId, session) => mapProjectSessions(data, projectId, (sessions) => sessions.map((candidate) => (candidate.id === oldId ? session : candidate)));
72
- const removeSession = (data, projectId, sessionId) => mapProjectSessions(data, projectId, (sessions) => sessions.filter((candidate) => candidate.id !== sessionId));
73
- const deriveProjects = (data) => (data?.projects ?? []).map((project) => {
74
- const detail = data?.details.get(project.id);
75
- const sessions = detail?.sessions ?? [];
76
- const live = sessions.filter((s) => LIVE_STATUSES.has(s.status)).length;
77
- const open = (detail?.annotations ?? []).reduce((sum, a) => sum + a.openComments, 0);
78
- return { project, total: sessions.length, live, open };
79
- });
80
- const deriveSessions = (data, projectId) => {
81
- if (data === undefined || projectId === null)
82
- return [];
83
- const detail = data.details.get(projectId);
84
- if (detail === undefined)
85
- return [];
86
- return detail.sessions
87
- .toSorted((a, b) => {
88
- const aLive = LIVE_STATUSES.has(a.status) ? 1 : 0;
89
- const bLive = LIVE_STATUSES.has(b.status) ? 1 : 0;
90
- if (aLive !== bLive)
91
- return bLive - aLive;
92
- return b.createdAt.localeCompare(a.createdAt);
93
- })
94
- .map((session) => ({
95
- session,
96
- annotation: detail.annotations.find((a) => a.sessionId === session.id),
97
- services: data.servicesBySession.get(session.id) ?? [],
98
- }));
99
- };
100
- /** The picker's rows: resume offers the same harness first, then the crossings. */
101
- const deriveHarnesses = (resuming) => {
102
- if (resuming !== null) {
103
- const others = Object.keys(HARNESS_COMMANDS).filter((h) => h !== resuming.harness);
104
- return [
105
- {
106
- harness: null,
107
- label: resuming.harness,
108
- hint: "same harness — native resume, conversation intact",
109
- },
110
- ...others.map((harness) => ({
111
- harness,
112
- label: harness,
113
- hint: harness === "shell"
114
- ? "a bash in the worktree — resume either agent from inside"
115
- : "the conversation crosses as a distilled prompt",
116
- })),
117
- ];
118
- }
119
- return Object.keys(HARNESS_COMMANDS).map((harness) => ({
120
- harness,
121
- label: harness,
122
- hint: harness === "shell"
123
- ? "a plain bash session — new worktree, recorded"
124
- : `mend ${harness} — new worktree, recorded session`,
125
- }));
126
- };
37
+ import { createSseParser, eventFamilies } from "./workbench-events.js";
127
38
  // ─── panes and rows ─────────────────────────────────────────────────────────
128
39
  const Gutter = ({ selected }) => (_jsx("span", { fg: selected ? COBALT : FAINT, children: selected ? "▌ " : " " }));
129
40
  /**
@@ -138,11 +49,39 @@ const paneScrollStyle = {
138
49
  contentOptions: { backgroundColor: "transparent" },
139
50
  };
140
51
  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
- const SessionRow = ({ item, selected, }) => {
142
- const { session, annotation } = item;
52
+ const ProcessLines = ({ processes, services, indent, }) => (_jsxs(_Fragment, { children: [processes.map((process) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: FAINT, children: indent }), _jsx("span", { fg: INK_2, children: process.kind === "shell"
53
+ ? (process.label ?? "shell")
54
+ : (process.harness ?? process.kind) }), _jsxs("span", { fg: FAINT, children: [process.kind === "agent-protocol"
55
+ ? " · protocol"
56
+ : process.kind === "agent-external"
57
+ ? " · external"
58
+ : "", ` ${process.status}`] })] }) }, process.id))), services.map((service) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: FAINT, children: indent }), _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)))] }));
59
+ /**
60
+ * A lone-session worktree, combined into one row (today's density): the
61
+ * worktree name leads, everything live inside hangs underneath as facts.
62
+ */
63
+ const CombinedRow = ({ group, item, selected, }) => {
64
+ const { session, annotation, services, processes } = item;
143
65
  const color = STATUS_COLOR[session.status] ?? MUTED;
144
66
  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) })] }) }));
67
+ const trailingLabel = labelIsIdentity(session) ? "" : (session.label ?? "");
68
+ 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: group.name.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: trailingLabel.slice(0, 40) })] }) }), _jsx(ProcessLines, { processes: processes, services: services, indent: " └ " })] }));
69
+ };
70
+ /** A shared worktree's header: the place, its folded status, its member facts. */
71
+ const WorktreeHeaderRow = ({ group, selected, }) => {
72
+ const folded = foldGroupStatus(group);
73
+ const color = group.live > 0 ? (STATUS_COLOR[folded] ?? MUTED) : FAINT;
74
+ const age = timeAgo(group.sessions.at(-1)?.session.createdAt ?? group.createdAt).replace(" ago", "");
75
+ const open = group.annotation?.openComments ?? 0;
76
+ 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: group.name.slice(0, 30).padEnd(31) }), _jsx("span", { fg: color, children: (group.live > 0 ? folded : "settled").padEnd(10) }), _jsx("span", { fg: FAINT, children: age.padEnd(9) }), _jsx("span", { fg: MUTED, children: `${group.sessions.length} sessions` }), open > 0 ? _jsx("span", { fg: MUTED, children: ` · ${open} open` }) : null] }) }));
77
+ };
78
+ /** One conversation inside a shared worktree — the label is its identity. */
79
+ const SessionChildRow = ({ item, selected, }) => {
80
+ const { session, services, processes } = item;
81
+ const color = STATUS_COLOR[session.status] ?? MUTED;
82
+ const age = timeAgo(session.createdAt).replace(" ago", "");
83
+ const name = session.label ?? `session ${session.id.slice(0, 8)}`;
84
+ 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: FAINT, children: " └ " }), _jsx("span", { fg: INK, children: name.slice(0, 26).padEnd(27) }), _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) })] }) }), _jsx(ProcessLines, { processes: processes, services: services, indent: " └ " })] }));
146
85
  };
147
86
  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
87
  /**
@@ -183,15 +122,16 @@ const StatusLine = ({ busy, busyStarted, status, }) => {
183
122
  };
184
123
  const errorText = (error) => error instanceof Error ? error.message : String(error);
185
124
  /** Keep a 1-line-per-row selection inside its scrollbox's real viewport. */
186
- const keepRowVisible = (scroll, index) => {
125
+ const keepSpanVisible = (scroll, top, height) => {
187
126
  if (scroll === null)
188
127
  return;
189
128
  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);
129
+ if (top < scroll.scrollTop)
130
+ scroll.scrollTo(top);
131
+ else if (top + height > scroll.scrollTop + viewH)
132
+ scroll.scrollTo(top + height - viewH);
194
133
  };
134
+ const keepRowVisible = (scroll, index) => keepSpanVisible(scroll, index, 1);
195
135
  const PROJECTS_PANE_WIDTH = 30;
196
136
  const App = ({ ctx, onQuit }) => {
197
137
  const renderer = useRenderer();
@@ -205,6 +145,7 @@ const App = ({ ctx, onQuit }) => {
205
145
  });
206
146
  const [focusState, setFocus] = useState("sessions");
207
147
  const [projectKey, setProjectKey] = useState(null);
148
+ /** A row key (`wt:<id>` | `s:<id>`), so selection survives regrouping. */
208
149
  const [sessionKey, setSessionKey] = useState(null);
209
150
  const [picker, setPicker] = useState(null);
210
151
  const [pickerIndex, setPickerIndex] = useState(0);
@@ -212,6 +153,11 @@ const App = ({ ctx, onQuit }) => {
212
153
  const [busyStarted, setBusyStarted] = useState(0);
213
154
  const [status, setStatus] = useState(null);
214
155
  const [editing, setEditing] = useState(null);
156
+ /** Session id a stop is armed against; the second press fires it. */
157
+ const [stopArmed, setStopArmed] = useState(null);
158
+ /** The new-session flow asks the worktree's name FIRST, then the harness. */
159
+ const [naming, setNaming] = useState(null);
160
+ const [pendingName, setPendingName] = useState(null);
215
161
  const [reviewing, setReviewing] = useState(null);
216
162
  /** Set synchronously around attach/launch so keystrokes can't double-fire. */
217
163
  const lockRef = useRef(false);
@@ -232,11 +178,23 @@ const App = ({ ctx, onQuit }) => {
232
178
  ? 0
233
179
  : projectItems.findIndex((p) => p.project.id === homeProject.id));
234
180
  const selectedProject = projectItems[projectIndex] ?? null;
235
- const sessionItems = deriveSessions(data, selectedProject?.project.id ?? null);
236
- const sessionIndexRaw = sessionKey === null ? -1 : sessionItems.findIndex((s) => s.session.id === sessionKey);
237
- const sessionIndex = sessionIndexRaw === -1 ? 0 : sessionIndexRaw;
238
- const selectedSession = sessionItems[sessionIndex] ?? null;
181
+ const worktreeGroups = deriveWorktrees(data, selectedProject?.project.id ?? null);
182
+ const rows = deriveRows(worktreeGroups);
183
+ const rowIndexRaw = sessionKey === null ? -1 : rows.findIndex((row) => rowKeyOf(row) === sessionKey);
184
+ const rowIndex = rowIndexRaw === -1 ? 0 : rowIndexRaw;
185
+ const selectedRow = rows[rowIndex] ?? null;
186
+ const selectedGroup = selectedRow?.group ?? null;
187
+ // A worktree header still names a concrete conversation for session verbs:
188
+ // the newest live member, else the newest at all.
189
+ const selectedSession = selectedRow === null
190
+ ? null
191
+ : selectedRow.kind === "session"
192
+ ? selectedRow.item
193
+ : (selectedRow.group.sessions.find((item) => LIVE_STATUSES.has(item.session.status)) ??
194
+ selectedRow.group.sessions[0] ??
195
+ null);
239
196
  const pickerItems = picker === null ? [] : deriveHarnesses(picker.session);
197
+ const selectSession = (id) => setSessionKey(`s:${id}`);
240
198
  const say = (text) => setStatus({ text, at: Date.now() });
241
199
  const refetch = () => void queryClient.invalidateQueries({ queryKey: WORKBENCH_KEY });
242
200
  // Keep each pane's selection on screen — the one imperative escape hatch.
@@ -246,8 +204,19 @@ const App = ({ ctx, onQuit }) => {
246
204
  keepRowVisible(projectScrollRef.current, projectIndex);
247
205
  }, [projectIndex]);
248
206
  useEffect(() => {
249
- keepRowVisible(sessionScrollRef.current, sessionIndex);
250
- }, [sessionIndex]);
207
+ // Rows vary in height (header = 1; a session row carries its process and
208
+ // service fact lines), so the scroll target is the row's y offset.
209
+ const rowHeight = (row) => row === undefined || row.kind === "worktree"
210
+ ? 1
211
+ : 1 + row.item.processes.length + row.item.services.length;
212
+ let top = 0;
213
+ for (const [index, row] of rows.entries()) {
214
+ if (index === rowIndex)
215
+ break;
216
+ top += rowHeight(row);
217
+ }
218
+ keepSpanVisible(sessionScrollRef.current, top, rowHeight(rows[rowIndex]));
219
+ }, [rowIndex, rows]);
251
220
  const attachFlow = async (session) => {
252
221
  const short = session.id.slice(0, 8);
253
222
  lockRef.current = true;
@@ -313,6 +282,7 @@ const App = ({ ctx, onQuit }) => {
313
282
  const session = await ctx.api("POST", `/projects/${vars.projectId}/sessions`, {
314
283
  harness: vars.harness,
315
284
  label: null,
285
+ name: vars.name,
316
286
  base: null,
317
287
  });
318
288
  await ctx.api("POST", `/sessions/${session.id}/launch`, { argv });
@@ -324,14 +294,14 @@ const App = ({ ctx, onQuit }) => {
324
294
  id: vars.pendingKey,
325
295
  harness: vars.harness,
326
296
  label: null,
327
- branch: "provisioning…",
297
+ branch: vars.name === null ? "provisioning…" : `mend/${vars.name}`,
328
298
  baseSha: "",
329
299
  baseRef: null,
330
300
  status: "starting",
331
301
  summary: null,
332
302
  createdAt: new Date().toISOString(),
333
303
  }));
334
- setSessionKey(vars.pendingKey);
304
+ selectSession(vars.pendingKey);
335
305
  setBusy(`provisioning ${vars.harness} workspace — a first launch builds the harness image ·`);
336
306
  setBusyStarted(Date.now());
337
307
  },
@@ -342,7 +312,7 @@ const App = ({ ctx, onQuit }) => {
342
312
  },
343
313
  onSuccess: async (session, vars) => {
344
314
  patchWorkbench((current) => replaceSession(current, vars.projectId, vars.pendingKey, session));
345
- setSessionKey(session.id);
315
+ selectSession(session.id);
346
316
  setBusy(null);
347
317
  await attachIfIdle(session);
348
318
  },
@@ -355,7 +325,7 @@ const App = ({ ctx, onQuit }) => {
355
325
  onMutate: async (vars) => {
356
326
  await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
357
327
  patchWorkbench((current) => mapWorkbenchSessions(current, (session) => session.id === vars.session.id ? { ...session, status: "starting" } : session));
358
- setSessionKey(vars.session.id);
328
+ selectSession(vars.session.id);
359
329
  setBusy(`resuming ${vars.session.id.slice(0, 8)} — a fresh workspace restores the saved state ·`);
360
330
  setBusyStarted(Date.now());
361
331
  },
@@ -366,7 +336,7 @@ const App = ({ ctx, onQuit }) => {
366
336
  },
367
337
  onSuccess: async (resumed, vars) => {
368
338
  patchWorkbench((current) => replaceSession(current, vars.projectId, vars.session.id, resumed));
369
- setSessionKey(resumed.id);
339
+ selectSession(resumed.id);
370
340
  setBusy(null);
371
341
  await attachIfIdle(resumed);
372
342
  },
@@ -389,9 +359,150 @@ const App = ({ ctx, onQuit }) => {
389
359
  },
390
360
  onSettled: settleRefetch,
391
361
  });
362
+ // Stops are explicit — and armed: the first press names what a second press
363
+ // will stop; moving the selection re-arms against the newly selected row.
364
+ const stopMutation = useMutation({
365
+ mutationFn: (session) => ctx.api("POST", `/sessions/${session.id}/stop`),
366
+ onMutate: async (session) => {
367
+ await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
368
+ patchWorkbench((current) => mapWorkbenchSessions(current, (row) => row.id === session.id ? { ...row, status: "stopped" } : row));
369
+ say(`stopped · ${worktreeDisplayName(session)} — the record and review remain`);
370
+ },
371
+ onError: (error) => {
372
+ say(errorText(error));
373
+ refetch();
374
+ },
375
+ onSettled: settleRefetch,
376
+ });
377
+ // A new conversation inside an existing worktree — the `s` key's flow.
378
+ const launchInWorktreeMutation = useMutation({
379
+ mutationFn: async (vars) => {
380
+ const argv = HARNESS_COMMANDS[vars.harness];
381
+ if (argv === undefined)
382
+ throw new Error(`unknown harness "${vars.harness}"`);
383
+ const session = await ctx.api("POST", `/worktrees/${vars.worktreeId}/sessions`, {
384
+ harness: vars.harness,
385
+ label: null,
386
+ });
387
+ await ctx.api("POST", `/sessions/${session.id}/launch`, { argv });
388
+ return session;
389
+ },
390
+ onMutate: async (vars) => {
391
+ await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
392
+ patchWorkbench((current) => prependSession(current, vars.projectId, {
393
+ id: vars.pendingKey,
394
+ worktreeId: vars.worktreeId,
395
+ harness: vars.harness,
396
+ label: null,
397
+ branch: "joining…",
398
+ baseSha: "",
399
+ baseRef: null,
400
+ status: "starting",
401
+ summary: null,
402
+ createdAt: new Date().toISOString(),
403
+ }));
404
+ selectSession(vars.pendingKey);
405
+ setBusy(`starting ${vars.harness} in the worktree ·`);
406
+ setBusyStarted(Date.now());
407
+ },
408
+ onError: (error, vars) => {
409
+ patchWorkbench((current) => removeSession(current, vars.projectId, vars.pendingKey));
410
+ setBusy(null);
411
+ say(errorText(error));
412
+ },
413
+ onSuccess: async (session, vars) => {
414
+ patchWorkbench((current) => replaceSession(current, vars.projectId, vars.pendingKey, session));
415
+ selectSession(session.id);
416
+ setBusy(null);
417
+ await attachIfIdle(session);
418
+ },
419
+ onSettled: settleRefetch,
420
+ });
421
+ // The one explicit destructive act. Against a pre-worktree server the
422
+ // session delete IS the old combined removal — same key, old semantics.
423
+ const removeWorktreeMutation = useMutation({
424
+ mutationFn: (group) => group.id !== null
425
+ ? ctx.api("DELETE", `/worktrees/${group.id}`)
426
+ : ctx.api("DELETE", `/sessions/${group.sessions[0]?.session.id ?? ""}`),
427
+ onMutate: (group) => {
428
+ say(`removing worktree · ${group.name}`);
429
+ },
430
+ onError: (error) => {
431
+ say(errorText(error));
432
+ refetch();
433
+ },
434
+ onSuccess: (_result, group) => {
435
+ say(`removed · ${group.name}`);
436
+ refetch();
437
+ },
438
+ onSettled: settleRefetch,
439
+ });
440
+ /** Worktree removal is armed like stops: the first press states the facts. */
441
+ const [removeArmed, setRemoveArmed] = useState(null);
442
+ const armRemove = () => {
443
+ const group = selectedGroup;
444
+ if (group === null || group.sessions.some((item) => isPendingId(item.session.id)))
445
+ return;
446
+ if (group.live > 0) {
447
+ say(`${group.live} session${group.live === 1 ? "" : "s"} live — stop them first · ${group.name}`);
448
+ return;
449
+ }
450
+ if (removeArmed === group.key) {
451
+ setRemoveArmed(null);
452
+ removeWorktreeMutation.mutate(group);
453
+ return;
454
+ }
455
+ setRemoveArmed(group.key);
456
+ const facts = group.sessions.length === 1
457
+ ? "its session and change go with it"
458
+ : `${group.sessions.length} sessions and the change go with it`;
459
+ say(`press again to remove worktree · ${group.name} — ${facts}`);
460
+ };
461
+ const armStop = () => {
462
+ // A worktree header arms a stop of EVERY live conversation in it.
463
+ if (selectedRow?.kind === "worktree") {
464
+ const group = selectedRow.group;
465
+ const live = group.sessions.filter((item) => LIVE_STATUSES.has(item.session.status));
466
+ if (live.length === 0) {
467
+ say("nothing to stop — the worktree is settled");
468
+ return;
469
+ }
470
+ if (stopArmed === `wt:${group.key}`) {
471
+ setStopArmed(null);
472
+ for (const item of live)
473
+ stopMutation.mutate(item.session);
474
+ return;
475
+ }
476
+ setStopArmed(`wt:${group.key}`);
477
+ say(`press again to stop ${live.length} live session${live.length === 1 ? "" : "s"} · ${group.name}`);
478
+ return;
479
+ }
480
+ const item = selectedSession;
481
+ if (item === null || isPendingId(item.session.id))
482
+ return;
483
+ if (!LIVE_STATUSES.has(item.session.status)) {
484
+ say("nothing to stop — the session is settled");
485
+ return;
486
+ }
487
+ if (stopArmed === item.session.id) {
488
+ setStopArmed(null);
489
+ stopMutation.mutate(item.session);
490
+ return;
491
+ }
492
+ setStopArmed(item.session.id);
493
+ say(`press again to stop · ${worktreeDisplayName(item.session)}`);
494
+ };
392
495
  const startSession = (projectId, harness) => {
393
496
  setFocus("sessions");
394
- launchMutation.mutate({ projectId, harness, pendingKey: pendingId() });
497
+ launchMutation.mutate({ projectId, harness, name: pendingName, pendingKey: pendingId() });
498
+ setPendingName(null);
499
+ };
500
+ /** Name first, then the harness: the worktree is the identity being created. */
501
+ const submitWorktreeName = (value) => {
502
+ setNaming(null);
503
+ const slug = normalizeProjectName(value.trim());
504
+ setPendingName(value.trim() === "" ? null : slug);
505
+ openPicker(null);
395
506
  };
396
507
  const resumeSession = (projectId, session, harness) => {
397
508
  setFocus("sessions");
@@ -402,8 +513,8 @@ const App = ({ ctx, onQuit }) => {
402
513
  const label = value.trim() === "" ? null : value.trim();
403
514
  renameMutation.mutate({ session, label });
404
515
  };
405
- const openPicker = (session) => {
406
- setPicker({ session });
516
+ const openPicker = (session, worktree) => {
517
+ setPicker(worktree === undefined ? { session } : { session, worktree });
407
518
  setPickerIndex(0);
408
519
  };
409
520
  const moveSelection = (delta) => {
@@ -422,12 +533,12 @@ const App = ({ ctx, onQuit }) => {
422
533
  }
423
534
  return;
424
535
  }
425
- if (sessionItems.length === 0)
536
+ if (rows.length === 0)
426
537
  return;
427
- const next = Math.max(0, Math.min(sessionItems.length - 1, sessionIndex + delta));
428
- const item = sessionItems[next];
429
- if (item !== undefined)
430
- setSessionKey(item.session.id);
538
+ const next = Math.max(0, Math.min(rows.length - 1, rowIndex + delta));
539
+ const row = rows[next];
540
+ if (row !== undefined)
541
+ setSessionKey(rowKeyOf(row));
431
542
  };
432
543
  const activate = () => {
433
544
  if (picker !== null) {
@@ -436,7 +547,18 @@ const App = ({ ctx, onQuit }) => {
436
547
  if (choice === undefined || projectId === undefined)
437
548
  return;
438
549
  setPicker(null);
439
- if (picker.session === null) {
550
+ if (picker.worktree !== undefined && picker.worktree.id !== null) {
551
+ if (choice.harness !== null) {
552
+ setFocus("sessions");
553
+ launchInWorktreeMutation.mutate({
554
+ projectId,
555
+ worktreeId: picker.worktree.id,
556
+ harness: choice.harness,
557
+ pendingKey: pendingId(),
558
+ });
559
+ }
560
+ }
561
+ else if (picker.session === null) {
440
562
  if (choice.harness !== null)
441
563
  startSession(projectId, choice.harness);
442
564
  }
@@ -449,6 +571,18 @@ const App = ({ ctx, onQuit }) => {
449
571
  setFocus("sessions");
450
572
  return;
451
573
  }
574
+ if (selectedRow?.kind === "worktree") {
575
+ // Enter on the place: attach its newest live conversation, or open a
576
+ // new one when nothing is live.
577
+ const live = selectedRow.group.sessions.find((item) => LIVE_STATUSES.has(item.session.status));
578
+ if (live !== undefined) {
579
+ void attachFlow(live.session);
580
+ }
581
+ else if (selectedRow.group.id !== null) {
582
+ openPicker(null, selectedRow.group);
583
+ }
584
+ return;
585
+ }
452
586
  const item = selectedSession;
453
587
  if (item === null)
454
588
  return;
@@ -475,6 +609,11 @@ const App = ({ ctx, onQuit }) => {
475
609
  setEditing(null);
476
610
  return;
477
611
  }
612
+ if (naming !== null) {
613
+ if (key.name === "escape")
614
+ setNaming(null);
615
+ return;
616
+ }
478
617
  if (picker !== null) {
479
618
  switch (key.name) {
480
619
  case "down":
@@ -495,6 +634,13 @@ const App = ({ ctx, onQuit }) => {
495
634
  return;
496
635
  }
497
636
  }
637
+ // Shift+K (and x below): stop the selected session. Lowercase k stays
638
+ // vim-up; the shift is the deliberateness the arm-confirm then doubles.
639
+ if (key.shift === true && key.name === "k")
640
+ return armStop();
641
+ // Shift+D: remove the selected worktree — the one explicit destructive act.
642
+ if (key.shift === true && key.name === "d")
643
+ return armRemove();
498
644
  switch (key.name) {
499
645
  case "q":
500
646
  return onQuit();
@@ -504,6 +650,8 @@ const App = ({ ctx, onQuit }) => {
504
650
  case "up":
505
651
  case "k":
506
652
  return moveSelection(-1);
653
+ case "x":
654
+ return armStop();
507
655
  case "return":
508
656
  case "linefeed":
509
657
  return activate();
@@ -524,9 +672,22 @@ const App = ({ ctx, onQuit }) => {
524
672
  setFocus(focus === "projects" ? "sessions" : "projects");
525
673
  return;
526
674
  case "n":
675
+ // The worktree's name comes first; the harness picker follows.
527
676
  if (selectedProject !== null)
528
- openPicker(null);
677
+ setNaming({ projectId: selectedProject.project.id });
529
678
  return;
679
+ case "s": {
680
+ // A new conversation inside the selected worktree.
681
+ const group = selectedGroup;
682
+ if (group === null)
683
+ return;
684
+ if (group.id === null) {
685
+ say("this server predates shared worktrees — n starts a new one");
686
+ return;
687
+ }
688
+ openPicker(null, group);
689
+ return;
690
+ }
530
691
  case "e": {
531
692
  const item = selectedSession;
532
693
  if (item !== null && !isPendingId(item.session.id))
@@ -549,7 +710,7 @@ const App = ({ ctx, onQuit }) => {
549
710
  say("still provisioning — nothing to review yet");
550
711
  return;
551
712
  }
552
- const target = reviewTargetForSession(item.session, item.annotation, selectedProject?.project.name ?? "project");
713
+ const target = reviewTargetForSession(item.session, selectedGroup?.annotation ?? item.annotation, selectedProject?.project.name ?? "project");
553
714
  if (target === null) {
554
715
  say("this session has no reviewable change yet");
555
716
  return;
@@ -571,21 +732,25 @@ const App = ({ ctx, onQuit }) => {
571
732
  const sessionsTitle = selectedProject === null
572
733
  ? "sessions"
573
734
  : `sessions — ${selectedProject.project.name}${selectedProject.live > 0 ? ` · ${selectedProject.live} live` : ""}`;
574
- const detailTitle = selectedSession === null
735
+ const detailTitle = selectedSession === null || selectedGroup === null
575
736
  ? "session"
576
- : `session — ${selectedSession.session.harness} ${selectedSession.session.id.slice(0, 8)}`;
737
+ : `worktree — ${selectedGroup.name} · ${selectedSession.session.harness} ${selectedSession.session.id.slice(0, 8)}`;
577
738
  const pickerTitle = picker === null
578
739
  ? ""
579
- : picker.session === null
580
- ? "new session — pick a harness"
581
- : `resume ${picker.session.id.slice(0, 8)} — pick a harness`;
740
+ : picker.worktree !== undefined
741
+ ? `new session in ${picker.worktree.name} — pick a harness`
742
+ : picker.session === null
743
+ ? "new session — pick a harness"
744
+ : `resume ${picker.session.id.slice(0, 8)} — pick a harness`;
582
745
  const footerText = editing !== null
583
746
  ? " 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";
747
+ : naming !== null
748
+ ? " enter continue — the harness picker follows · esc cancel"
749
+ : picker !== null
750
+ ? " ↑↓ move · enter start · esc cancel"
751
+ : focus === "projects"
752
+ ? " ↑↓ move · enter/l sessions · ⇥ panes · r refresh · q quit"
753
+ : " ↑↓ move · enter attach/resume · n new worktree · s session here · x stop · ⇧D remove · v review · e rename · o web · q quit";
589
754
  const loadFailure = data === undefined && failureReason !== null
590
755
  ? failureReason instanceof Error
591
756
  ? failureReason.message
@@ -597,7 +762,10 @@ const App = ({ ctx, onQuit }) => {
597
762
  refetch();
598
763
  }, onQuit: onQuit }));
599
764
  }
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) => {
765
+ 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: [rows.map((row, index) => row.kind === "worktree" ? (_jsx(WorktreeHeaderRow, { group: row.group, selected: index === rowIndex }, rowKeyOf(row))) : row.group.sessions.length === 1 ? (_jsx(CombinedRow, { group: row.group, item: row.item, selected: index === rowIndex }, rowKeyOf(row))) : (_jsx(SessionChildRow, { item: row.item, selected: index === rowIndex }, rowKeyOf(row)))), data !== undefined && rows.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) => {
766
+ if (typeof value === "string")
767
+ submitWorktreeName(value);
768
+ } }) })) : 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
769
  if (typeof value === "string")
602
770
  submitRename(editing, value);
603
771
  } }) })) : 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, sessionDisplayName, 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,39 @@ 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
+ // `--worktree` insists on joining: the name must already exist here.
288
+ let worktreeName;
289
+ if (parsed.worktree !== null) {
290
+ const detail = await api(config, "GET", `/projects/${project.id}`);
291
+ const existing = detail.worktrees ?? [];
292
+ if (detail.worktrees === undefined) {
293
+ return fail("this server predates shared worktrees — use --name instead");
294
+ }
295
+ const match = existing.find((worktree) => worktree.name === parsed.worktree) ??
296
+ (existing.filter((worktree) => worktree.name.startsWith(parsed.worktree ?? "")).length === 1
297
+ ? existing.find((worktree) => worktree.name.startsWith(parsed.worktree ?? ""))
298
+ : undefined);
299
+ if (match === undefined) {
300
+ const names = existing.map((worktree) => worktree.name).join(", ");
301
+ return fail(`no worktree matches "${parsed.worktree}" in ${project.name}${names === "" ? "" : ` — have: ${names}`}`);
302
+ }
303
+ worktreeName = match.name;
304
+ }
305
+ else {
306
+ worktreeName = harness === "run" ? parsed.name : (parsed.name ?? (await askWorktreeName()));
307
+ }
308
+ // Say when the name joins an existing worktree — a join is a fact worth
309
+ // stating before the session exists, not a surprise in the tree later.
310
+ if (worktreeName !== null) {
311
+ const detail = await api(config, "GET", `/projects/${project.id}`).catch(() => null);
312
+ const joined = detail?.worktrees?.find((worktree) => worktree.name === worktreeName);
313
+ if (joined !== undefined) {
314
+ const members = (detail?.sessions ?? []).filter((candidate) => candidate.worktreeId === joined.id).length;
315
+ say(`${green("✓")} joins worktree ${joined.name} ${dim(`· ${members} session${members === 1 ? "" : "s"} · branch ${joined.branch}`)}`);
316
+ }
317
+ }
271
318
  const lifecycle = harness === "run"
272
319
  ? "background"
273
320
  : parsed.detach
@@ -297,6 +344,7 @@ const launch = async (config, harness, args) => {
297
344
  const session = await api(config, "POST", `/projects/${project.id}/sessions`, {
298
345
  harness,
299
346
  label: null,
347
+ name: worktreeName,
300
348
  base: parsed.base,
301
349
  });
302
350
  createdSessionId = session.id;
@@ -423,8 +471,10 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
423
471
  }
424
472
  };
425
473
  const onKeys = (data) => {
426
- if (detachKeyEnabled && data.includes(0x1d)) {
427
- // Ctrl+] — detach, leave the session running.
474
+ if (detachKeyEnabled && isDetachChunk(data)) {
475
+ // Ctrl+] — detach, leave the session running. Matched in both its
476
+ // encodings: the inner TUI may have switched the user's terminal onto
477
+ // the kitty keyboard protocol, where the key arrives as CSI-u.
428
478
  detached = true;
429
479
  ws.close();
430
480
  return;
@@ -463,6 +513,19 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
463
513
  ws.removeEventListener("close", onClose);
464
514
  if (rawModeEnabled)
465
515
  process.stdin.setRawMode(false);
516
+ if (options?.readOnly !== true && process.stdout.isTTY === true) {
517
+ // The inner TUI's terminal modes leaked onto OUR terminal through the
518
+ // byte bridge — kitty keyboard protocol, bracketed paste, mouse
519
+ // reporting, alternate screen, hidden cursor. The TUI keeps running
520
+ // remotely; the local terminal must come back to shell sanity, or every
521
+ // keystroke after a detach arrives as CSI-u junk. A reattach replays
522
+ // the session from 0, which re-establishes whatever the TUI had set.
523
+ process.stdout.write("\x1b[<u\x1b[=0;1u" + // pop the kitty keyboard stack, then force flags 0
524
+ "\x1b[?2004l" + // bracketed paste off
525
+ "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l" + // mouse reporting off
526
+ "\x1b[?1049l" + // leave the alternate screen (no-op when already left)
527
+ "\x1b[?25h");
528
+ }
466
529
  process.stdin.pause();
467
530
  if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)
468
531
  ws.close();
@@ -560,7 +623,7 @@ const attach = async (config, args) => {
560
623
  const match = sessions.find((s) => s.id.startsWith(prefix));
561
624
  if (match === undefined)
562
625
  return fail(`no active session matches "${prefix}"`);
563
- say(`${green("✓")} attaching to ${match.harness} · ${dim(match.id.slice(0, 8))}${detachHint()}`);
626
+ say(`${green("✓")} attaching to ${sessionDisplayName(match)} · ${match.harness} ${dim(match.id.slice(0, 8))}${detachHint()}`);
564
627
  say("");
565
628
  await attachOrExit(config, match.id, match.harness);
566
629
  exitAfterSessionEnd(config, match.id);
@@ -2034,7 +2097,7 @@ const rejoinCommand = async (config, args) => {
2034
2097
  if (session === undefined)
2035
2098
  return fail("session selection failed");
2036
2099
  const alreadyLive = agentLiveIn(detail, session);
2037
- say(`${green("✓")} rejoining ${session.harness} · ${dim(session.id.slice(0, 8))} · ${alreadyLive ? "already live" : "restoring"}`);
2100
+ say(`${green("✓")} rejoining ${sessionDisplayName(session)} · ${session.harness} ${dim(session.id.slice(0, 8))} · ${alreadyLive ? "already live" : "restoring"}`);
2038
2101
  say(`${cobalt(" watch")} · ${config.url}/sessions/${session.id}`);
2039
2102
  let restored = false;
2040
2103
  const currentAgent = detail.annotations.find((annotation) => annotation.sessionId === session.id)?.currentAgent ??
@@ -2135,6 +2198,15 @@ const printSessionRow = (row) => {
2135
2198
  */
2136
2199
  const sessionsCommand = async (config, args) => {
2137
2200
  const all = args.includes("--all");
2201
+ // v1 stays byte-stable for pinned integrations; v2 is the worktree envelope.
2202
+ if (args.includes("--json=v2")) {
2203
+ const projectFlag = args.indexOf("--project");
2204
+ const projectName = projectFlag !== -1 && args[projectFlag + 1] !== undefined
2205
+ ? String(args[projectFlag + 1])
2206
+ : null;
2207
+ say(JSON.stringify(await buildWorktreesJson(config, projectName), null, 2));
2208
+ return;
2209
+ }
2138
2210
  const json = args.includes("--json");
2139
2211
  const projectFlag = args.indexOf("--project");
2140
2212
  const projectName = projectFlag !== -1 && args[projectFlag + 1] !== undefined
@@ -2210,6 +2282,117 @@ const sessionsCommand = async (config, args) => {
2210
2282
  for (const row of rows)
2211
2283
  printSessionRow(row);
2212
2284
  };
2285
+ /**
2286
+ * The worktree-grouped view both `mend worktrees --json` and
2287
+ * `mend sessions --json=v2` emit. Against a pre-worktree server every session
2288
+ * becomes its own pseudo worktree (`id: null`) — the envelope shape is stable
2289
+ * either way, so integrations pin on `version`, not server age.
2290
+ */
2291
+ const toWorktreeJsonSession = (session) => ({
2292
+ id: session.id,
2293
+ harness: session.harness,
2294
+ label: session.label,
2295
+ status: session.status,
2296
+ summary: session.summary,
2297
+ createdAt: session.createdAt,
2298
+ });
2299
+ const buildWorktreesJson = async (config, projectName) => {
2300
+ const projects = await api(config, "GET", "/projects");
2301
+ const scope = projectName === null ? projects : projects.filter((p) => p.name === projectName);
2302
+ const details = await Promise.all(scope.map((p) => api(config, "GET", `/projects/${p.id}`)));
2303
+ const worktrees = [];
2304
+ for (const detail of details) {
2305
+ const reviewOf = (sessionIds) => {
2306
+ const facts = detail.annotations.find((a) => sessionIds.has(a.sessionId));
2307
+ return facts === undefined
2308
+ ? null
2309
+ : {
2310
+ openComments: facts.openComments,
2311
+ totalComments: facts.totalComments,
2312
+ pendingFollowUp: detail.annotations.some((a) => sessionIds.has(a.sessionId) && a.pendingFollowUp),
2313
+ };
2314
+ };
2315
+ const urlOf = (sessionIds) => {
2316
+ const first = [...sessionIds][0];
2317
+ return first === undefined ? null : `${config.url.replace(/\/$/, "")}/sessions/${first}`;
2318
+ };
2319
+ if (detail.worktrees !== undefined) {
2320
+ for (const worktree of detail.worktrees) {
2321
+ const members = detail.sessions.filter((session) => session.worktreeId === worktree.id);
2322
+ const ids = new Set(members.map((session) => session.id));
2323
+ worktrees.push({
2324
+ id: worktree.id,
2325
+ name: worktree.name,
2326
+ projectId: detail.project.id,
2327
+ projectName: detail.project.name,
2328
+ branch: worktree.branch,
2329
+ baseSha: worktree.baseSha,
2330
+ baseRef: worktree.baseRef,
2331
+ createdAt: worktree.createdAt,
2332
+ reviewUrl: urlOf(ids),
2333
+ review: reviewOf(ids),
2334
+ sessions: members
2335
+ .toSorted((a, b) => b.createdAt.localeCompare(a.createdAt))
2336
+ .map(toWorktreeJsonSession),
2337
+ });
2338
+ }
2339
+ }
2340
+ else {
2341
+ for (const session of detail.sessions) {
2342
+ const ids = new Set([session.id]);
2343
+ worktrees.push({
2344
+ id: null,
2345
+ name: session.worktree,
2346
+ projectId: detail.project.id,
2347
+ projectName: detail.project.name,
2348
+ branch: session.branch,
2349
+ baseSha: session.baseSha,
2350
+ baseRef: session.baseRef,
2351
+ createdAt: session.createdAt,
2352
+ reviewUrl: urlOf(ids),
2353
+ review: reviewOf(ids),
2354
+ sessions: [toWorktreeJsonSession(session)],
2355
+ });
2356
+ }
2357
+ }
2358
+ }
2359
+ return { version: 2, worktrees };
2360
+ };
2361
+ /** `mend worktrees [--project <p>] [--json]` — the container-first listing. */
2362
+ const worktreesCommand = async (config, args) => {
2363
+ const json = args.includes("--json");
2364
+ const projectFlag = args.indexOf("--project");
2365
+ const projectName = projectFlag !== -1 && args[projectFlag + 1] !== undefined
2366
+ ? String(args[projectFlag + 1])
2367
+ : null;
2368
+ const payload = await buildWorktreesJson(config, projectName);
2369
+ if (json) {
2370
+ say(JSON.stringify(payload, null, 2));
2371
+ return;
2372
+ }
2373
+ if (payload.worktrees.length === 0) {
2374
+ say(dim("no worktrees yet — mend claude --name <worktree> starts one"));
2375
+ return;
2376
+ }
2377
+ for (const worktree of payload.worktrees) {
2378
+ const live = worktree.sessions.filter((session) => ACTIVE_STATUSES.has(session.status)).length;
2379
+ const facts = [
2380
+ `${worktree.sessions.length} session${worktree.sessions.length === 1 ? "" : "s"}`,
2381
+ ];
2382
+ if (live > 0)
2383
+ facts.push(green(`${live} live`));
2384
+ if (worktree.review !== null && worktree.review.openComments > 0) {
2385
+ facts.push(amber(`${worktree.review.openComments} open`));
2386
+ }
2387
+ const base = worktree.baseRef ?? worktree.baseSha.slice(0, 12);
2388
+ say(`${worktree.name.padEnd(24)} ${worktree.projectName} ${dim(`${worktree.branch} · base ${base}`)} ${facts.join(dim(" · "))}`);
2389
+ for (const session of worktree.sessions) {
2390
+ const status = session.status.padEnd(9);
2391
+ const name = session.label ?? `session ${session.id.slice(0, 8)}`;
2392
+ say(` └ ${session.harness.padEnd(8)} ${dim(session.id.slice(0, 8))} ${ACTIVE_STATUSES.has(session.status) ? green(status) : dim(status)} ${name}`);
2393
+ }
2394
+ }
2395
+ };
2213
2396
  // ─── dashboard: the live TUI on bare `mend` ─────────────────────────────────
2214
2397
  const hasNodeFfi = () => {
2215
2398
  try {
@@ -2262,10 +2445,14 @@ start
2262
2445
  mend adopt [source] [--name <name>] [--auth ambient|mend-key|bridge]
2263
2446
  adopt a repository into the store (default: cwd; any git
2264
2447
  URL — GitHub, GitLab, self-hosted, ssh://, a local path)
2265
- mend codex|claude|opencode ["prompt"] [--model <id>] [--effort low|medium|high|xhigh|max]
2448
+ mend codex|claude|opencode ["prompt"] [--name <worktree>] [--worktree <existing>]
2449
+ [--model <id>] [--effort low|medium|high|xhigh|max]
2266
2450
  [--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),
2451
+ launch the harness in a worktree; the worktree's name is
2452
+ asked first (--name skips the ask — an EXISTING name joins
2453
+ that worktree as a new session; --worktree joins only,
2454
+ failing if absent), a
2455
+ quoted prompt becomes its first message,
2269
2456
  --ask restores the harness's permission prompts, --fast
2270
2457
  requests priority processing (codex service tier),
2271
2458
  --detach launches without attaching (reattach anywhere),
@@ -2290,6 +2477,9 @@ everything else
2290
2477
  mend keys share relay THIS machine's ssh-agent to the server (bridge mode:
2291
2478
  hardware keys sign here; Ctrl-C stops sharing)
2292
2479
  mend run -- <command...> same, with an arbitrary command
2480
+ mend worktrees [--project <p>] [--json]
2481
+ every worktree and the sessions inside it (mend sessions
2482
+ --json stays the v1 flat list; --json=v2 groups like this)
2293
2483
  mend attach <session-id-prefix> reattach this terminal to a running session
2294
2484
  mend stop <session-id-prefix> | --all [--project <p>]
2295
2485
  stop the agent — the workspace harvests and closes; the
@@ -2386,6 +2576,8 @@ const main = async () => {
2386
2576
  case "sessions":
2387
2577
  case "status":
2388
2578
  return sessionsCommand(config, rest);
2579
+ case "worktrees":
2580
+ return worktreesCommand(config, rest);
2389
2581
  case undefined:
2390
2582
  case "ui":
2391
2583
  return dashboard(config);
package/dist/shared.js CHANGED
@@ -34,6 +34,8 @@ const LAUNCH_ERROR = {
34
34
  model: null,
35
35
  effort: null,
36
36
  base: null,
37
+ name: null,
38
+ worktree: null,
37
39
  ask: false,
38
40
  fast: false,
39
41
  detach: false,
@@ -56,6 +58,8 @@ export const parseLaunchArgs = (args) => {
56
58
  let model = null;
57
59
  let effort = null;
58
60
  let base = null;
61
+ let workName = null;
62
+ let joinWorktree = null;
59
63
  let ask = false;
60
64
  let fast = false;
61
65
  let detach = false;
@@ -78,7 +82,12 @@ export const parseLaunchArgs = (args) => {
78
82
  foreground = true;
79
83
  continue;
80
84
  }
81
- if (arg === "--project" || arg === "--model" || arg === "--effort" || arg === "--base") {
85
+ if (arg === "--project" ||
86
+ arg === "--model" ||
87
+ arg === "--effort" ||
88
+ arg === "--base" ||
89
+ arg === "--name" ||
90
+ arg === "--worktree") {
82
91
  const value = flagArgs[index + 1];
83
92
  if (value === undefined)
84
93
  return { ...LAUNCH_ERROR, error: `${arg} needs a value` };
@@ -88,6 +97,10 @@ export const parseLaunchArgs = (args) => {
88
97
  model = value;
89
98
  else if (arg === "--effort")
90
99
  effort = value;
100
+ else if (arg === "--name")
101
+ workName = normalizeProjectName(value);
102
+ else if (arg === "--worktree")
103
+ joinWorktree = value;
91
104
  else
92
105
  base = value;
93
106
  index += 1;
@@ -115,12 +128,20 @@ export const parseLaunchArgs = (args) => {
115
128
  if (detach && foreground) {
116
129
  return { ...LAUNCH_ERROR, error: "--detach and --foreground contradict — pick one" };
117
130
  }
131
+ if (workName !== null && joinWorktree !== null) {
132
+ return {
133
+ ...LAUNCH_ERROR,
134
+ error: "--name and --worktree contradict — --name names (or joins), --worktree only joins",
135
+ };
136
+ }
118
137
  return {
119
138
  project,
120
139
  prompt,
121
140
  model,
122
141
  effort,
123
142
  base,
143
+ name: workName,
144
+ worktree: joinWorktree,
124
145
  ask,
125
146
  fast,
126
147
  detach,
@@ -129,6 +150,42 @@ export const parseLaunchArgs = (args) => {
129
150
  error: null,
130
151
  };
131
152
  };
153
+ /**
154
+ * Whether a raw stdin chunk carries the detach key (Ctrl+]). Two encodings
155
+ * reach us: the legacy control byte 0x1d — and, once an inner TUI (claude)
156
+ * pushes the kitty keyboard protocol through the PTY and the user's own
157
+ * terminal honors it, the CSI-u escape `ESC [ 93 ; 5 u` (`]` is code 93,
158
+ * ctrl is modifier 5), optionally with a kitty event-type suffix. Only press
159
+ * (`:1`) and repeat (`:2`) are intent; a release (`:3`) is not.
160
+ */
161
+ export const isDetachChunk = (data) => {
162
+ if (data.includes(0x1d))
163
+ return true;
164
+ // latin1 maps bytes 1:1, so the scan sees exactly the wire bytes. A string
165
+ // scan rather than a regex: an escape byte inside a pattern trips lint, and
166
+ // anchoring on the real ESC[ prefix keeps pasted text from ever matching.
167
+ const text = data.toString("latin1");
168
+ const prefix = "\u001b[93;5";
169
+ for (let start = text.indexOf(prefix); start !== -1; start = text.indexOf(prefix, start + 1)) {
170
+ const rest = text.slice(start + prefix.length);
171
+ if (rest.startsWith("u") || rest.startsWith(":1u") || rest.startsWith(":2u"))
172
+ return true;
173
+ }
174
+ return false;
175
+ };
176
+ /**
177
+ * What to call a session's worktree in banners and rows: a NAMED worktree is
178
+ * its branch minus the `mend/` prefix; an unnamed one (`mend/session/<uuid>`)
179
+ * is called by its auto-name label, or its short id before one lands.
180
+ */
181
+ export const sessionDisplayName = (session) => {
182
+ if (session.branch.startsWith("mend/") && !session.branch.startsWith("mend/session/")) {
183
+ return session.branch.slice("mend/".length);
184
+ }
185
+ if (!session.branch.startsWith("mend/session/"))
186
+ return session.branch;
187
+ return session.label ?? `session ${session.id.slice(0, 8)}`;
188
+ };
132
189
  export const LIVE_STATUSES = new Set([
133
190
  "starting",
134
191
  "running",
@@ -55,6 +55,7 @@ export const eventFamilies = (payload) => {
55
55
  return [];
56
56
  switch (parsed.type) {
57
57
  case "project":
58
+ case "worktree":
58
59
  case "session-process":
59
60
  return ["workbench"];
60
61
  // Session facts feed both surfaces: rows and annotations on the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sealant/mend",
3
- "version": "0.13.0",
3
+ "version": "0.15.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",