@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.
- package/dist/dashboard-model.js +202 -0
- package/dist/dashboard.js +301 -133
- package/dist/main.js +200 -8
- package/dist/shared.js +58 -1
- package/dist/workbench-events.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
142
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
191
|
-
scroll.scrollTo(
|
|
192
|
-
else if (
|
|
193
|
-
scroll.scrollTo(
|
|
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
|
|
236
|
-
const
|
|
237
|
-
const
|
|
238
|
-
const
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
536
|
+
if (rows.length === 0)
|
|
426
537
|
return;
|
|
427
|
-
const next = Math.max(0, Math.min(
|
|
428
|
-
const
|
|
429
|
-
if (
|
|
430
|
-
setSessionKey(
|
|
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.
|
|
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
|
-
|
|
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
|
-
: `
|
|
737
|
+
: `worktree — ${selectedGroup.name} · ${selectedSession.session.harness} ${selectedSession.session.id.slice(0, 8)}`;
|
|
577
738
|
const pickerTitle = picker === null
|
|
578
739
|
? ""
|
|
579
|
-
: picker.
|
|
580
|
-
?
|
|
581
|
-
:
|
|
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
|
-
:
|
|
585
|
-
? "
|
|
586
|
-
:
|
|
587
|
-
? " ↑↓ move · enter
|
|
588
|
-
:
|
|
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: [
|
|
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
|
|
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
|
|
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
|
|
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"] [--
|
|
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
|
-
|
|
2268
|
-
|
|
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" ||
|
|
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",
|
package/dist/workbench-events.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sealant/mend",
|
|
3
|
-
"version": "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",
|