@sealant/mend 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -6
- package/dist/chunk-4MX7ZC34.js +27894 -0
- package/dist/dashboard-YEU7SDYB.js +2951 -0
- package/dist/main.js +6917 -2580
- package/man/mend-accounts.1 +1 -1
- package/man/mend-adopt.1 +4 -4
- package/man/mend-attach.1 +1 -1
- package/man/mend-codex.1 +1 -1
- package/man/mend-completions.1 +1 -1
- package/man/mend-connect.1 +1 -1
- package/man/mend-continue.1 +1 -1
- package/man/mend-doctor.1 +1 -1
- package/man/mend-dotfiles-sync.1 +1 -1
- package/man/mend-dotfiles.1 +1 -1
- package/man/mend-env-cluster.1 +1 -1
- package/man/mend-env-load.1 +1 -1
- package/man/mend-env-show.1 +1 -1
- package/man/mend-help.1 +1 -1
- package/man/mend-keys-init.1 +1 -1
- package/man/mend-keys-mode.1 +1 -1
- package/man/mend-keys-share.1 +1 -1
- package/man/mend-keys-show.1 +1 -1
- package/man/mend-login.1 +1 -1
- package/man/mend-logout.1 +1 -1
- package/man/mend-man.1 +1 -1
- package/man/mend-pair.1 +1 -1
- package/man/mend-projects.1 +1 -1
- package/man/mend-refresh.1 +1 -1
- package/man/mend-rejoin.1 +1 -1
- package/man/mend-resume.1 +1 -1
- package/man/mend-run.1 +1 -1
- package/man/mend-server-logs.1 +15 -0
- package/man/mend-server-restart.1 +15 -0
- package/man/mend-server-setup.1 +64 -0
- package/man/mend-server-start.1 +15 -0
- package/man/mend-server-status.1 +11 -0
- package/man/mend-server-stop.1 +11 -0
- package/man/mend-server-upgrade.1 +30 -0
- package/man/mend-server.1 +11 -0
- package/man/mend-service-add.1 +1 -1
- package/man/mend-service-connect.1 +1 -1
- package/man/mend-service-init.1 +1 -1
- package/man/mend-service-list.1 +1 -1
- package/man/mend-service-logs.1 +1 -1
- package/man/mend-service-restart.1 +1 -1
- package/man/mend-service-run.1 +1 -1
- package/man/mend-service-stop.1 +1 -1
- package/man/mend-sessions.1 +1 -1
- package/man/mend-shell.1 +1 -1
- package/man/mend-skills-push.1 +1 -1
- package/man/mend-skills.1 +1 -1
- package/man/mend-ssh-setup.1 +6 -3
- package/man/mend-ssh.1 +1 -1
- package/man/mend-stop.1 +1 -1
- package/man/mend-ui.1 +1 -1
- package/man/mend-version.1 +1 -1
- package/man/mend-worktrees.1 +1 -1
- package/man/mend.1 +26 -2
- package/package.json +7 -3
- package/dist/agent-share.js +0 -150
- package/dist/clipboard.js +0 -73
- package/dist/dashboard-model.js +0 -308
- package/dist/dashboard.js +0 -1200
- package/dist/doctor.js +0 -233
- package/dist/dotfiles.js +0 -90
- package/dist/env.js +0 -17
- package/dist/help.js +0 -839
- package/dist/login.js +0 -240
- package/dist/pair.js +0 -83
- package/dist/review-model.js +0 -212
- package/dist/review-workflow.js +0 -8
- package/dist/review.js +0 -690
- package/dist/service-init.js +0 -227
- package/dist/shared.js +0 -338
- package/dist/skills.js +0 -134
- package/dist/ssh-setup.js +0 -181
- package/dist/terminal.js +0 -8
- package/dist/tui-theme.js +0 -41
- package/dist/version.js +0 -48
- package/dist/workbench-events.js +0 -72
|
@@ -0,0 +1,2951 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HARNESS_COMMANDS,
|
|
3
|
+
LIVE_STATUSES,
|
|
4
|
+
RepositoryCloneUrl,
|
|
5
|
+
cwdFacts,
|
|
6
|
+
isPendingId,
|
|
7
|
+
matchProjectByCwd,
|
|
8
|
+
normalizeProjectName,
|
|
9
|
+
pendingId,
|
|
10
|
+
repositoryCloneUrlIssue
|
|
11
|
+
} from "./chunk-4MX7ZC34.js";
|
|
12
|
+
|
|
13
|
+
// src/dashboard.tsx
|
|
14
|
+
import { CliRenderEvents, createCliRenderer } from "@opentui/core";
|
|
15
|
+
import { createRoot, useKeyboard as useKeyboard2, useRenderer, useTerminalDimensions as useTerminalDimensions2 } from "@opentui/react";
|
|
16
|
+
import {
|
|
17
|
+
QueryClient,
|
|
18
|
+
QueryClientProvider,
|
|
19
|
+
useMutation as useMutation2,
|
|
20
|
+
useQuery as useQuery2,
|
|
21
|
+
useQueryClient as useQueryClient2
|
|
22
|
+
} from "@tanstack/react-query";
|
|
23
|
+
import { useEffect as useEffect2, useRef as useRef2, useState as useState2, useSyncExternalStore } from "react";
|
|
24
|
+
|
|
25
|
+
// src/dashboard-adoption.ts
|
|
26
|
+
var deriveAdoptOffer = (facts) => {
|
|
27
|
+
if (facts.repoRoot === null || facts.originUrl === null || repositoryCloneUrlIssue(facts.originUrl) !== null)
|
|
28
|
+
return null;
|
|
29
|
+
return {
|
|
30
|
+
source: RepositoryCloneUrl.make(facts.originUrl),
|
|
31
|
+
name: normalizeProjectName(facts.repoRoot.split("/").at(-1) ?? "project"),
|
|
32
|
+
modeIndex: 0
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
var submitDashboardAdoption = async (api, input) => {
|
|
36
|
+
const issue = repositoryCloneUrlIssue(input.source);
|
|
37
|
+
if (issue !== null) return { kind: "invalid-source", message: issue };
|
|
38
|
+
const project = await api("POST", "/projects", {
|
|
39
|
+
name: input.name,
|
|
40
|
+
source: RepositoryCloneUrl.make(input.source),
|
|
41
|
+
gitAuthMode: input.gitAuthMode
|
|
42
|
+
});
|
|
43
|
+
return { kind: "adopted", project };
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// src/dashboard-model.ts
|
|
47
|
+
var worktreeName = (branch) => branch.startsWith("mend/") ? branch.slice("mend/".length) : branch;
|
|
48
|
+
var worktreeDisplayName = (session) => {
|
|
49
|
+
const name = worktreeName(session.branch);
|
|
50
|
+
if (!name.startsWith("session/")) return name;
|
|
51
|
+
return session.label ?? `session ${session.id.slice(0, 8)}`;
|
|
52
|
+
};
|
|
53
|
+
var WORKBENCH_KEY = ["workbench"];
|
|
54
|
+
var fetchWorkbench = async (ctx) => {
|
|
55
|
+
const projects = await ctx.api("GET", "/projects");
|
|
56
|
+
const [fetched, services] = await Promise.all([
|
|
57
|
+
Promise.all(
|
|
58
|
+
projects.map((project) => ctx.api("GET", `/projects/${project.id}`))
|
|
59
|
+
),
|
|
60
|
+
ctx.api("GET", "/services")
|
|
61
|
+
]);
|
|
62
|
+
const servicesBySession = /* @__PURE__ */ new Map();
|
|
63
|
+
for (const service of services) {
|
|
64
|
+
const bucket = servicesBySession.get(service.sessionId) ?? [];
|
|
65
|
+
bucket.push(service);
|
|
66
|
+
servicesBySession.set(service.sessionId, bucket);
|
|
67
|
+
}
|
|
68
|
+
const liveSessions = fetched.flatMap(
|
|
69
|
+
(detail) => detail.sessions.filter((session) => LIVE_STATUSES.has(session.status))
|
|
70
|
+
);
|
|
71
|
+
const detailed = await Promise.all(
|
|
72
|
+
liveSessions.map(async (session) => {
|
|
73
|
+
try {
|
|
74
|
+
return await ctx.api("GET", `/sessions/${session.id}`);
|
|
75
|
+
} catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
);
|
|
80
|
+
const processesBySession = /* @__PURE__ */ new Map();
|
|
81
|
+
for (const detail of detailed) {
|
|
82
|
+
if (detail === null) continue;
|
|
83
|
+
processesBySession.set(
|
|
84
|
+
detail.session.id,
|
|
85
|
+
detail.processes.filter((process2) => process2.exitedAt === null && process2.kind !== "service")
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
projects,
|
|
90
|
+
details: new Map(fetched.map((detail) => [detail.project.id, detail])),
|
|
91
|
+
servicesBySession,
|
|
92
|
+
processesBySession
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
var mapWorkbenchSessions = (data, f) => ({
|
|
96
|
+
...data,
|
|
97
|
+
details: new Map(
|
|
98
|
+
[...data.details].map(([id, detail]) => [id, { ...detail, sessions: detail.sessions.map(f) }])
|
|
99
|
+
)
|
|
100
|
+
});
|
|
101
|
+
var mapProjectSessions = (data, projectId, f) => {
|
|
102
|
+
const detail = data.details.get(projectId);
|
|
103
|
+
if (detail === void 0) return data;
|
|
104
|
+
const details = new Map(data.details);
|
|
105
|
+
details.set(projectId, { ...detail, sessions: f(detail.sessions) });
|
|
106
|
+
return { ...data, details };
|
|
107
|
+
};
|
|
108
|
+
var prependSession = (data, projectId, session) => mapProjectSessions(data, projectId, (sessions) => [session, ...sessions]);
|
|
109
|
+
var replaceSession = (data, projectId, oldId, session) => mapProjectSessions(
|
|
110
|
+
data,
|
|
111
|
+
projectId,
|
|
112
|
+
(sessions) => sessions.map((candidate) => candidate.id === oldId ? session : candidate)
|
|
113
|
+
);
|
|
114
|
+
var removeSession = (data, projectId, sessionId) => mapProjectSessions(
|
|
115
|
+
data,
|
|
116
|
+
projectId,
|
|
117
|
+
(sessions) => sessions.filter((candidate) => candidate.id !== sessionId)
|
|
118
|
+
);
|
|
119
|
+
var deriveProjects = (data) => (data?.projects ?? []).map((project) => {
|
|
120
|
+
const detail = data?.details.get(project.id);
|
|
121
|
+
const sessions = detail?.sessions ?? [];
|
|
122
|
+
const live = sessions.filter((s) => LIVE_STATUSES.has(s.status)).length;
|
|
123
|
+
const open = (detail?.annotations ?? []).reduce((sum, a) => sum + a.openComments, 0);
|
|
124
|
+
return { project, total: sessions.length, live, open };
|
|
125
|
+
});
|
|
126
|
+
var bySessionRecency = (a, b) => {
|
|
127
|
+
const aLive = LIVE_STATUSES.has(a.status) ? 1 : 0;
|
|
128
|
+
const bLive = LIVE_STATUSES.has(b.status) ? 1 : 0;
|
|
129
|
+
if (aLive !== bLive) return bLive - aLive;
|
|
130
|
+
return b.createdAt.localeCompare(a.createdAt);
|
|
131
|
+
};
|
|
132
|
+
var toSessionItem = (data, detail) => (session) => ({
|
|
133
|
+
session,
|
|
134
|
+
annotation: detail.annotations.find((a) => a.sessionId === session.id),
|
|
135
|
+
services: data.servicesBySession.get(session.id) ?? [],
|
|
136
|
+
processes: data.processesBySession.get(session.id) ?? []
|
|
137
|
+
});
|
|
138
|
+
var isDeadEnd = (session) => !LIVE_STATUSES.has(session.status) && session.hasTranscript === false;
|
|
139
|
+
var deriveWorktrees = (data, projectId) => {
|
|
140
|
+
if (data === void 0 || projectId === null) return [];
|
|
141
|
+
const detail = data.details.get(projectId);
|
|
142
|
+
if (detail === void 0) return [];
|
|
143
|
+
const item = toSessionItem(data, detail);
|
|
144
|
+
const sorted = detail.sessions.filter((session) => !isDeadEnd(session)).toSorted(bySessionRecency);
|
|
145
|
+
const groups = [];
|
|
146
|
+
if (detail.worktrees !== void 0) {
|
|
147
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
148
|
+
for (const worktree of detail.worktrees) {
|
|
149
|
+
const members = sorted.filter((session) => session.worktreeId === worktree.id);
|
|
150
|
+
for (const member of members) claimed.add(member.id);
|
|
151
|
+
groups.push({
|
|
152
|
+
key: worktree.id,
|
|
153
|
+
id: worktree.id,
|
|
154
|
+
name: worktree.name.startsWith("wt-") ? members.find((m) => m.label !== null)?.label ?? (members[0] === void 0 ? worktree.name : `session ${members[0].id.slice(0, 8)}`) : worktree.name,
|
|
155
|
+
branch: worktree.branch,
|
|
156
|
+
baseRef: worktree.baseRef,
|
|
157
|
+
createdAt: worktree.createdAt,
|
|
158
|
+
sessions: members.map(item),
|
|
159
|
+
live: members.filter((session) => LIVE_STATUSES.has(session.status)).length,
|
|
160
|
+
annotation: detail.annotations.find((a) => members.some((m) => m.id === a.sessionId))
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
for (const session of sorted) {
|
|
164
|
+
if (claimed.has(session.id)) continue;
|
|
165
|
+
groups.push(pseudoGroup(session, item));
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
for (const session of sorted) groups.push(pseudoGroup(session, item));
|
|
169
|
+
}
|
|
170
|
+
return groups.toSorted((a, b) => {
|
|
171
|
+
if (a.live > 0 !== b.live > 0) return a.live > 0 ? -1 : 1;
|
|
172
|
+
const aNewest = a.sessions[0]?.session.createdAt ?? a.createdAt;
|
|
173
|
+
const bNewest = b.sessions[0]?.session.createdAt ?? b.createdAt;
|
|
174
|
+
return bNewest.localeCompare(aNewest);
|
|
175
|
+
});
|
|
176
|
+
};
|
|
177
|
+
var pseudoGroup = (session, item) => ({
|
|
178
|
+
key: session.id,
|
|
179
|
+
id: null,
|
|
180
|
+
name: worktreeDisplayName(session),
|
|
181
|
+
branch: session.branch,
|
|
182
|
+
baseRef: session.baseRef,
|
|
183
|
+
createdAt: session.createdAt,
|
|
184
|
+
sessions: [item(session)],
|
|
185
|
+
live: LIVE_STATUSES.has(session.status) ? 1 : 0,
|
|
186
|
+
annotation: item(session).annotation
|
|
187
|
+
});
|
|
188
|
+
var deriveRows = (groups) => groups.flatMap(
|
|
189
|
+
(group) => [
|
|
190
|
+
{ kind: "worktree", group },
|
|
191
|
+
...group.sessions.map((item) => ({ kind: "session", group, item }))
|
|
192
|
+
]
|
|
193
|
+
);
|
|
194
|
+
var rowKeyOf = (row) => row.kind === "worktree" ? `wt:${row.group.key}` : `s:${row.item.session.id}`;
|
|
195
|
+
var deriveHarnesses = (resuming) => {
|
|
196
|
+
if (resuming !== null) {
|
|
197
|
+
const others = Object.keys(HARNESS_COMMANDS).filter((h) => h !== resuming.harness);
|
|
198
|
+
return [
|
|
199
|
+
{
|
|
200
|
+
harness: null,
|
|
201
|
+
label: resuming.harness,
|
|
202
|
+
hint: "same harness \u2014 native resume, conversation intact"
|
|
203
|
+
},
|
|
204
|
+
...others.map(
|
|
205
|
+
(harness) => ({
|
|
206
|
+
harness,
|
|
207
|
+
label: harness,
|
|
208
|
+
hint: harness === "shell" ? "a bash in the worktree \u2014 resume either agent from inside" : "the conversation crosses as a distilled prompt"
|
|
209
|
+
})
|
|
210
|
+
)
|
|
211
|
+
];
|
|
212
|
+
}
|
|
213
|
+
return Object.keys(HARNESS_COMMANDS).map(
|
|
214
|
+
(harness) => ({
|
|
215
|
+
harness,
|
|
216
|
+
label: harness,
|
|
217
|
+
hint: harness === "shell" ? "a plain bash session \u2014 new worktree, recorded" : `mend ${harness} \u2014 new worktree, recorded session`
|
|
218
|
+
})
|
|
219
|
+
);
|
|
220
|
+
};
|
|
221
|
+
var foldGroupStatus = (group) => {
|
|
222
|
+
const statuses = group.sessions.map((item) => item.session.status);
|
|
223
|
+
if (statuses.includes("waiting")) return "waiting";
|
|
224
|
+
if (statuses.includes("running") || statuses.includes("starting")) return "running";
|
|
225
|
+
if (statuses.includes("idle")) return "idle";
|
|
226
|
+
return statuses[0] ?? "idle";
|
|
227
|
+
};
|
|
228
|
+
var liveShellOf = (processes) => processes.findLast((process2) => process2.kind === "shell" && process2.exitedAt === null) ?? null;
|
|
229
|
+
var liveProtocolOf = (processes) => processes.find((process2) => process2.kind === "agent-protocol" && process2.exitedAt === null) ?? null;
|
|
230
|
+
var markSessionStopped = (data, sessionId) => {
|
|
231
|
+
const processesBySession = new Map(data.processesBySession);
|
|
232
|
+
processesBySession.delete(sessionId);
|
|
233
|
+
const servicesBySession = new Map(data.servicesBySession);
|
|
234
|
+
servicesBySession.delete(sessionId);
|
|
235
|
+
return {
|
|
236
|
+
...mapWorkbenchSessions(
|
|
237
|
+
data,
|
|
238
|
+
(session) => session.id === sessionId ? { ...session, status: "stopped" } : session
|
|
239
|
+
),
|
|
240
|
+
processesBySession,
|
|
241
|
+
servicesBySession
|
|
242
|
+
};
|
|
243
|
+
};
|
|
244
|
+
var removeWorktreeGroup = (data, projectId, group) => {
|
|
245
|
+
const memberIds = new Set(group.sessions.map((item) => item.session.id));
|
|
246
|
+
const detail = data.details.get(projectId);
|
|
247
|
+
if (detail === void 0) return data;
|
|
248
|
+
const details = new Map(data.details);
|
|
249
|
+
details.set(projectId, {
|
|
250
|
+
...detail,
|
|
251
|
+
sessions: detail.sessions.filter((session) => !memberIds.has(session.id)),
|
|
252
|
+
...detail.worktrees === void 0 ? {} : { worktrees: detail.worktrees.filter((worktree) => worktree.id !== group.id) }
|
|
253
|
+
});
|
|
254
|
+
return { ...data, details };
|
|
255
|
+
};
|
|
256
|
+
var fuzzyScore = (query, candidate) => {
|
|
257
|
+
if (query === "") return 0;
|
|
258
|
+
const q = query.toLowerCase();
|
|
259
|
+
const c = candidate.toLowerCase();
|
|
260
|
+
let score = 0;
|
|
261
|
+
let last = -1;
|
|
262
|
+
for (const char of q) {
|
|
263
|
+
const at = c.indexOf(char, last + 1);
|
|
264
|
+
if (at === -1) return null;
|
|
265
|
+
if (at === last + 1) score += 3;
|
|
266
|
+
else if (at === 0 || "/-_.".includes(c[at - 1] ?? "")) score += 2;
|
|
267
|
+
else score += 1;
|
|
268
|
+
score -= (at - last - 1) * 0.01;
|
|
269
|
+
last = at;
|
|
270
|
+
}
|
|
271
|
+
return score;
|
|
272
|
+
};
|
|
273
|
+
var filterBranches = (branches, query) => {
|
|
274
|
+
const scored = branches.flatMap((branch) => {
|
|
275
|
+
const score = fuzzyScore(query, branch.name);
|
|
276
|
+
return score === null ? [] : [{ branch, score }];
|
|
277
|
+
});
|
|
278
|
+
return scored.toSorted((a, b) => {
|
|
279
|
+
if (query === "" && a.branch.isDefault !== b.branch.isDefault) {
|
|
280
|
+
return a.branch.isDefault ? -1 : 1;
|
|
281
|
+
}
|
|
282
|
+
if (a.score !== b.score) return b.score - a.score;
|
|
283
|
+
return b.branch.committedAt.localeCompare(a.branch.committedAt);
|
|
284
|
+
}).map((entry) => entry.branch);
|
|
285
|
+
};
|
|
286
|
+
var advanceFromBase = (current) => {
|
|
287
|
+
const chosen = filterBranches(current.branches ?? [], current.query)[current.baseIndex];
|
|
288
|
+
return {
|
|
289
|
+
...current,
|
|
290
|
+
base: chosen === void 0 || chosen.isDefault ? null : chosen.name,
|
|
291
|
+
step: "harness"
|
|
292
|
+
};
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// src/review-workflow.ts
|
|
296
|
+
var reviewTargetForSession = (session, annotation, projectName) => annotation?.changeId === null || annotation?.changeId === void 0 ? null : { session, changeId: annotation.changeId, projectName };
|
|
297
|
+
var commentRange = (start, current) => start === null ? { line: current, endLine: null } : { line: Math.min(start, current), endLine: Math.max(start, current) };
|
|
298
|
+
var deliverReview = (api, sessionId, input) => api("POST", `/sessions/${sessionId}/follow-up/deliver`, input);
|
|
299
|
+
|
|
300
|
+
// src/review.tsx
|
|
301
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
302
|
+
import {
|
|
303
|
+
RGBA,
|
|
304
|
+
SyntaxStyle
|
|
305
|
+
} from "@opentui/core";
|
|
306
|
+
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
307
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
308
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
309
|
+
|
|
310
|
+
// src/review-model.ts
|
|
311
|
+
import * as path from "node:path";
|
|
312
|
+
var FILETYPE_BY_EXTENSION = {
|
|
313
|
+
c: "c",
|
|
314
|
+
cc: "cpp",
|
|
315
|
+
cpp: "cpp",
|
|
316
|
+
css: "css",
|
|
317
|
+
go: "go",
|
|
318
|
+
h: "c",
|
|
319
|
+
hpp: "cpp",
|
|
320
|
+
html: "html",
|
|
321
|
+
java: "java",
|
|
322
|
+
js: "javascript",
|
|
323
|
+
json: "json",
|
|
324
|
+
jsx: "javascript",
|
|
325
|
+
md: "markdown",
|
|
326
|
+
mjs: "javascript",
|
|
327
|
+
py: "python",
|
|
328
|
+
rb: "ruby",
|
|
329
|
+
rs: "rust",
|
|
330
|
+
sh: "bash",
|
|
331
|
+
sql: "sql",
|
|
332
|
+
swift: "swift",
|
|
333
|
+
toml: "toml",
|
|
334
|
+
ts: "typescript",
|
|
335
|
+
tsx: "tsx",
|
|
336
|
+
yaml: "yaml",
|
|
337
|
+
yml: "yaml",
|
|
338
|
+
zsh: "bash"
|
|
339
|
+
};
|
|
340
|
+
var filetypeOf = (name) => {
|
|
341
|
+
const extension = path.extname(name).slice(1).toLowerCase();
|
|
342
|
+
return extension === "" ? void 0 : FILETYPE_BY_EXTENSION[extension] ?? extension;
|
|
343
|
+
};
|
|
344
|
+
var statusOf = (patch) => {
|
|
345
|
+
if (/^new file mode /m.test(patch)) return "added";
|
|
346
|
+
if (/^deleted file mode /m.test(patch)) return "deleted";
|
|
347
|
+
if (/^rename (?:from|to) /m.test(patch)) return "renamed";
|
|
348
|
+
return "modified";
|
|
349
|
+
};
|
|
350
|
+
var isLikelyGenerated = (name) => /(^|\/)(?:dist|build|generated)(?:\/|$)/i.test(name) || /(?:\.generated\.|\.min\.(?:js|css)$|(?:^|\/)(?:pnpm-lock\.yaml|package-lock\.json|yarn\.lock)$)/i.test(
|
|
351
|
+
name
|
|
352
|
+
);
|
|
353
|
+
var visibleWhitespace = (patch) => patch.split("\n").map((line) => {
|
|
354
|
+
const hunkContent = line.startsWith("+") && !line.startsWith("+++") || line.startsWith("-") && !line.startsWith("---") || line.startsWith(" ");
|
|
355
|
+
if (!hunkContent) return line;
|
|
356
|
+
const prefix = line.slice(0, 1);
|
|
357
|
+
const content = line.slice(1).replaceAll(" ", "\u2192\xB7\xB7\xB7\xB7").replaceAll(" ", "\xB7");
|
|
358
|
+
return `${prefix}${content}`;
|
|
359
|
+
}).join("\n");
|
|
360
|
+
var splitFilePatches = (diff) => {
|
|
361
|
+
const starts = [...diff.matchAll(/^diff --git /gm)].map((match) => match.index);
|
|
362
|
+
return starts.map((start, index) => {
|
|
363
|
+
const end = starts[index + 1] ?? diff.length;
|
|
364
|
+
const patch = diff.slice(start, end);
|
|
365
|
+
return patch.endsWith("\n") ? patch : `${patch}
|
|
366
|
+
`;
|
|
367
|
+
});
|
|
368
|
+
};
|
|
369
|
+
var parseRows = (patch) => {
|
|
370
|
+
const patchLines = patch.split("\n");
|
|
371
|
+
const lines = [];
|
|
372
|
+
const hunks = [];
|
|
373
|
+
let unifiedRow = 0;
|
|
374
|
+
let splitRow = 0;
|
|
375
|
+
let hunkIndex = -1;
|
|
376
|
+
let oldLine = 0;
|
|
377
|
+
let newLine = 0;
|
|
378
|
+
for (let index = 0; index < patchLines.length; index += 1) {
|
|
379
|
+
const header = patchLines[index] ?? "";
|
|
380
|
+
const hunkMatch = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(header);
|
|
381
|
+
if (hunkMatch === null) continue;
|
|
382
|
+
hunkIndex += 1;
|
|
383
|
+
oldLine = Number(hunkMatch[1]);
|
|
384
|
+
newLine = Number(hunkMatch[2]);
|
|
385
|
+
hunks.push({ index: hunkIndex, header, unifiedRow, splitRow });
|
|
386
|
+
const body = [];
|
|
387
|
+
let bodyIndex = index + 1;
|
|
388
|
+
while (bodyIndex < patchLines.length) {
|
|
389
|
+
const bodyLine = patchLines[bodyIndex] ?? "";
|
|
390
|
+
if (bodyLine.startsWith("@@ ")) break;
|
|
391
|
+
if (bodyLine.startsWith("+") && !bodyLine.startsWith("+++")) {
|
|
392
|
+
body.push({ kind: "addition" });
|
|
393
|
+
} else if (bodyLine.startsWith("-") && !bodyLine.startsWith("---")) {
|
|
394
|
+
body.push({ kind: "deletion" });
|
|
395
|
+
} else if (bodyLine.startsWith(" ")) {
|
|
396
|
+
body.push({ kind: "context" });
|
|
397
|
+
}
|
|
398
|
+
bodyIndex += 1;
|
|
399
|
+
}
|
|
400
|
+
let cursor = 0;
|
|
401
|
+
while (cursor < body.length) {
|
|
402
|
+
const current = body[cursor];
|
|
403
|
+
if (current?.kind === "context") {
|
|
404
|
+
lines.push({
|
|
405
|
+
kind: "context",
|
|
406
|
+
oldLine,
|
|
407
|
+
newLine,
|
|
408
|
+
unifiedRow,
|
|
409
|
+
splitRow,
|
|
410
|
+
hunk: hunkIndex,
|
|
411
|
+
commentable: true
|
|
412
|
+
});
|
|
413
|
+
oldLine += 1;
|
|
414
|
+
newLine += 1;
|
|
415
|
+
unifiedRow += 1;
|
|
416
|
+
splitRow += 1;
|
|
417
|
+
cursor += 1;
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
const groupStart = cursor;
|
|
421
|
+
while (cursor < body.length && body[cursor]?.kind !== "context") cursor += 1;
|
|
422
|
+
const group = body.slice(groupStart, cursor);
|
|
423
|
+
let deletionOffset = 0;
|
|
424
|
+
let additionOffset = 0;
|
|
425
|
+
for (const entry of group) {
|
|
426
|
+
if (entry.kind === "deletion") {
|
|
427
|
+
lines.push({
|
|
428
|
+
kind: entry.kind,
|
|
429
|
+
oldLine,
|
|
430
|
+
newLine: null,
|
|
431
|
+
unifiedRow,
|
|
432
|
+
splitRow: splitRow + deletionOffset,
|
|
433
|
+
hunk: hunkIndex,
|
|
434
|
+
// ReviewComment persists a line number but no old/new side. Like
|
|
435
|
+
// the web review, deletions therefore abstain instead of storing
|
|
436
|
+
// a confidently wrong new-file anchor.
|
|
437
|
+
commentable: false
|
|
438
|
+
});
|
|
439
|
+
oldLine += 1;
|
|
440
|
+
deletionOffset += 1;
|
|
441
|
+
} else {
|
|
442
|
+
lines.push({
|
|
443
|
+
kind: entry.kind,
|
|
444
|
+
oldLine: null,
|
|
445
|
+
newLine,
|
|
446
|
+
unifiedRow,
|
|
447
|
+
splitRow: splitRow + additionOffset,
|
|
448
|
+
hunk: hunkIndex,
|
|
449
|
+
commentable: true
|
|
450
|
+
});
|
|
451
|
+
newLine += 1;
|
|
452
|
+
additionOffset += 1;
|
|
453
|
+
}
|
|
454
|
+
unifiedRow += 1;
|
|
455
|
+
}
|
|
456
|
+
splitRow += Math.max(deletionOffset, additionOffset);
|
|
457
|
+
}
|
|
458
|
+
index = bodyIndex - 1;
|
|
459
|
+
}
|
|
460
|
+
return { lines, hunks, unifiedRows: unifiedRow, splitRows: splitRow };
|
|
461
|
+
};
|
|
462
|
+
var buildReviewFiles = (diff, stats) => {
|
|
463
|
+
const patches = splitFilePatches(diff);
|
|
464
|
+
return stats.map((stat, index) => {
|
|
465
|
+
const patch = patches[index] ?? "";
|
|
466
|
+
const rows = parseRows(patch);
|
|
467
|
+
return {
|
|
468
|
+
...stat,
|
|
469
|
+
patch,
|
|
470
|
+
filetype: filetypeOf(stat.path),
|
|
471
|
+
status: statusOf(patch),
|
|
472
|
+
binary: /^Binary files |^GIT binary patch$/m.test(patch),
|
|
473
|
+
likelyGenerated: isLikelyGenerated(stat.path),
|
|
474
|
+
...rows
|
|
475
|
+
};
|
|
476
|
+
});
|
|
477
|
+
};
|
|
478
|
+
var assembleReviewInstruction = (change, comments) => {
|
|
479
|
+
const points = comments.map((comment, index) => {
|
|
480
|
+
const anchor = comment.file === null ? "the change as a whole" : `${comment.file}${comment.line === null ? "" : `:${comment.line}${comment.endLine === null || comment.endLine === comment.line ? "" : `-${comment.endLine}`}`}`;
|
|
481
|
+
const proposal = comment.suggestion === null ? "" : `
|
|
482
|
+
Proposed replacement:
|
|
483
|
+
${comment.suggestion.split("\n").map((line) => ` ${line}`).join("\n")}`;
|
|
484
|
+
return `${index + 1}. ${anchor} \u2014 ${comment.body}${proposal}`;
|
|
485
|
+
}).join("\n");
|
|
486
|
+
return `This is a follow-up on your earlier work in this worktree, responding to review feedback. The branch ${change.branch} is already checked out with your work on it: keep working there, and do not start a new branch or reset it.
|
|
487
|
+
|
|
488
|
+
Review comments:
|
|
489
|
+
${points}
|
|
490
|
+
|
|
491
|
+
Address each point using your own judgment about how. Check your work with the repository's own build, tests, or typecheck; a check that still fails is something to report, not to force green. Report honestly what you changed and what you did not \u2014 if a point cannot or should not be done, say so plainly.`;
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
// src/terminal.ts
|
|
495
|
+
import { spawn } from "node:child_process";
|
|
496
|
+
var openUrl = (target) => {
|
|
497
|
+
const opener = process.platform === "darwin" ? "open" : "xdg-open";
|
|
498
|
+
const child = spawn(opener, [target], { detached: true, stdio: "ignore" });
|
|
499
|
+
child.on("error", () => void 0);
|
|
500
|
+
child.unref();
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
// src/tui-theme.ts
|
|
504
|
+
var INK = "#eeece8";
|
|
505
|
+
var INK_2 = "#c9c7c2";
|
|
506
|
+
var MUTED = "#a3a09a";
|
|
507
|
+
var FAINT = "#807d76";
|
|
508
|
+
var RULE = "#37374a";
|
|
509
|
+
var COBALT = "#6f96f5";
|
|
510
|
+
var WASH = "#2c2c35";
|
|
511
|
+
var SURFACE = "#17171d";
|
|
512
|
+
var GREEN = "#69c189";
|
|
513
|
+
var RED = "#e87f76";
|
|
514
|
+
var ADD_WASH = "#20362a";
|
|
515
|
+
var DELETE_WASH = "#3c2b2e";
|
|
516
|
+
var SYNTAX_KEYWORD = "#cda6f2";
|
|
517
|
+
var SYNTAX_STRING = "#98cfa8";
|
|
518
|
+
var SYNTAX_NUMBER = "#e6c37e";
|
|
519
|
+
var SYNTAX_FUNCTION = "#94bdf7";
|
|
520
|
+
|
|
521
|
+
// src/review.tsx
|
|
522
|
+
import { Fragment, jsx, jsxs } from "@opentui/react/jsx-runtime";
|
|
523
|
+
var REVIEW_KEY = (changeId) => ["review", changeId];
|
|
524
|
+
var EDITOR_KEY_BINDINGS = [{ name: "return", ctrl: true, action: "submit" }];
|
|
525
|
+
var syntaxStyle = SyntaxStyle.fromStyles({
|
|
526
|
+
keyword: { fg: RGBA.fromHex(SYNTAX_KEYWORD) },
|
|
527
|
+
string: { fg: RGBA.fromHex(SYNTAX_STRING) },
|
|
528
|
+
comment: { fg: RGBA.fromHex(FAINT), italic: true },
|
|
529
|
+
number: { fg: RGBA.fromHex(SYNTAX_NUMBER) },
|
|
530
|
+
function: { fg: RGBA.fromHex(SYNTAX_FUNCTION) },
|
|
531
|
+
variable: { fg: RGBA.fromHex(INK) },
|
|
532
|
+
default: { fg: RGBA.fromHex(INK) }
|
|
533
|
+
});
|
|
534
|
+
var truncate = (text, width) => {
|
|
535
|
+
const oneLine = text.replaceAll(/\s+/g, " ").trim();
|
|
536
|
+
if (width <= 1) return "";
|
|
537
|
+
return oneLine.length <= width ? oneLine : `${oneLine.slice(0, width - 1)}\u2026`;
|
|
538
|
+
};
|
|
539
|
+
var fetchReview = async (ctx, changeId) => {
|
|
540
|
+
const opened = await ctx.api("POST", `/changes/${changeId}/reviews/open`, {
|
|
541
|
+
idempotencyKey: `cli-review:${changeId}`
|
|
542
|
+
});
|
|
543
|
+
const pinned = await ctx.api(
|
|
544
|
+
"GET",
|
|
545
|
+
`/changes/${changeId}/reviews/${opened.slice.id}/diff`
|
|
546
|
+
);
|
|
547
|
+
const wire = {
|
|
548
|
+
change: pinned.change,
|
|
549
|
+
diff: pinned.patch,
|
|
550
|
+
files: pinned.files.map((file) => ({
|
|
551
|
+
path: file.newPath ?? file.oldPath ?? "unknown path",
|
|
552
|
+
additions: file.additions,
|
|
553
|
+
deletions: file.deletions
|
|
554
|
+
}))
|
|
555
|
+
};
|
|
556
|
+
const [comments, tour, passes, followUp, sessionDetail] = await Promise.all([
|
|
557
|
+
ctx.api("GET", `/changes/${changeId}/comments`),
|
|
558
|
+
ctx.api("GET", `/changes/${changeId}/tour`),
|
|
559
|
+
ctx.api("GET", `/changes/${changeId}/passes`),
|
|
560
|
+
ctx.api("GET", `/sessions/${wire.change.sessionId}/follow-up`),
|
|
561
|
+
ctx.api("GET", `/sessions/${wire.change.sessionId}`)
|
|
562
|
+
]);
|
|
563
|
+
return {
|
|
564
|
+
wire,
|
|
565
|
+
sliceId: pinned.slice.id,
|
|
566
|
+
checkpointAId: pinned.checkpointA.id,
|
|
567
|
+
checkpointBId: pinned.checkpointB.id,
|
|
568
|
+
diffDigest: pinned.slice.diffDigest,
|
|
569
|
+
anchorFiles: pinned.anchorFiles,
|
|
570
|
+
files: buildReviewFiles(wire.diff, wire.files),
|
|
571
|
+
comments,
|
|
572
|
+
tour,
|
|
573
|
+
passes,
|
|
574
|
+
followUp,
|
|
575
|
+
sessionStatus: sessionDetail.session.status
|
|
576
|
+
};
|
|
577
|
+
};
|
|
578
|
+
var commentPriority = (comment) => comment.state === "draft" ? 0 : comment.state === "open" ? 1 : 2;
|
|
579
|
+
var orderedComments = (comments) => comments.toSorted((a, b) => {
|
|
580
|
+
const byState = commentPriority(a) - commentPriority(b);
|
|
581
|
+
return byState === 0 ? b.createdAt.localeCompare(a.createdAt) : byState;
|
|
582
|
+
});
|
|
583
|
+
var stateColor = (state) => state === "draft" || state === "open" ? INK : state === "addressed" ? INK_2 : FAINT;
|
|
584
|
+
var anchorOf = (comment) => comment.file === null ? "change" : `${comment.file}${comment.line === null ? "" : `:${comment.line}${comment.endLine === null || comment.endLine === comment.line ? "" : `-${comment.endLine}`}`}`;
|
|
585
|
+
var passFact = (pass) => {
|
|
586
|
+
const label = pass.kind === "suggest" ? "suggestions" : pass.kind;
|
|
587
|
+
if (pass.status === "running") return { text: `${label} running`, color: INK_2 };
|
|
588
|
+
if (pass.status === "failed") return { text: `${label} failed`, color: RED };
|
|
589
|
+
return {
|
|
590
|
+
text: `${label} observed \xB7 ${pass.findings ?? 0} ${pass.kind === "tour" ? "stops" : "drafts"}`,
|
|
591
|
+
color: MUTED
|
|
592
|
+
};
|
|
593
|
+
};
|
|
594
|
+
var FileRow = ({ file, selected }) => /* @__PURE__ */ jsxs("box", { height: 2, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: [
|
|
595
|
+
/* @__PURE__ */ jsxs("text", { height: 1, bg: "transparent", children: [
|
|
596
|
+
/* @__PURE__ */ jsx("span", { fg: selected ? COBALT : FAINT, children: selected ? "\u258C " : " " }),
|
|
597
|
+
/* @__PURE__ */ jsx("span", { fg: INK, children: file.path }),
|
|
598
|
+
file.binary ? /* @__PURE__ */ jsx("span", { fg: MUTED, children: " \xB7 binary" }) : null,
|
|
599
|
+
file.likelyGenerated ? /* @__PURE__ */ jsx("span", { fg: FAINT, children: " \xB7 inferred \xB7 likely generated" }) : null
|
|
600
|
+
] }),
|
|
601
|
+
/* @__PURE__ */ jsxs("text", { height: 1, bg: "transparent", children: [
|
|
602
|
+
/* @__PURE__ */ jsx("span", { children: " " }),
|
|
603
|
+
/* @__PURE__ */ jsxs("span", { fg: GREEN, children: [
|
|
604
|
+
"+",
|
|
605
|
+
file.additions
|
|
606
|
+
] }),
|
|
607
|
+
/* @__PURE__ */ jsx("span", { fg: FAINT, children: " " }),
|
|
608
|
+
/* @__PURE__ */ jsxs("span", { fg: RED, children: [
|
|
609
|
+
"\u2212",
|
|
610
|
+
file.deletions
|
|
611
|
+
] }),
|
|
612
|
+
/* @__PURE__ */ jsxs("span", { fg: FAINT, children: [
|
|
613
|
+
" ",
|
|
614
|
+
"\xB7 ",
|
|
615
|
+
file.status,
|
|
616
|
+
" \xB7 ",
|
|
617
|
+
file.hunks.length,
|
|
618
|
+
" hunks"
|
|
619
|
+
] })
|
|
620
|
+
] })
|
|
621
|
+
] });
|
|
622
|
+
var CommentRow = ({
|
|
623
|
+
comment,
|
|
624
|
+
selected
|
|
625
|
+
}) => /* @__PURE__ */ jsxs("box", { height: 2, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: [
|
|
626
|
+
/* @__PURE__ */ jsxs("text", { height: 1, bg: "transparent", children: [
|
|
627
|
+
/* @__PURE__ */ jsx("span", { fg: selected ? COBALT : FAINT, children: selected ? "\u258C " : " " }),
|
|
628
|
+
/* @__PURE__ */ jsx("span", { fg: stateColor(comment.state), children: comment.state }),
|
|
629
|
+
/* @__PURE__ */ jsxs("span", { fg: FAINT, children: [
|
|
630
|
+
" \xB7 ",
|
|
631
|
+
anchorOf(comment)
|
|
632
|
+
] })
|
|
633
|
+
] }),
|
|
634
|
+
/* @__PURE__ */ jsxs("text", { height: 1, bg: "transparent", children: [
|
|
635
|
+
/* @__PURE__ */ jsx("span", { children: " " }),
|
|
636
|
+
/* @__PURE__ */ jsx("span", { fg: MUTED, children: comment.authorKind === "mend" ? "Mend \xB7 " : "You \xB7 " }),
|
|
637
|
+
/* @__PURE__ */ jsx("span", { fg: INK, children: truncate(comment.body, 27) })
|
|
638
|
+
] })
|
|
639
|
+
] });
|
|
640
|
+
var Description = ({
|
|
641
|
+
data,
|
|
642
|
+
tourIndex,
|
|
643
|
+
stale,
|
|
644
|
+
width,
|
|
645
|
+
height
|
|
646
|
+
}) => {
|
|
647
|
+
const passFacts = data.passes.map(passFact);
|
|
648
|
+
const tourStop = data.tour?.stops[tourIndex] ?? null;
|
|
649
|
+
const stopEvidence = tourStop?.evidence[0] ?? null;
|
|
650
|
+
return /* @__PURE__ */ jsxs(
|
|
651
|
+
"box",
|
|
652
|
+
{
|
|
653
|
+
height,
|
|
654
|
+
border: true,
|
|
655
|
+
borderStyle: "rounded",
|
|
656
|
+
borderColor: RULE,
|
|
657
|
+
title: " change description ",
|
|
658
|
+
titleAlignment: "left",
|
|
659
|
+
backgroundColor: "transparent",
|
|
660
|
+
flexShrink: 0,
|
|
661
|
+
flexDirection: "column",
|
|
662
|
+
paddingX: 1,
|
|
663
|
+
children: [
|
|
664
|
+
/* @__PURE__ */ jsx("text", { height: 1, bg: "transparent", children: data.tour === null ? /* @__PURE__ */ jsx("span", { fg: INK, children: "No description yet \u2014 t composes one from the diff and session record." }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
665
|
+
/* @__PURE__ */ jsx("span", { fg: MUTED, children: "Mend uses inference \xB7 " }),
|
|
666
|
+
stale ? /* @__PURE__ */ jsx("span", { fg: MUTED, children: "diff changed since \xB7 " }) : null,
|
|
667
|
+
/* @__PURE__ */ jsx("span", { fg: INK, children: truncate(data.tour.summary, Math.max(20, width - 50)) })
|
|
668
|
+
] }) }),
|
|
669
|
+
height >= 5 && data.tour !== null ? /* @__PURE__ */ jsx("text", { height: 1, bg: "transparent", children: tourStop === null ? /* @__PURE__ */ jsxs("span", { fg: FAINT, children: [
|
|
670
|
+
"inferred approach \xB7 ",
|
|
671
|
+
truncate(data.tour.approach ?? "No tour stops yet.", width - 24)
|
|
672
|
+
] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
673
|
+
/* @__PURE__ */ jsxs("span", { fg: INK_2, children: [
|
|
674
|
+
"stop ",
|
|
675
|
+
tourIndex + 1,
|
|
676
|
+
"/",
|
|
677
|
+
data.tour.stops.length,
|
|
678
|
+
" \xB7 ",
|
|
679
|
+
tourStop.title
|
|
680
|
+
] }),
|
|
681
|
+
/* @__PURE__ */ jsxs("span", { fg: FAINT, children: [
|
|
682
|
+
tourStop.file === null ? " \xB7 change" : ` \xB7 ${tourStop.file}${tourStop.line === null ? "" : `:${tourStop.line}`}`,
|
|
683
|
+
tourStop.grounded ? " \xB7 direct record" : " \xB7 inferred reading"
|
|
684
|
+
] })
|
|
685
|
+
] }) }) : null,
|
|
686
|
+
height >= 6 && tourStop !== null ? /* @__PURE__ */ jsxs("text", { height: 1, bg: "transparent", children: [
|
|
687
|
+
/* @__PURE__ */ jsx("span", { fg: MUTED, children: tourStop.grounded ? "record-grounded narration \xB7 " : "inferred narration \xB7 " }),
|
|
688
|
+
/* @__PURE__ */ jsx("span", { fg: MUTED, children: truncate(tourStop.narration, Math.max(20, width - 24)) })
|
|
689
|
+
] }) : null,
|
|
690
|
+
height >= 7 && tourStop !== null ? /* @__PURE__ */ jsxs("text", { height: 1, bg: "transparent", children: [
|
|
691
|
+
/* @__PURE__ */ jsx("span", { fg: MUTED, children: stopEvidence === null ? "no direct evidence \xB7 " : `evidence seq ${stopEvidence.sequence} \xB7 ` }),
|
|
692
|
+
/* @__PURE__ */ jsx("span", { fg: MUTED, children: truncate(
|
|
693
|
+
stopEvidence?.excerpt ?? "this stop is labeled inferred",
|
|
694
|
+
Math.max(20, width - 26)
|
|
695
|
+
) })
|
|
696
|
+
] }) : null,
|
|
697
|
+
/* @__PURE__ */ jsxs("text", { height: 1, bg: "transparent", children: [
|
|
698
|
+
passFacts.length === 0 ? /* @__PURE__ */ jsx("span", { fg: FAINT, children: "Mend has not read this change." }) : passFacts.map((fact, index) => /* @__PURE__ */ jsxs("span", { children: [
|
|
699
|
+
index > 0 ? /* @__PURE__ */ jsx("span", { fg: FAINT, children: " \xB7 " }) : null,
|
|
700
|
+
/* @__PURE__ */ jsx("span", { fg: fact.color, children: fact.text })
|
|
701
|
+
] }, `${fact.text}-${index}`)),
|
|
702
|
+
data.followUp?.status === "pending" ? /* @__PURE__ */ jsx("span", { fg: INK_2, children: " \xB7 follow-up pending" }) : null
|
|
703
|
+
] })
|
|
704
|
+
]
|
|
705
|
+
}
|
|
706
|
+
);
|
|
707
|
+
};
|
|
708
|
+
var CompactTourEvidence = ({
|
|
709
|
+
stop,
|
|
710
|
+
index,
|
|
711
|
+
total,
|
|
712
|
+
width
|
|
713
|
+
}) => {
|
|
714
|
+
const evidence = stop.evidence[0] ?? null;
|
|
715
|
+
return /* @__PURE__ */ jsxs(
|
|
716
|
+
"box",
|
|
717
|
+
{
|
|
718
|
+
height: 5,
|
|
719
|
+
border: true,
|
|
720
|
+
borderStyle: "rounded",
|
|
721
|
+
borderColor: RULE,
|
|
722
|
+
title: ` tour stop ${index + 1}/${total} \xB7 ${stop.grounded ? "direct record" : "inferred reading"} `,
|
|
723
|
+
titleAlignment: "left",
|
|
724
|
+
backgroundColor: "transparent",
|
|
725
|
+
flexShrink: 0,
|
|
726
|
+
flexDirection: "column",
|
|
727
|
+
paddingX: 1,
|
|
728
|
+
children: [
|
|
729
|
+
/* @__PURE__ */ jsx("text", { height: 1, fg: INK, bg: "transparent", children: truncate(stop.narration, Math.max(12, width - 6)) }),
|
|
730
|
+
/* @__PURE__ */ jsxs("text", { height: 1, bg: "transparent", children: [
|
|
731
|
+
/* @__PURE__ */ jsx("span", { fg: MUTED, children: evidence === null ? "no direct record link \xB7 " : `seq ${evidence.sequence} \xB7 ` }),
|
|
732
|
+
/* @__PURE__ */ jsx("span", { fg: MUTED, children: truncate(
|
|
733
|
+
evidence?.excerpt ?? "the narration is an inferred reading",
|
|
734
|
+
Math.max(12, width - 26)
|
|
735
|
+
) })
|
|
736
|
+
] }),
|
|
737
|
+
/* @__PURE__ */ jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: ", previous stop \xB7 . next stop" })
|
|
738
|
+
]
|
|
739
|
+
}
|
|
740
|
+
);
|
|
741
|
+
};
|
|
742
|
+
var EvidenceCard = ({
|
|
743
|
+
comment,
|
|
744
|
+
width
|
|
745
|
+
}) => /* @__PURE__ */ jsxs(
|
|
746
|
+
"box",
|
|
747
|
+
{
|
|
748
|
+
height: Math.min(6, 4 + Math.min(2, comment.evidence.length)),
|
|
749
|
+
border: true,
|
|
750
|
+
borderStyle: "rounded",
|
|
751
|
+
borderColor: RULE,
|
|
752
|
+
title: ` ${anchorOf(comment)} \xB7 ${comment.authorKind === "mend" ? "Mend" : "You"} `,
|
|
753
|
+
titleAlignment: "left",
|
|
754
|
+
backgroundColor: "transparent",
|
|
755
|
+
flexShrink: 0,
|
|
756
|
+
flexDirection: "column",
|
|
757
|
+
paddingX: 1,
|
|
758
|
+
children: [
|
|
759
|
+
/* @__PURE__ */ jsx("text", { height: 1, fg: INK, bg: "transparent", children: truncate(comment.body, Math.max(12, width - 6)) }),
|
|
760
|
+
comment.suggestion === null ? null : /* @__PURE__ */ jsxs("text", { height: 1, bg: "transparent", children: [
|
|
761
|
+
/* @__PURE__ */ jsx("span", { fg: MUTED, children: "proposed \xB7 " }),
|
|
762
|
+
/* @__PURE__ */ jsx("span", { fg: MUTED, children: truncate(comment.suggestion, Math.max(12, width - 17)) })
|
|
763
|
+
] }),
|
|
764
|
+
comment.evidence.slice(0, 2).map((evidence) => /* @__PURE__ */ jsxs("text", { height: 1, bg: "transparent", children: [
|
|
765
|
+
/* @__PURE__ */ jsxs("span", { fg: FAINT, children: [
|
|
766
|
+
"seq ",
|
|
767
|
+
evidence.sequence,
|
|
768
|
+
" \xB7 "
|
|
769
|
+
] }),
|
|
770
|
+
/* @__PURE__ */ jsx("span", { fg: MUTED, children: truncate(evidence.excerpt, Math.max(12, width - 18)) })
|
|
771
|
+
] }, `${evidence.sequence}-${evidence.excerpt}`))
|
|
772
|
+
]
|
|
773
|
+
}
|
|
774
|
+
);
|
|
775
|
+
var EditorPanel = ({
|
|
776
|
+
editor,
|
|
777
|
+
refValue,
|
|
778
|
+
busy,
|
|
779
|
+
onSubmit
|
|
780
|
+
}) => {
|
|
781
|
+
const title = editor.kind === "send" ? " send review to session \xB7 edit before sending " : ` comment \xB7 ${editor.file === null ? "change" : `${editor.file}:${editor.line ?? ""}`} `;
|
|
782
|
+
return /* @__PURE__ */ jsxs(
|
|
783
|
+
"box",
|
|
784
|
+
{
|
|
785
|
+
border: true,
|
|
786
|
+
borderStyle: "rounded",
|
|
787
|
+
borderColor: COBALT,
|
|
788
|
+
title,
|
|
789
|
+
titleAlignment: "left",
|
|
790
|
+
backgroundColor: "transparent",
|
|
791
|
+
height: editor.kind === "send" ? 16 : 8,
|
|
792
|
+
flexShrink: 0,
|
|
793
|
+
flexDirection: "column",
|
|
794
|
+
children: [
|
|
795
|
+
/* @__PURE__ */ jsx(
|
|
796
|
+
"textarea",
|
|
797
|
+
{
|
|
798
|
+
ref: refValue,
|
|
799
|
+
focused: true,
|
|
800
|
+
initialValue: editor.kind === "send" ? editor.instruction : editor.initialBody ?? "",
|
|
801
|
+
placeholder: editor.kind === "send" ? "Review instruction\u2026" : "What should change, and why?",
|
|
802
|
+
backgroundColor: "transparent",
|
|
803
|
+
focusedBackgroundColor: "transparent",
|
|
804
|
+
textColor: INK,
|
|
805
|
+
focusedTextColor: INK,
|
|
806
|
+
placeholderColor: FAINT,
|
|
807
|
+
cursorColor: INK,
|
|
808
|
+
keyBindings: [...EDITOR_KEY_BINDINGS],
|
|
809
|
+
flexGrow: 1,
|
|
810
|
+
onSubmit
|
|
811
|
+
}
|
|
812
|
+
),
|
|
813
|
+
/* @__PURE__ */ jsx("text", { height: 1, bg: "transparent", children: /* @__PURE__ */ jsxs("span", { fg: FAINT, children: [
|
|
814
|
+
" ctrl+enter ",
|
|
815
|
+
busy ? "working\u2026" : "save",
|
|
816
|
+
" \xB7 esc cancel"
|
|
817
|
+
] }) })
|
|
818
|
+
]
|
|
819
|
+
}
|
|
820
|
+
);
|
|
821
|
+
};
|
|
822
|
+
var errorMessage = (error) => error instanceof Error ? error.message : error === null ? "" : String(error);
|
|
823
|
+
function ReviewScreen({
|
|
824
|
+
ctx,
|
|
825
|
+
projectName,
|
|
826
|
+
session,
|
|
827
|
+
changeId,
|
|
828
|
+
onBack,
|
|
829
|
+
onQuit
|
|
830
|
+
}) {
|
|
831
|
+
const queryClient = useQueryClient();
|
|
832
|
+
const { width, height } = useTerminalDimensions();
|
|
833
|
+
const wide = width >= 100;
|
|
834
|
+
const descriptionHeight = height >= 30 ? 7 : 5;
|
|
835
|
+
const { data, failureReason, isFetching } = useQuery({
|
|
836
|
+
queryKey: REVIEW_KEY(changeId),
|
|
837
|
+
queryFn: () => fetchReview(ctx, changeId),
|
|
838
|
+
retry: true,
|
|
839
|
+
retryDelay: 1e3
|
|
840
|
+
});
|
|
841
|
+
const [focus, setFocus] = useState("diff");
|
|
842
|
+
const [fileIndex, setFileIndex] = useState(0);
|
|
843
|
+
const [anchorIndex, setAnchorIndex] = useState(0);
|
|
844
|
+
const [commentIndex, setCommentIndex] = useState(0);
|
|
845
|
+
const [tourIndex, setTourIndex] = useState(0);
|
|
846
|
+
const [view, setView] = useState("unified");
|
|
847
|
+
const [wrap, setWrap] = useState(false);
|
|
848
|
+
const [showWhitespace, setShowWhitespace] = useState(false);
|
|
849
|
+
const [rangeStart, setRangeStart] = useState(null);
|
|
850
|
+
const [editor, setEditor] = useState(null);
|
|
851
|
+
const [busy, setBusy] = useState(false);
|
|
852
|
+
const [status, setStatus] = useState("");
|
|
853
|
+
const checkpointState = data === void 0 ? isFetching ? "recording" : "failed" : "recorded";
|
|
854
|
+
const lockRef = useRef(false);
|
|
855
|
+
const editorRef = useRef(null);
|
|
856
|
+
const diffRef = useRef(null);
|
|
857
|
+
const diffScrollRef = useRef(null);
|
|
858
|
+
const fileScrollRef = useRef(null);
|
|
859
|
+
const commentScrollRef = useRef(null);
|
|
860
|
+
const files = useMemo(() => {
|
|
861
|
+
const order = {
|
|
862
|
+
added: 0,
|
|
863
|
+
modified: 1,
|
|
864
|
+
renamed: 2,
|
|
865
|
+
deleted: 3
|
|
866
|
+
};
|
|
867
|
+
return (data?.files ?? []).toSorted(
|
|
868
|
+
(left, right) => order[left.status] - order[right.status] || left.path.localeCompare(right.path)
|
|
869
|
+
);
|
|
870
|
+
}, [data?.files]);
|
|
871
|
+
const selectedFile = files[Math.min(fileIndex, Math.max(0, files.length - 1))] ?? null;
|
|
872
|
+
const anchors = useMemo(
|
|
873
|
+
() => selectedFile?.lines.filter((line) => line.commentable) ?? [],
|
|
874
|
+
[selectedFile]
|
|
875
|
+
);
|
|
876
|
+
const selectedAnchor = anchors[Math.min(anchorIndex, Math.max(0, anchors.length - 1))] ?? null;
|
|
877
|
+
const comments = useMemo(() => orderedComments(data?.comments ?? []), [data?.comments]);
|
|
878
|
+
const selectedComment = comments[Math.min(commentIndex, Math.max(0, comments.length - 1))] ?? null;
|
|
879
|
+
const lineComment = selectedFile === null || selectedAnchor === null ? null : comments.find(
|
|
880
|
+
(comment) => comment.file === selectedFile.path && comment.line !== null && selectedAnchor.newLine !== null && comment.line <= selectedAnchor.newLine && (comment.endLine ?? comment.line) >= selectedAnchor.newLine
|
|
881
|
+
) ?? null;
|
|
882
|
+
const detailComment = focus === "comments" ? selectedComment : lineComment;
|
|
883
|
+
const tourStops = data?.tour?.stops ?? [];
|
|
884
|
+
const activeTourIndex = Math.min(tourIndex, Math.max(0, tourStops.length - 1));
|
|
885
|
+
const activeTourStop = tourStops[activeTourIndex] ?? null;
|
|
886
|
+
const tourStale = useMemo(
|
|
887
|
+
() => data?.tour === null || data?.tour === void 0 ? false : createHash("sha256").update(data.wire.diff).digest("hex") !== data.tour.diffDigest,
|
|
888
|
+
[data?.tour, data?.wire.diff]
|
|
889
|
+
);
|
|
890
|
+
const refresh = () => {
|
|
891
|
+
void queryClient.invalidateQueries({ queryKey: REVIEW_KEY(changeId) });
|
|
892
|
+
};
|
|
893
|
+
const settleRefresh = () => {
|
|
894
|
+
if (queryClient.isMutating() === 1) refresh();
|
|
895
|
+
};
|
|
896
|
+
const patchReview = (f) => {
|
|
897
|
+
const current = queryClient.getQueryData(REVIEW_KEY(changeId));
|
|
898
|
+
if (current !== void 0) queryClient.setQueryData(REVIEW_KEY(changeId), f(current));
|
|
899
|
+
};
|
|
900
|
+
const commentStateMutation = useMutation({
|
|
901
|
+
mutationFn: (vars) => ctx.api("POST", `/changes/${changeId}/comments/${vars.commentId}/state`, {
|
|
902
|
+
state: vars.state
|
|
903
|
+
}),
|
|
904
|
+
onMutate: async (vars) => {
|
|
905
|
+
await queryClient.cancelQueries({ queryKey: REVIEW_KEY(changeId) });
|
|
906
|
+
patchReview((current) => ({
|
|
907
|
+
...current,
|
|
908
|
+
comments: current.comments.map(
|
|
909
|
+
(comment) => comment.id === vars.commentId ? { ...comment, state: vars.state } : comment
|
|
910
|
+
)
|
|
911
|
+
}));
|
|
912
|
+
},
|
|
913
|
+
onError: (error) => {
|
|
914
|
+
setStatus(errorMessage(error));
|
|
915
|
+
refresh();
|
|
916
|
+
},
|
|
917
|
+
onSettled: settleRefresh
|
|
918
|
+
});
|
|
919
|
+
const commentCreateMutation = useMutation({
|
|
920
|
+
mutationFn: (vars) => ctx.api("POST", `/changes/${changeId}/reviews/${vars.sliceId}/comments`, {
|
|
921
|
+
target: vars.target,
|
|
922
|
+
body: vars.body
|
|
923
|
+
}),
|
|
924
|
+
onMutate: async (vars) => {
|
|
925
|
+
await queryClient.cancelQueries({ queryKey: REVIEW_KEY(changeId) });
|
|
926
|
+
patchReview((current) => ({
|
|
927
|
+
...current,
|
|
928
|
+
comments: [vars.optimistic, ...current.comments]
|
|
929
|
+
}));
|
|
930
|
+
setEditor(null);
|
|
931
|
+
setStatus(vars.optimistic.file === null ? "Change comment added" : "Inline comment added");
|
|
932
|
+
},
|
|
933
|
+
onError: (error, vars) => {
|
|
934
|
+
patchReview((current) => ({
|
|
935
|
+
...current,
|
|
936
|
+
comments: current.comments.filter((comment) => comment.id !== vars.optimistic.id)
|
|
937
|
+
}));
|
|
938
|
+
setEditor({
|
|
939
|
+
kind: "comment",
|
|
940
|
+
file: vars.optimistic.file,
|
|
941
|
+
line: vars.optimistic.line,
|
|
942
|
+
endLine: vars.optimistic.endLine,
|
|
943
|
+
initialBody: vars.body
|
|
944
|
+
});
|
|
945
|
+
setStatus(errorMessage(error));
|
|
946
|
+
},
|
|
947
|
+
onSettled: settleRefresh
|
|
948
|
+
});
|
|
949
|
+
useEffect(() => {
|
|
950
|
+
fileScrollRef.current?.scrollTo(Math.max(0, fileIndex * 2 - 2));
|
|
951
|
+
}, [fileIndex]);
|
|
952
|
+
useEffect(() => {
|
|
953
|
+
commentScrollRef.current?.scrollTo(Math.max(0, commentIndex * 2 - 2));
|
|
954
|
+
}, [commentIndex]);
|
|
955
|
+
useEffect(() => {
|
|
956
|
+
if (selectedAnchor === null) return;
|
|
957
|
+
const row = view === "unified" ? selectedAnchor.unifiedRow : selectedAnchor.splitRow;
|
|
958
|
+
diffScrollRef.current?.scrollTo(Math.max(0, row - 3));
|
|
959
|
+
}, [selectedAnchor, view]);
|
|
960
|
+
useEffect(() => {
|
|
961
|
+
if (view !== "unified" || selectedAnchor === null || diffRef.current === null) return;
|
|
962
|
+
const start = rangeStart === null ? selectedAnchor.newLine : rangeStart;
|
|
963
|
+
const selectedLine = selectedAnchor.newLine;
|
|
964
|
+
const highlighted = start === null || selectedLine === null ? [selectedAnchor] : anchors.filter(
|
|
965
|
+
(anchor) => anchor.newLine !== null && anchor.newLine >= Math.min(start, selectedLine) && anchor.newLine <= Math.max(start, selectedLine)
|
|
966
|
+
);
|
|
967
|
+
for (const anchor of highlighted) {
|
|
968
|
+
diffRef.current.setLineColor(anchor.unifiedRow, { gutter: COBALT, content: WASH });
|
|
969
|
+
}
|
|
970
|
+
return () => {
|
|
971
|
+
if (diffRef.current === null) return;
|
|
972
|
+
for (const anchor of highlighted) {
|
|
973
|
+
const content = anchor.kind === "addition" ? ADD_WASH : anchor.kind === "deletion" ? DELETE_WASH : "transparent";
|
|
974
|
+
diffRef.current.setLineColor(anchor.unifiedRow, {
|
|
975
|
+
gutter: "transparent",
|
|
976
|
+
content
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
}, [anchors, rangeStart, selectedAnchor, view]);
|
|
981
|
+
const moveFile = (delta) => {
|
|
982
|
+
if (files.length === 0) return;
|
|
983
|
+
setFileIndex((current) => {
|
|
984
|
+
const next = Math.max(0, Math.min(files.length - 1, current + delta));
|
|
985
|
+
if (next !== current) {
|
|
986
|
+
setAnchorIndex(0);
|
|
987
|
+
setRangeStart(null);
|
|
988
|
+
}
|
|
989
|
+
return next;
|
|
990
|
+
});
|
|
991
|
+
};
|
|
992
|
+
const moveAnchor = (delta) => {
|
|
993
|
+
if (anchors.length === 0) return;
|
|
994
|
+
setAnchorIndex((current) => Math.max(0, Math.min(anchors.length - 1, current + delta)));
|
|
995
|
+
};
|
|
996
|
+
const navigateToAnchor = (file, line) => {
|
|
997
|
+
if (file === null) return;
|
|
998
|
+
const nextFileIndex = files.findIndex((candidate) => candidate.path === file);
|
|
999
|
+
if (nextFileIndex < 0) return;
|
|
1000
|
+
const nextFile = files[nextFileIndex];
|
|
1001
|
+
const nextAnchorIndex = line === null ? 0 : nextFile?.lines.findIndex(
|
|
1002
|
+
(candidate) => candidate.newLine !== null && candidate.newLine >= line
|
|
1003
|
+
) ?? -1;
|
|
1004
|
+
setFileIndex(nextFileIndex);
|
|
1005
|
+
setAnchorIndex(Math.max(0, nextAnchorIndex));
|
|
1006
|
+
setRangeStart(null);
|
|
1007
|
+
};
|
|
1008
|
+
const selectComment = (index) => {
|
|
1009
|
+
const next = comments[index];
|
|
1010
|
+
if (next === void 0) return;
|
|
1011
|
+
setCommentIndex(index);
|
|
1012
|
+
navigateToAnchor(next.file, next.line);
|
|
1013
|
+
};
|
|
1014
|
+
const moveComment = (delta) => {
|
|
1015
|
+
if (comments.length === 0) return;
|
|
1016
|
+
const current = Math.min(commentIndex, comments.length - 1);
|
|
1017
|
+
selectComment(Math.max(0, Math.min(comments.length - 1, current + delta)));
|
|
1018
|
+
};
|
|
1019
|
+
const moveTour = (delta) => {
|
|
1020
|
+
if (tourStops.length === 0) return;
|
|
1021
|
+
const current = Math.min(tourIndex, tourStops.length - 1);
|
|
1022
|
+
const nextIndex = Math.max(0, Math.min(tourStops.length - 1, current + delta));
|
|
1023
|
+
const stop = tourStops[nextIndex];
|
|
1024
|
+
setTourIndex(nextIndex);
|
|
1025
|
+
if (stop !== void 0) navigateToAnchor(stop.file, stop.line);
|
|
1026
|
+
};
|
|
1027
|
+
const moveHunk = (delta) => {
|
|
1028
|
+
if (selectedFile === null || selectedAnchor === null) return;
|
|
1029
|
+
const target = Math.max(
|
|
1030
|
+
0,
|
|
1031
|
+
Math.min(selectedFile.hunks.length - 1, selectedAnchor.hunk + delta)
|
|
1032
|
+
);
|
|
1033
|
+
const next = anchors.findIndex((anchor) => anchor.hunk === target);
|
|
1034
|
+
if (next >= 0) setAnchorIndex(next);
|
|
1035
|
+
};
|
|
1036
|
+
const setWorking = (value) => {
|
|
1037
|
+
lockRef.current = value;
|
|
1038
|
+
setBusy(value);
|
|
1039
|
+
};
|
|
1040
|
+
const runAction = async (label, action, completed = `${label} \xB7 requested`) => {
|
|
1041
|
+
setWorking(true);
|
|
1042
|
+
setStatus(`${label}\u2026`);
|
|
1043
|
+
try {
|
|
1044
|
+
await action();
|
|
1045
|
+
setStatus(completed);
|
|
1046
|
+
refresh();
|
|
1047
|
+
} catch (error) {
|
|
1048
|
+
setStatus(errorMessage(error));
|
|
1049
|
+
} finally {
|
|
1050
|
+
setWorking(false);
|
|
1051
|
+
}
|
|
1052
|
+
};
|
|
1053
|
+
const openCommentEditor = (wholeChange) => {
|
|
1054
|
+
if (wholeChange) {
|
|
1055
|
+
setEditor({ kind: "comment", file: null, line: null, endLine: null });
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
if (selectedFile?.status === "deleted" && selectedAnchor === null) {
|
|
1059
|
+
setStatus("Deleted-side anchors are not persisted \xB7 use C for a change comment");
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
if (selectedFile === null || selectedAnchor?.newLine === null || selectedAnchor === null) {
|
|
1063
|
+
setStatus("No new-side line selected");
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
const range = commentRange(rangeStart, selectedAnchor.newLine);
|
|
1067
|
+
setEditor({
|
|
1068
|
+
kind: "comment",
|
|
1069
|
+
file: selectedFile.path,
|
|
1070
|
+
line: range.line,
|
|
1071
|
+
endLine: range.endLine === range.line ? null : range.endLine
|
|
1072
|
+
});
|
|
1073
|
+
setRangeStart(null);
|
|
1074
|
+
};
|
|
1075
|
+
const openSendEditor = () => {
|
|
1076
|
+
if (data === void 0) return;
|
|
1077
|
+
const sendable = data.comments.filter(
|
|
1078
|
+
(comment) => comment.state === "open" && comment.sentToSessionId === null && !isPendingId(comment.id)
|
|
1079
|
+
);
|
|
1080
|
+
if (sendable.length === 0) {
|
|
1081
|
+
setStatus("No unsent open comments \u2014 accept a draft or write a comment first");
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
setEditor({
|
|
1085
|
+
kind: "send",
|
|
1086
|
+
instruction: assembleReviewInstruction(data.wire.change, sendable),
|
|
1087
|
+
commentIds: sendable.map((comment) => comment.id),
|
|
1088
|
+
idempotencyKey: `cli-follow-up:${data.sliceId}:${randomUUID()}`
|
|
1089
|
+
});
|
|
1090
|
+
};
|
|
1091
|
+
const submitEditor = async () => {
|
|
1092
|
+
if (editor === null || lockRef.current) return;
|
|
1093
|
+
const value = editorRef.current?.plainText.trim() ?? "";
|
|
1094
|
+
if (value === "") {
|
|
1095
|
+
setStatus("Write something before saving");
|
|
1096
|
+
return;
|
|
1097
|
+
}
|
|
1098
|
+
if (data === void 0) {
|
|
1099
|
+
setStatus("The pinned Review is not available.");
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
if (editor.kind === "comment") {
|
|
1103
|
+
const anchorFile = data.anchorFiles.find(
|
|
1104
|
+
(file) => (file.newPath ?? file.oldPath) === editor.file
|
|
1105
|
+
);
|
|
1106
|
+
const endLine = editor.endLine ?? editor.line;
|
|
1107
|
+
const hunk = editor.line === null || endLine === null ? null : anchorFile?.hunks.find(
|
|
1108
|
+
(candidate) => candidate.newLines > 0 && editor.line !== null && editor.line >= candidate.newStart && endLine <= candidate.newStart + candidate.newLines - 1
|
|
1109
|
+
) ?? null;
|
|
1110
|
+
if (editor.file !== null && (anchorFile === void 0 || hunk === null)) {
|
|
1111
|
+
setStatus("The selected line is outside the pinned Review anchor.");
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
commentCreateMutation.mutate({
|
|
1115
|
+
sliceId: data.sliceId,
|
|
1116
|
+
body: value,
|
|
1117
|
+
target: editor.file === null ? {
|
|
1118
|
+
oldPath: null,
|
|
1119
|
+
newPath: null,
|
|
1120
|
+
side: null,
|
|
1121
|
+
startLine: null,
|
|
1122
|
+
endLine: null,
|
|
1123
|
+
hunkContextHash: null
|
|
1124
|
+
} : {
|
|
1125
|
+
oldPath: anchorFile?.oldPath ?? null,
|
|
1126
|
+
newPath: anchorFile?.newPath ?? null,
|
|
1127
|
+
side: "new",
|
|
1128
|
+
startLine: editor.line,
|
|
1129
|
+
endLine,
|
|
1130
|
+
hunkContextHash: hunk?.contextHash ?? null
|
|
1131
|
+
},
|
|
1132
|
+
optimistic: {
|
|
1133
|
+
id: pendingId(),
|
|
1134
|
+
file: editor.file,
|
|
1135
|
+
line: editor.line,
|
|
1136
|
+
endLine: editor.endLine,
|
|
1137
|
+
authorKind: "reviewer",
|
|
1138
|
+
authorName: "You",
|
|
1139
|
+
body: value,
|
|
1140
|
+
suggestion: null,
|
|
1141
|
+
state: "open",
|
|
1142
|
+
evidence: [],
|
|
1143
|
+
sentToSessionId: null,
|
|
1144
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1145
|
+
}
|
|
1146
|
+
});
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
setWorking(true);
|
|
1150
|
+
try {
|
|
1151
|
+
await deliverReview(ctx.api, session.id, {
|
|
1152
|
+
reviewSliceId: data.sliceId,
|
|
1153
|
+
checkpointAId: data.checkpointAId,
|
|
1154
|
+
checkpointBId: data.checkpointBId,
|
|
1155
|
+
diffDigest: data.diffDigest,
|
|
1156
|
+
commentIds: editor.commentIds,
|
|
1157
|
+
instruction: value,
|
|
1158
|
+
idempotencyKey: editor.idempotencyKey
|
|
1159
|
+
});
|
|
1160
|
+
setStatus("Follow-up delivery requested \xB7 retry keeps the same run");
|
|
1161
|
+
setEditor(null);
|
|
1162
|
+
refresh();
|
|
1163
|
+
} catch (error) {
|
|
1164
|
+
setStatus(errorMessage(error));
|
|
1165
|
+
} finally {
|
|
1166
|
+
setWorking(false);
|
|
1167
|
+
}
|
|
1168
|
+
};
|
|
1169
|
+
const updateComment = (state) => {
|
|
1170
|
+
if (selectedComment === null) return;
|
|
1171
|
+
if (isPendingId(selectedComment.id)) {
|
|
1172
|
+
setStatus("That comment is still saving \u2014 one moment");
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
setStatus(`${state} \xB7 ${anchorOf(selectedComment)}`);
|
|
1176
|
+
commentStateMutation.mutate({ commentId: selectedComment.id, state });
|
|
1177
|
+
};
|
|
1178
|
+
const deliverAndRelaunch = () => {
|
|
1179
|
+
const followUp = data?.followUp ?? null;
|
|
1180
|
+
if (followUp === null || followUp.status === "delivered" || followUp.status === "superseded") {
|
|
1181
|
+
setStatus("No retryable follow-up \u2014 s assembles the open Review comments first");
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
if (followUp.reviewSliceId === null || followUp.checkpointAId === null || followUp.checkpointBId === null || followUp.diffDigest === null || followUp.idempotencyKey === null) {
|
|
1185
|
+
setStatus("Legacy follow-up \xB7 recreate it from a pinned Review before delivery");
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
const input = {
|
|
1189
|
+
reviewSliceId: followUp.reviewSliceId,
|
|
1190
|
+
checkpointAId: followUp.checkpointAId,
|
|
1191
|
+
checkpointBId: followUp.checkpointBId,
|
|
1192
|
+
diffDigest: followUp.diffDigest,
|
|
1193
|
+
commentIds: followUp.commentIds,
|
|
1194
|
+
instruction: followUp.instruction,
|
|
1195
|
+
idempotencyKey: followUp.idempotencyKey
|
|
1196
|
+
};
|
|
1197
|
+
void runAction(
|
|
1198
|
+
"Delivering the persisted Review bundle",
|
|
1199
|
+
() => deliverReview(ctx.api, session.id, input),
|
|
1200
|
+
"Delivery reconciled \xB7 the same idempotency key names one run"
|
|
1201
|
+
);
|
|
1202
|
+
};
|
|
1203
|
+
const focusOrder = wide ? ["files", "diff", "comments"] : ["diff", "comments"];
|
|
1204
|
+
const cycleFocus = (backwards) => {
|
|
1205
|
+
const index = focusOrder.indexOf(focus);
|
|
1206
|
+
const direction = backwards ? -1 : 1;
|
|
1207
|
+
const next = (index + direction + focusOrder.length) % focusOrder.length;
|
|
1208
|
+
const nextFocus = focusOrder[next] ?? "diff";
|
|
1209
|
+
if (nextFocus === "comments" && selectedComment !== null) {
|
|
1210
|
+
navigateToAnchor(selectedComment.file, selectedComment.line);
|
|
1211
|
+
}
|
|
1212
|
+
setFocus(nextFocus);
|
|
1213
|
+
};
|
|
1214
|
+
useKeyboard((key) => {
|
|
1215
|
+
if (key.ctrl && key.name === "c") return onQuit();
|
|
1216
|
+
if (editor !== null) {
|
|
1217
|
+
if (key.name === "escape" && !lockRef.current) setEditor(null);
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
if (lockRef.current) return;
|
|
1221
|
+
if (key.name === "q") return onQuit();
|
|
1222
|
+
if (key.name === "escape" || key.name === "backspace" || key.name === "h") return onBack();
|
|
1223
|
+
if (key.name === "tab") return cycleFocus(key.shift);
|
|
1224
|
+
if (key.name === "r") {
|
|
1225
|
+
setStatus("Refreshing live change\u2026");
|
|
1226
|
+
return refresh();
|
|
1227
|
+
}
|
|
1228
|
+
if (key.name === "o") {
|
|
1229
|
+
openUrl(`${ctx.config.url}/changes/${changeId}`);
|
|
1230
|
+
setStatus("Opened web review");
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
if (key.name === "c" && key.shift) return openCommentEditor(true);
|
|
1234
|
+
if (key.name === "s") return openSendEditor();
|
|
1235
|
+
if (key.name === "y") return deliverAndRelaunch();
|
|
1236
|
+
if (key.name === ",") return moveTour(-1);
|
|
1237
|
+
if (key.name === ".") return moveTour(1);
|
|
1238
|
+
if (key.name === "t") {
|
|
1239
|
+
return void runAction(
|
|
1240
|
+
data?.tour === null ? "Compose description and tour" : "Recompose tour",
|
|
1241
|
+
() => ctx.api("POST", `/changes/${changeId}/tour`, {})
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
if (key.name === "m") {
|
|
1245
|
+
return void runAction(
|
|
1246
|
+
"Mend reads the change",
|
|
1247
|
+
() => ctx.api("POST", `/changes/${changeId}/read`, {})
|
|
1248
|
+
);
|
|
1249
|
+
}
|
|
1250
|
+
if (key.name === "g") {
|
|
1251
|
+
return void runAction(
|
|
1252
|
+
"Draft concrete suggestions",
|
|
1253
|
+
() => ctx.api("POST", `/changes/${changeId}/suggest`, {})
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
if (focus === "files") {
|
|
1257
|
+
if (key.name === "j" || key.name === "down") return moveFile(1);
|
|
1258
|
+
if (key.name === "k" || key.name === "up") return moveFile(-1);
|
|
1259
|
+
if (key.name === "return" || key.name === "linefeed" || key.name === "l") {
|
|
1260
|
+
return setFocus("diff");
|
|
1261
|
+
}
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
if (focus === "comments") {
|
|
1265
|
+
if (key.name === "j" || key.name === "down") return moveComment(1);
|
|
1266
|
+
if (key.name === "k" || key.name === "up") return moveComment(-1);
|
|
1267
|
+
if (key.name === "a" && selectedComment?.state === "draft") return updateComment("open");
|
|
1268
|
+
if (key.name === "a" && selectedComment?.state === "open") {
|
|
1269
|
+
return updateComment("addressed");
|
|
1270
|
+
}
|
|
1271
|
+
if (key.name === "x") return updateComment("dismissed");
|
|
1272
|
+
if (key.name === "u" && selectedComment !== null) return updateComment("open");
|
|
1273
|
+
if (key.name === "return" || key.name === "linefeed" || key.name === "l") {
|
|
1274
|
+
if (selectedComment !== null) navigateToAnchor(selectedComment.file, selectedComment.line);
|
|
1275
|
+
return setFocus("diff");
|
|
1276
|
+
}
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
if (key.name === "j" || key.name === "down") return moveAnchor(1);
|
|
1280
|
+
if (key.name === "k" || key.name === "up") return moveAnchor(-1);
|
|
1281
|
+
if (key.name === "n") return moveFile(1);
|
|
1282
|
+
if (key.name === "p") return moveFile(-1);
|
|
1283
|
+
if (key.name === "]") return moveHunk(1);
|
|
1284
|
+
if (key.name === "[") return moveHunk(-1);
|
|
1285
|
+
if (key.name === "c") return openCommentEditor(false);
|
|
1286
|
+
if (key.name === "v") {
|
|
1287
|
+
if (selectedAnchor?.newLine === null || selectedAnchor === null) {
|
|
1288
|
+
setStatus("Select a new-side line before starting a range");
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
setRangeStart((current) => current === null ? selectedAnchor.newLine : null);
|
|
1292
|
+
setStatus(
|
|
1293
|
+
rangeStart === null ? `Range starts at line ${selectedAnchor.newLine}` : "Range cleared"
|
|
1294
|
+
);
|
|
1295
|
+
return;
|
|
1296
|
+
}
|
|
1297
|
+
if (key.name === "d" && wide)
|
|
1298
|
+
return setView((current) => current === "unified" ? "split" : "unified");
|
|
1299
|
+
if (key.name === "w") return setWrap((current) => !current);
|
|
1300
|
+
if (key.name === "z") return setShowWhitespace((current) => !current);
|
|
1301
|
+
});
|
|
1302
|
+
if (data === void 0) {
|
|
1303
|
+
return /* @__PURE__ */ jsx("box", { flexGrow: 1, backgroundColor: "transparent", alignItems: "center", justifyContent: "center", children: /* @__PURE__ */ jsx("text", { bg: "transparent", children: /* @__PURE__ */ jsx("span", { fg: failureReason === null ? INK_2 : MUTED, children: failureReason === null ? "Loading the live change\u2026" : `${errorMessage(failureReason)} \xB7 retrying` }) }) });
|
|
1304
|
+
}
|
|
1305
|
+
const additions = data.wire.files.reduce((sum, file) => sum + file.additions, 0);
|
|
1306
|
+
const deletions = data.wire.files.reduce((sum, file) => sum + file.deletions, 0);
|
|
1307
|
+
const openCount = data.comments.filter((comment) => comment.state === "open").length;
|
|
1308
|
+
const draftCount = data.comments.filter((comment) => comment.state === "draft").length;
|
|
1309
|
+
const displayRows = selectedFile === null ? 1 : view === "unified" ? Math.max(1, selectedFile.unifiedRows) : Math.max(1, selectedFile.splitRows);
|
|
1310
|
+
const displayPatch = selectedFile === null || !showWhitespace ? selectedFile?.patch ?? "" : visibleWhitespace(selectedFile.patch);
|
|
1311
|
+
const footer = editor === null ? focus === "files" ? " files \xB7 \u2191\u2193/jk move \xB7 enter review \xB7 tab pane \xB7 esc back \xB7 q quit" : focus === "comments" ? " comments \xB7 \u2191\u2193/jk move \xB7 enter anchor \xB7 a accept/address \xB7 x dismiss \xB7 u reopen \xB7 tab pane" : ` diff \xB7 \u2191\u2193/jk lines \xB7 n/p files \xB7 [/ ] hunks \xB7 v range${rangeStart === null ? "" : `:${rangeStart}`} \xB7 c inline \xB7 C change \xB7 d layout \xB7 w wrap \xB7 z whitespace` : " ctrl+enter save \xB7 esc cancel";
|
|
1312
|
+
const sidebar = wide ? /* @__PURE__ */ jsxs("box", { width: 36, flexShrink: 0, flexDirection: "column", backgroundColor: "transparent", children: [
|
|
1313
|
+
/* @__PURE__ */ jsx(
|
|
1314
|
+
"box",
|
|
1315
|
+
{
|
|
1316
|
+
border: true,
|
|
1317
|
+
borderStyle: "rounded",
|
|
1318
|
+
borderColor: focus === "files" ? COBALT : RULE,
|
|
1319
|
+
title: ` files \xB7 ${files.length} `,
|
|
1320
|
+
titleAlignment: "left",
|
|
1321
|
+
backgroundColor: "transparent",
|
|
1322
|
+
flexGrow: 1,
|
|
1323
|
+
minHeight: 6,
|
|
1324
|
+
children: /* @__PURE__ */ jsx(
|
|
1325
|
+
"scrollbox",
|
|
1326
|
+
{
|
|
1327
|
+
ref: fileScrollRef,
|
|
1328
|
+
focused: focus === "files",
|
|
1329
|
+
flexGrow: 1,
|
|
1330
|
+
minHeight: 0,
|
|
1331
|
+
style: {
|
|
1332
|
+
rootOptions: { backgroundColor: "transparent", border: false },
|
|
1333
|
+
wrapperOptions: { backgroundColor: "transparent" },
|
|
1334
|
+
viewportOptions: { backgroundColor: "transparent" },
|
|
1335
|
+
contentOptions: { backgroundColor: "transparent" }
|
|
1336
|
+
},
|
|
1337
|
+
children: files.map((file, index) => /* @__PURE__ */ jsxs("box", { flexShrink: 0, flexDirection: "column", children: [
|
|
1338
|
+
index === 0 || files[index - 1]?.status !== file.status ? /* @__PURE__ */ jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: ` ${file.status}` }) : null,
|
|
1339
|
+
/* @__PURE__ */ jsx(FileRow, { file, selected: index === fileIndex })
|
|
1340
|
+
] }, file.path))
|
|
1341
|
+
}
|
|
1342
|
+
)
|
|
1343
|
+
}
|
|
1344
|
+
),
|
|
1345
|
+
/* @__PURE__ */ jsx(
|
|
1346
|
+
"box",
|
|
1347
|
+
{
|
|
1348
|
+
border: true,
|
|
1349
|
+
borderStyle: "rounded",
|
|
1350
|
+
borderColor: focus === "comments" ? COBALT : RULE,
|
|
1351
|
+
title: ` comments \xB7 ${openCount} open${draftCount > 0 ? ` \xB7 ${draftCount} draft` : ""} `,
|
|
1352
|
+
titleAlignment: "left",
|
|
1353
|
+
backgroundColor: "transparent",
|
|
1354
|
+
flexGrow: 1,
|
|
1355
|
+
minHeight: 6,
|
|
1356
|
+
children: /* @__PURE__ */ jsx(
|
|
1357
|
+
"scrollbox",
|
|
1358
|
+
{
|
|
1359
|
+
ref: commentScrollRef,
|
|
1360
|
+
focused: focus === "comments",
|
|
1361
|
+
flexGrow: 1,
|
|
1362
|
+
minHeight: 0,
|
|
1363
|
+
style: {
|
|
1364
|
+
rootOptions: { backgroundColor: "transparent", border: false },
|
|
1365
|
+
wrapperOptions: { backgroundColor: "transparent" },
|
|
1366
|
+
viewportOptions: { backgroundColor: "transparent" },
|
|
1367
|
+
contentOptions: { backgroundColor: "transparent" }
|
|
1368
|
+
},
|
|
1369
|
+
children: comments.length === 0 ? /* @__PURE__ */ jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " no review comments yet" }) : comments.map((comment, index) => /* @__PURE__ */ jsx(CommentRow, { comment, selected: index === commentIndex }, comment.id))
|
|
1370
|
+
}
|
|
1371
|
+
)
|
|
1372
|
+
}
|
|
1373
|
+
)
|
|
1374
|
+
] }) : null;
|
|
1375
|
+
return /* @__PURE__ */ jsxs("box", { flexGrow: 1, flexDirection: "column", backgroundColor: "transparent", children: [
|
|
1376
|
+
/* @__PURE__ */ jsxs("box", { height: 2, flexShrink: 0, flexDirection: "column", backgroundColor: "transparent", children: [
|
|
1377
|
+
/* @__PURE__ */ jsxs("text", { height: 1, bg: "transparent", children: [
|
|
1378
|
+
/* @__PURE__ */ jsx("span", { fg: INK, children: " mend" }),
|
|
1379
|
+
/* @__PURE__ */ jsx("span", { fg: FAINT, children: " / " }),
|
|
1380
|
+
/* @__PURE__ */ jsx("span", { fg: MUTED, children: projectName }),
|
|
1381
|
+
/* @__PURE__ */ jsx("span", { fg: FAINT, children: " / " }),
|
|
1382
|
+
/* @__PURE__ */ jsx("span", { fg: INK, children: "review" }),
|
|
1383
|
+
/* @__PURE__ */ jsxs("span", { fg: FAINT, children: [
|
|
1384
|
+
" ",
|
|
1385
|
+
"\xB7 ",
|
|
1386
|
+
session.harness,
|
|
1387
|
+
" ",
|
|
1388
|
+
session.id.slice(0, 8)
|
|
1389
|
+
] }),
|
|
1390
|
+
isFetching ? /* @__PURE__ */ jsx("span", { fg: FAINT, children: " \xB7 syncing" }) : null,
|
|
1391
|
+
/* @__PURE__ */ jsx("span", { fg: checkpointState === "failed" ? MUTED : FAINT, children: checkpointState === "recording" ? " \xB7 checkpointing" : checkpointState === "failed" ? " \xB7 checkpoint incomplete" : " \xB7 checkpoint recorded" })
|
|
1392
|
+
] }),
|
|
1393
|
+
/* @__PURE__ */ jsxs("text", { height: 1, bg: "transparent", children: [
|
|
1394
|
+
/* @__PURE__ */ jsxs("span", { fg: FAINT, children: [
|
|
1395
|
+
" ",
|
|
1396
|
+
data.wire.change.branch.replace(/^mend\/session\//, "session ")
|
|
1397
|
+
] }),
|
|
1398
|
+
/* @__PURE__ */ jsxs("span", { fg: FAINT, children: [
|
|
1399
|
+
" \xB7 worktree vs ",
|
|
1400
|
+
data.wire.change.baseSha.slice(0, 12),
|
|
1401
|
+
" \xB7 "
|
|
1402
|
+
] }),
|
|
1403
|
+
/* @__PURE__ */ jsxs("span", { fg: MUTED, children: [
|
|
1404
|
+
files.length,
|
|
1405
|
+
" files"
|
|
1406
|
+
] }),
|
|
1407
|
+
/* @__PURE__ */ jsxs("span", { fg: GREEN, children: [
|
|
1408
|
+
" +",
|
|
1409
|
+
additions
|
|
1410
|
+
] }),
|
|
1411
|
+
/* @__PURE__ */ jsxs("span", { fg: RED, children: [
|
|
1412
|
+
" \u2212",
|
|
1413
|
+
deletions
|
|
1414
|
+
] }),
|
|
1415
|
+
/* @__PURE__ */ jsxs("span", { fg: MUTED, children: [
|
|
1416
|
+
" \xB7 ",
|
|
1417
|
+
openCount,
|
|
1418
|
+
" open"
|
|
1419
|
+
] })
|
|
1420
|
+
] })
|
|
1421
|
+
] }),
|
|
1422
|
+
/* @__PURE__ */ jsx(
|
|
1423
|
+
Description,
|
|
1424
|
+
{
|
|
1425
|
+
data,
|
|
1426
|
+
tourIndex: activeTourIndex,
|
|
1427
|
+
stale: tourStale,
|
|
1428
|
+
width,
|
|
1429
|
+
height: descriptionHeight
|
|
1430
|
+
}
|
|
1431
|
+
),
|
|
1432
|
+
/* @__PURE__ */ jsxs("box", { flexGrow: 1, minHeight: 0, flexDirection: "row", backgroundColor: "transparent", children: [
|
|
1433
|
+
sidebar,
|
|
1434
|
+
/* @__PURE__ */ jsxs(
|
|
1435
|
+
"box",
|
|
1436
|
+
{
|
|
1437
|
+
flexGrow: 1,
|
|
1438
|
+
minWidth: 0,
|
|
1439
|
+
minHeight: 0,
|
|
1440
|
+
flexDirection: "column",
|
|
1441
|
+
backgroundColor: "transparent",
|
|
1442
|
+
children: [
|
|
1443
|
+
/* @__PURE__ */ jsx(
|
|
1444
|
+
"box",
|
|
1445
|
+
{
|
|
1446
|
+
border: true,
|
|
1447
|
+
borderStyle: "rounded",
|
|
1448
|
+
borderColor: focus === "diff" ? COBALT : RULE,
|
|
1449
|
+
title: selectedFile === null ? " diff " : ` ${selectedFile.path} \xB7 ${view}${selectedFile.likelyGenerated ? " \xB7 inferred likely generated" : ""}${showWhitespace ? " \xB7 whitespace" : ""}${wrap ? " \xB7 wrapped" : ""} `,
|
|
1450
|
+
titleAlignment: "left",
|
|
1451
|
+
backgroundColor: "transparent",
|
|
1452
|
+
flexGrow: 1,
|
|
1453
|
+
minHeight: 0,
|
|
1454
|
+
children: selectedFile === null || selectedFile.patch === "" || selectedFile.binary ? /* @__PURE__ */ jsx("box", { flexGrow: 1, alignItems: "center", justifyContent: "center", children: /* @__PURE__ */ jsx("text", { fg: FAINT, bg: "transparent", children: files.length === 0 ? "The worktree matches its base \u2014 nothing to review yet." : selectedFile?.binary === true ? "Binary change \u2014 text diff unavailable; statistics remain reviewable." : "This file has no renderable text patch." }) }) : /* @__PURE__ */ jsx(
|
|
1455
|
+
"scrollbox",
|
|
1456
|
+
{
|
|
1457
|
+
ref: diffScrollRef,
|
|
1458
|
+
focused: focus === "diff",
|
|
1459
|
+
flexGrow: 1,
|
|
1460
|
+
minHeight: 0,
|
|
1461
|
+
scrollX: true,
|
|
1462
|
+
style: {
|
|
1463
|
+
rootOptions: { backgroundColor: "transparent", border: false },
|
|
1464
|
+
wrapperOptions: { backgroundColor: "transparent" },
|
|
1465
|
+
viewportOptions: { backgroundColor: "transparent" },
|
|
1466
|
+
contentOptions: { backgroundColor: "transparent" }
|
|
1467
|
+
},
|
|
1468
|
+
children: /* @__PURE__ */ jsx(
|
|
1469
|
+
"diff",
|
|
1470
|
+
{
|
|
1471
|
+
ref: diffRef,
|
|
1472
|
+
diff: displayPatch,
|
|
1473
|
+
view,
|
|
1474
|
+
syncScroll: true,
|
|
1475
|
+
...selectedFile.filetype === void 0 ? {} : { filetype: selectedFile.filetype },
|
|
1476
|
+
syntaxStyle,
|
|
1477
|
+
wrapMode: wrap ? "word" : "none",
|
|
1478
|
+
showLineNumbers: true,
|
|
1479
|
+
fg: INK,
|
|
1480
|
+
lineNumberFg: FAINT,
|
|
1481
|
+
lineNumberBg: "transparent",
|
|
1482
|
+
contextBg: "transparent",
|
|
1483
|
+
contextContentBg: "transparent",
|
|
1484
|
+
addedBg: ADD_WASH,
|
|
1485
|
+
addedContentBg: ADD_WASH,
|
|
1486
|
+
removedBg: DELETE_WASH,
|
|
1487
|
+
removedContentBg: DELETE_WASH,
|
|
1488
|
+
addedSignColor: GREEN,
|
|
1489
|
+
removedSignColor: RED,
|
|
1490
|
+
addedLineNumberBg: "transparent",
|
|
1491
|
+
removedLineNumberBg: "transparent",
|
|
1492
|
+
selectionBg: WASH,
|
|
1493
|
+
selectionFg: INK,
|
|
1494
|
+
width: "100%",
|
|
1495
|
+
height: displayRows,
|
|
1496
|
+
flexShrink: 0
|
|
1497
|
+
},
|
|
1498
|
+
`${selectedFile.path}-${view}-${wrap}-${showWhitespace}`
|
|
1499
|
+
)
|
|
1500
|
+
}
|
|
1501
|
+
)
|
|
1502
|
+
}
|
|
1503
|
+
),
|
|
1504
|
+
focus === "comments" && detailComment !== null ? /* @__PURE__ */ jsx(EvidenceCard, { comment: detailComment, width: wide ? width - 38 : width }) : height < 30 && activeTourStop !== null ? /* @__PURE__ */ jsx(
|
|
1505
|
+
CompactTourEvidence,
|
|
1506
|
+
{
|
|
1507
|
+
stop: activeTourStop,
|
|
1508
|
+
index: activeTourIndex,
|
|
1509
|
+
total: tourStops.length,
|
|
1510
|
+
width
|
|
1511
|
+
}
|
|
1512
|
+
) : detailComment === null ? null : /* @__PURE__ */ jsx(EvidenceCard, { comment: detailComment, width: wide ? width - 38 : width })
|
|
1513
|
+
]
|
|
1514
|
+
}
|
|
1515
|
+
)
|
|
1516
|
+
] }),
|
|
1517
|
+
editor === null ? null : /* @__PURE__ */ jsx(
|
|
1518
|
+
EditorPanel,
|
|
1519
|
+
{
|
|
1520
|
+
editor,
|
|
1521
|
+
refValue: editorRef,
|
|
1522
|
+
busy,
|
|
1523
|
+
onSubmit: () => void submitEditor()
|
|
1524
|
+
}
|
|
1525
|
+
),
|
|
1526
|
+
/* @__PURE__ */ jsx("text", { height: 1, fg: status === "" ? FAINT : INK_2, bg: "transparent", children: status === "" ? ` m read \xB7 g suggest \xB7 t tour \xB7 ,/. tour stops \xB7 s draft review${data.followUp?.status === "pending" ? " \xB7 y deliver & relaunch" : ""} \xB7 o web \xB7 r refresh` : ` ${status}` }),
|
|
1527
|
+
/* @__PURE__ */ jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: footer })
|
|
1528
|
+
] });
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
// src/workbench-events.ts
|
|
1532
|
+
var createSseParser = () => {
|
|
1533
|
+
let buffer = "";
|
|
1534
|
+
return {
|
|
1535
|
+
push: (chunk) => {
|
|
1536
|
+
buffer += chunk;
|
|
1537
|
+
const payloads = [];
|
|
1538
|
+
for (; ; ) {
|
|
1539
|
+
const boundary = buffer.indexOf("\n\n");
|
|
1540
|
+
if (boundary === -1) break;
|
|
1541
|
+
const frame = buffer.slice(0, boundary);
|
|
1542
|
+
buffer = buffer.slice(boundary + 2);
|
|
1543
|
+
const data = frame.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice("data:".length).replace(/^ /, "")).join("\n");
|
|
1544
|
+
if (data !== "") payloads.push(data);
|
|
1545
|
+
}
|
|
1546
|
+
return payloads;
|
|
1547
|
+
}
|
|
1548
|
+
};
|
|
1549
|
+
};
|
|
1550
|
+
var eventFamilies = (payload) => {
|
|
1551
|
+
let parsed;
|
|
1552
|
+
try {
|
|
1553
|
+
parsed = JSON.parse(payload);
|
|
1554
|
+
} catch {
|
|
1555
|
+
return [];
|
|
1556
|
+
}
|
|
1557
|
+
if (typeof parsed !== "object" || parsed === null || !("type" in parsed)) return [];
|
|
1558
|
+
switch (parsed.type) {
|
|
1559
|
+
case "project":
|
|
1560
|
+
case "worktree":
|
|
1561
|
+
case "session-process":
|
|
1562
|
+
return ["workbench"];
|
|
1563
|
+
// Session facts feed both surfaces: rows and annotations on the
|
|
1564
|
+
// dashboard; status and the follow-up banner inside a review.
|
|
1565
|
+
case "session":
|
|
1566
|
+
case "agent-conversation":
|
|
1567
|
+
// A comment moves the review AND the dashboard's open-comment counts.
|
|
1568
|
+
case "session-change":
|
|
1569
|
+
case "review-comment":
|
|
1570
|
+
return ["workbench", "review"];
|
|
1571
|
+
default:
|
|
1572
|
+
return [];
|
|
1573
|
+
}
|
|
1574
|
+
};
|
|
1575
|
+
|
|
1576
|
+
// src/dashboard.tsx
|
|
1577
|
+
import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "@opentui/react/jsx-runtime";
|
|
1578
|
+
var STATUS_COLOR = {
|
|
1579
|
+
starting: INK_2,
|
|
1580
|
+
running: INK,
|
|
1581
|
+
waiting: INK_2,
|
|
1582
|
+
idle: INK_2,
|
|
1583
|
+
completed: FAINT,
|
|
1584
|
+
failed: RED,
|
|
1585
|
+
stopped: FAINT
|
|
1586
|
+
};
|
|
1587
|
+
var timeAgo = (iso) => {
|
|
1588
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
1589
|
+
if (!Number.isFinite(ms) || ms < 0) return "";
|
|
1590
|
+
const minutes = Math.floor(ms / 6e4);
|
|
1591
|
+
if (minutes < 1) return "just now";
|
|
1592
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
1593
|
+
const hours = Math.floor(minutes / 60);
|
|
1594
|
+
if (hours < 24) return `${hours}h ago`;
|
|
1595
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
1596
|
+
};
|
|
1597
|
+
var AGENT_LIVE_STATUSES = /* @__PURE__ */ new Set(["starting", "running", "waiting"]);
|
|
1598
|
+
var Gutter = ({ selected }) => /* @__PURE__ */ jsx2("span", { fg: selected ? COBALT : FAINT, children: selected ? "\u258C " : " " });
|
|
1599
|
+
var Pane = ({
|
|
1600
|
+
title,
|
|
1601
|
+
focused,
|
|
1602
|
+
children,
|
|
1603
|
+
width,
|
|
1604
|
+
height,
|
|
1605
|
+
grow
|
|
1606
|
+
}) => /* @__PURE__ */ jsx2(
|
|
1607
|
+
"box",
|
|
1608
|
+
{
|
|
1609
|
+
border: true,
|
|
1610
|
+
borderStyle: "rounded",
|
|
1611
|
+
borderColor: focused ? COBALT : RULE,
|
|
1612
|
+
title: ` ${title} `,
|
|
1613
|
+
titleAlignment: "left",
|
|
1614
|
+
backgroundColor: "transparent",
|
|
1615
|
+
...width === void 0 ? {} : { width, flexShrink: 0, minHeight: 0 },
|
|
1616
|
+
...height === void 0 ? {} : { height, flexShrink: 0 },
|
|
1617
|
+
...grow === true ? { flexGrow: 1, flexShrink: 1, minHeight: 0, minWidth: 0 } : {},
|
|
1618
|
+
flexDirection: "column",
|
|
1619
|
+
children
|
|
1620
|
+
}
|
|
1621
|
+
);
|
|
1622
|
+
var paneScrollStyle = {
|
|
1623
|
+
rootOptions: { backgroundColor: "transparent", border: false },
|
|
1624
|
+
wrapperOptions: { backgroundColor: "transparent" },
|
|
1625
|
+
viewportOptions: { backgroundColor: "transparent" },
|
|
1626
|
+
contentOptions: { backgroundColor: "transparent" }
|
|
1627
|
+
};
|
|
1628
|
+
var ProjectRow = ({
|
|
1629
|
+
item,
|
|
1630
|
+
selected,
|
|
1631
|
+
nameWidth
|
|
1632
|
+
}) => /* @__PURE__ */ jsx2("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: /* @__PURE__ */ jsxs2("text", { height: 1, bg: "transparent", children: [
|
|
1633
|
+
/* @__PURE__ */ jsx2(Gutter, { selected }),
|
|
1634
|
+
/* @__PURE__ */ jsx2("span", { fg: INK, children: item.project.name.padEnd(nameWidth) }),
|
|
1635
|
+
/* @__PURE__ */ jsx2("span", { fg: item.total === 0 ? FAINT : MUTED, children: ` ${item.total}` }),
|
|
1636
|
+
item.live > 0 ? /* @__PURE__ */ jsx2("span", { fg: MUTED, children: ` \xB7 ${item.live} live` }) : null,
|
|
1637
|
+
item.open > 0 ? /* @__PURE__ */ jsx2("span", { fg: MUTED, children: ` \xB7 ${item.open}` }) : null
|
|
1638
|
+
] }) });
|
|
1639
|
+
var ProcessLines = ({
|
|
1640
|
+
processes,
|
|
1641
|
+
services,
|
|
1642
|
+
indent
|
|
1643
|
+
}) => /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
1644
|
+
processes.map((process2) => /* @__PURE__ */ jsx2("box", { height: 1, flexShrink: 0, backgroundColor: "transparent", children: /* @__PURE__ */ jsxs2("text", { height: 1, bg: "transparent", children: [
|
|
1645
|
+
/* @__PURE__ */ jsx2("span", { fg: FAINT, children: indent }),
|
|
1646
|
+
/* @__PURE__ */ jsx2("span", { fg: INK_2, children: process2.kind === "shell" ? process2.label ?? "shell" : process2.harness ?? process2.kind }),
|
|
1647
|
+
/* @__PURE__ */ jsxs2("span", { fg: FAINT, children: [
|
|
1648
|
+
process2.kind === "agent-protocol" ? " \xB7 protocol" : process2.kind === "agent-external" ? " \xB7 external" : "",
|
|
1649
|
+
` ${process2.status}`
|
|
1650
|
+
] })
|
|
1651
|
+
] }) }, process2.id)),
|
|
1652
|
+
services.map((service) => /* @__PURE__ */ jsx2("box", { height: 1, flexShrink: 0, backgroundColor: "transparent", children: /* @__PURE__ */ jsxs2("text", { height: 1, bg: "transparent", children: [
|
|
1653
|
+
/* @__PURE__ */ jsx2("span", { fg: FAINT, children: indent }),
|
|
1654
|
+
/* @__PURE__ */ jsx2("span", { fg: service.status === "reachable" ? INK_2 : MUTED, children: `${service.label ?? service.id.slice(0, 6)} :${service.workspacePort ?? "?"}${service.protocol === "udp" ? "u" : ""}\u2192${service.hostPort ?? "?"}` }),
|
|
1655
|
+
/* @__PURE__ */ jsx2("span", { fg: FAINT, children: ` ${service.status}` })
|
|
1656
|
+
] }) }, service.id))
|
|
1657
|
+
] });
|
|
1658
|
+
var WorktreeHeaderRow = ({
|
|
1659
|
+
group,
|
|
1660
|
+
selected
|
|
1661
|
+
}) => {
|
|
1662
|
+
const folded = foldGroupStatus(group);
|
|
1663
|
+
const color = group.live > 0 ? STATUS_COLOR[folded] ?? MUTED : FAINT;
|
|
1664
|
+
const age = timeAgo(group.sessions.at(-1)?.session.createdAt ?? group.createdAt).replace(
|
|
1665
|
+
" ago",
|
|
1666
|
+
""
|
|
1667
|
+
);
|
|
1668
|
+
const open = group.annotation?.openComments ?? 0;
|
|
1669
|
+
return /* @__PURE__ */ jsx2("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: /* @__PURE__ */ jsxs2("text", { height: 1, bg: "transparent", children: [
|
|
1670
|
+
/* @__PURE__ */ jsx2(Gutter, { selected }),
|
|
1671
|
+
/* @__PURE__ */ jsx2("span", { fg: INK, children: group.name.slice(0, 30).padEnd(31) }),
|
|
1672
|
+
/* @__PURE__ */ jsx2("span", { fg: color, children: (group.live > 0 ? folded : "settled").padEnd(10) }),
|
|
1673
|
+
/* @__PURE__ */ jsx2("span", { fg: FAINT, children: age.padEnd(9) }),
|
|
1674
|
+
/* @__PURE__ */ jsx2("span", { fg: MUTED, children: `${group.sessions.length} session${group.sessions.length === 1 ? "" : "s"}` }),
|
|
1675
|
+
open > 0 ? /* @__PURE__ */ jsx2("span", { fg: MUTED, children: ` \xB7 ${open} open` }) : null
|
|
1676
|
+
] }) });
|
|
1677
|
+
};
|
|
1678
|
+
var SessionChildRow = ({
|
|
1679
|
+
item,
|
|
1680
|
+
selected
|
|
1681
|
+
}) => {
|
|
1682
|
+
const { session, services, processes } = item;
|
|
1683
|
+
const color = STATUS_COLOR[session.status] ?? MUTED;
|
|
1684
|
+
const age = timeAgo(session.createdAt).replace(" ago", "");
|
|
1685
|
+
const name = session.label ?? `session ${session.id.slice(0, 8)}`;
|
|
1686
|
+
return /* @__PURE__ */ jsxs2("box", { flexShrink: 0, flexDirection: "column", backgroundColor: "transparent", children: [
|
|
1687
|
+
/* @__PURE__ */ jsx2("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: /* @__PURE__ */ jsxs2("text", { height: 1, bg: "transparent", children: [
|
|
1688
|
+
/* @__PURE__ */ jsx2(Gutter, { selected }),
|
|
1689
|
+
/* @__PURE__ */ jsx2("span", { fg: FAINT, children: " \u2514 " }),
|
|
1690
|
+
/* @__PURE__ */ jsx2("span", { fg: INK, children: name.slice(0, 26).padEnd(27) }),
|
|
1691
|
+
/* @__PURE__ */ jsx2("span", { fg: color, children: session.status.padEnd(10) }),
|
|
1692
|
+
/* @__PURE__ */ jsx2("span", { fg: FAINT, children: age.padEnd(9) }),
|
|
1693
|
+
/* @__PURE__ */ jsx2("span", { fg: MUTED, children: session.harness.padEnd(10) })
|
|
1694
|
+
] }) }),
|
|
1695
|
+
/* @__PURE__ */ jsx2(ProcessLines, { processes, services, indent: " \u2514 " })
|
|
1696
|
+
] });
|
|
1697
|
+
};
|
|
1698
|
+
var HarnessRow = ({
|
|
1699
|
+
item,
|
|
1700
|
+
selected
|
|
1701
|
+
}) => /* @__PURE__ */ jsx2("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: /* @__PURE__ */ jsxs2("text", { height: 1, bg: "transparent", children: [
|
|
1702
|
+
/* @__PURE__ */ jsx2(Gutter, { selected }),
|
|
1703
|
+
/* @__PURE__ */ jsx2("span", { fg: INK, children: item.label.padEnd(12) }),
|
|
1704
|
+
/* @__PURE__ */ jsx2("span", { fg: FAINT, children: item.hint })
|
|
1705
|
+
] }) });
|
|
1706
|
+
var SessionDetail = ({ item }) => {
|
|
1707
|
+
if (item === null) {
|
|
1708
|
+
return /* @__PURE__ */ jsx2("text", { height: 1, bg: "transparent", fg: FAINT, children: " no session selected \u2014 n starts one" });
|
|
1709
|
+
}
|
|
1710
|
+
const { session, annotation, services } = item;
|
|
1711
|
+
const color = STATUS_COLOR[session.status] ?? MUTED;
|
|
1712
|
+
const summary = session.summary?.split("\n")[0] ?? null;
|
|
1713
|
+
return /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
1714
|
+
/* @__PURE__ */ jsxs2("text", { height: 1, bg: "transparent", children: [
|
|
1715
|
+
/* @__PURE__ */ jsx2("span", { children: " " }),
|
|
1716
|
+
/* @__PURE__ */ jsx2("span", { fg: color, children: session.status }),
|
|
1717
|
+
/* @__PURE__ */ jsx2("span", { fg: FAINT, children: " \xB7 " }),
|
|
1718
|
+
/* @__PURE__ */ jsx2("span", { fg: MUTED, children: session.branch }),
|
|
1719
|
+
/* @__PURE__ */ jsxs2("span", { fg: FAINT, children: [
|
|
1720
|
+
session.baseSha === "" ? "" : ` vs ${session.baseRef ?? session.baseSha.slice(0, 12)}`,
|
|
1721
|
+
" \xB7 started ",
|
|
1722
|
+
timeAgo(session.createdAt)
|
|
1723
|
+
] })
|
|
1724
|
+
] }),
|
|
1725
|
+
/* @__PURE__ */ jsxs2("text", { height: 1, bg: "transparent", children: [
|
|
1726
|
+
/* @__PURE__ */ jsx2("span", { children: " " }),
|
|
1727
|
+
services.length === 0 ? /* @__PURE__ */ jsx2("span", { fg: FAINT, children: "no services running" }) : services.slice(0, 3).map((service, index) => /* @__PURE__ */ jsxs2("span", { children: [
|
|
1728
|
+
index > 0 ? /* @__PURE__ */ jsx2("span", { fg: FAINT, children: " \xB7 " }) : null,
|
|
1729
|
+
/* @__PURE__ */ jsx2("span", { fg: service.status === "reachable" ? INK_2 : MUTED, children: `${service.label ?? service.id.slice(0, 6)} :${service.workspacePort ?? "?"}${service.protocol === "udp" ? "u" : ""}\u2192${service.hostPort ?? "?"} ${service.status}` })
|
|
1730
|
+
] }, service.id)),
|
|
1731
|
+
services.length > 3 ? /* @__PURE__ */ jsx2("span", { fg: FAINT, children: ` \xB7 +${services.length - 3} more` }) : null
|
|
1732
|
+
] }),
|
|
1733
|
+
/* @__PURE__ */ jsxs2("text", { height: 1, bg: "transparent", children: [
|
|
1734
|
+
/* @__PURE__ */ jsx2("span", { children: " " }),
|
|
1735
|
+
annotation === void 0 || annotation.openComments === 0 ? /* @__PURE__ */ jsx2("span", { fg: FAINT, children: "no open review comments" }) : /* @__PURE__ */ jsxs2("span", { fg: INK_2, children: [
|
|
1736
|
+
annotation.openComments,
|
|
1737
|
+
" open comment",
|
|
1738
|
+
annotation.openComments === 1 ? "" : "s"
|
|
1739
|
+
] }),
|
|
1740
|
+
annotation?.pendingFollowUp === true ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
1741
|
+
/* @__PURE__ */ jsx2("span", { fg: FAINT, children: " \xB7 " }),
|
|
1742
|
+
/* @__PURE__ */ jsx2("span", { fg: INK_2, children: "follow-up pending" })
|
|
1743
|
+
] }) : null,
|
|
1744
|
+
annotation?.changeId != null ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
1745
|
+
/* @__PURE__ */ jsx2("span", { fg: FAINT, children: " \xB7 " }),
|
|
1746
|
+
/* @__PURE__ */ jsx2("span", { fg: MUTED, children: "v reviews the change" })
|
|
1747
|
+
] }) : null
|
|
1748
|
+
] }),
|
|
1749
|
+
/* @__PURE__ */ jsx2("text", { height: 1, bg: "transparent", fg: summary === null ? FAINT : MUTED, children: ` ${summary ?? "no summary yet"}` })
|
|
1750
|
+
] });
|
|
1751
|
+
};
|
|
1752
|
+
var StatusLine = ({
|
|
1753
|
+
busy,
|
|
1754
|
+
busyStarted,
|
|
1755
|
+
status
|
|
1756
|
+
}) => {
|
|
1757
|
+
const [now, setNow] = useState2(() => Date.now());
|
|
1758
|
+
useEffect2(() => {
|
|
1759
|
+
if (busy !== null) {
|
|
1760
|
+
const timer = setInterval(() => setNow(Date.now()), 1e3);
|
|
1761
|
+
return () => clearInterval(timer);
|
|
1762
|
+
}
|
|
1763
|
+
if (status !== null) {
|
|
1764
|
+
const timer = setTimeout(() => setNow(Date.now()), 5100);
|
|
1765
|
+
return () => clearTimeout(timer);
|
|
1766
|
+
}
|
|
1767
|
+
return void 0;
|
|
1768
|
+
}, [busy, status]);
|
|
1769
|
+
const text = busy !== null ? ` ${busy} ${Math.max(0, Math.round((now - busyStarted) / 1e3))}s` : status !== null && Date.now() - status.at < 5e3 ? ` ${status.text}` : "";
|
|
1770
|
+
return /* @__PURE__ */ jsx2("text", { height: 1, fg: INK_2, bg: "transparent", children: text });
|
|
1771
|
+
};
|
|
1772
|
+
var errorText = (error) => error instanceof Error ? error.message : String(error);
|
|
1773
|
+
var keepSpanVisible = (scroll, top, height) => {
|
|
1774
|
+
if (scroll === null) return;
|
|
1775
|
+
const viewH = Math.max(1, scroll.viewport.height);
|
|
1776
|
+
if (top < scroll.scrollTop) scroll.scrollTo(top);
|
|
1777
|
+
else if (top + height > scroll.scrollTop + viewH) scroll.scrollTo(top + height - viewH);
|
|
1778
|
+
};
|
|
1779
|
+
var keepRowVisible = (scroll, index) => keepSpanVisible(scroll, index, 1);
|
|
1780
|
+
var PROJECTS_PANE_WIDTH = 30;
|
|
1781
|
+
var noShare = () => "off";
|
|
1782
|
+
var App = ({ ctx, onQuit }) => {
|
|
1783
|
+
const renderer = useRenderer();
|
|
1784
|
+
const queryClient = useQueryClient2();
|
|
1785
|
+
const { data, failureReason } = useQuery2({
|
|
1786
|
+
queryKey: WORKBENCH_KEY,
|
|
1787
|
+
queryFn: () => fetchWorkbench(ctx),
|
|
1788
|
+
// A dev server mid-restart is normal: keep trying, once a second.
|
|
1789
|
+
retry: true,
|
|
1790
|
+
retryDelay: 1e3
|
|
1791
|
+
});
|
|
1792
|
+
const [focusState, setFocus] = useState2("sessions");
|
|
1793
|
+
const [projectKey, setProjectKey] = useState2(null);
|
|
1794
|
+
const [sessionKey, setSessionKey] = useState2(null);
|
|
1795
|
+
const [picker, setPicker] = useState2(null);
|
|
1796
|
+
const [pickerIndex, setPickerIndex] = useState2(0);
|
|
1797
|
+
const [busy, setBusy] = useState2(null);
|
|
1798
|
+
const [busyStarted, setBusyStarted] = useState2(0);
|
|
1799
|
+
const [status, setStatus] = useState2(null);
|
|
1800
|
+
const shareState = useSyncExternalStore(
|
|
1801
|
+
(onChange) => ctx.agentShare?.subscribe(() => onChange()) ?? (() => {
|
|
1802
|
+
}),
|
|
1803
|
+
ctx.agentShare === null ? noShare : ctx.agentShare.snapshot
|
|
1804
|
+
);
|
|
1805
|
+
const [editing, setEditing] = useState2(null);
|
|
1806
|
+
const [stopArmed, setStopArmed] = useState2(null);
|
|
1807
|
+
const [creating, setCreating] = useState2(null);
|
|
1808
|
+
const [adoptOffer, setAdoptOffer] = useState2(null);
|
|
1809
|
+
const adoptOfferDecided = useRef2(false);
|
|
1810
|
+
const [reviewing, setReviewing] = useState2(null);
|
|
1811
|
+
const lockRef = useRef2(false);
|
|
1812
|
+
const modalRef = useRef2(false);
|
|
1813
|
+
modalRef.current = editing !== null || reviewing !== null || picker !== null || creating !== null;
|
|
1814
|
+
const { width: terminalCols, height: terminalRows } = useTerminalDimensions2();
|
|
1815
|
+
const showProjectsPane = terminalCols >= 72;
|
|
1816
|
+
const showDetail = terminalRows >= 18;
|
|
1817
|
+
const focus = showProjectsPane ? focusState : "sessions";
|
|
1818
|
+
const projectItems = deriveProjects(data);
|
|
1819
|
+
const homeProject = data === void 0 ? void 0 : matchProjectByCwd(data.projects, cwdFacts(ctx.cwd));
|
|
1820
|
+
const projectIndexRaw = projectKey === null ? -1 : projectItems.findIndex((p) => p.project.id === projectKey);
|
|
1821
|
+
const projectIndex = projectIndexRaw !== -1 ? projectIndexRaw : Math.max(
|
|
1822
|
+
0,
|
|
1823
|
+
homeProject === void 0 ? 0 : projectItems.findIndex((p) => p.project.id === homeProject.id)
|
|
1824
|
+
);
|
|
1825
|
+
const selectedProject = projectItems[projectIndex] ?? null;
|
|
1826
|
+
const worktreeGroups = deriveWorktrees(data, selectedProject?.project.id ?? null);
|
|
1827
|
+
const rows = deriveRows(worktreeGroups);
|
|
1828
|
+
const rowIndexRaw = sessionKey === null ? -1 : rows.findIndex((row) => rowKeyOf(row) === sessionKey);
|
|
1829
|
+
const rowIndex = rowIndexRaw === -1 ? 0 : rowIndexRaw;
|
|
1830
|
+
const selectedRow = rows[rowIndex] ?? null;
|
|
1831
|
+
const selectedGroup = selectedRow?.group ?? null;
|
|
1832
|
+
const selectedSession = selectedRow === null ? null : selectedRow.kind === "session" ? selectedRow.item : selectedRow.group.sessions.find((item) => LIVE_STATUSES.has(item.session.status)) ?? selectedRow.group.sessions[0] ?? null;
|
|
1833
|
+
const pickerItems = picker === null ? [] : deriveHarnesses(picker.session);
|
|
1834
|
+
const selectSession = (id) => setSessionKey(`s:${id}`);
|
|
1835
|
+
const say = (text) => setStatus({ text, at: Date.now() });
|
|
1836
|
+
const refetch = () => void queryClient.invalidateQueries({ queryKey: WORKBENCH_KEY });
|
|
1837
|
+
useEffect2(() => {
|
|
1838
|
+
const share = ctx.agentShare;
|
|
1839
|
+
if (share === null) return;
|
|
1840
|
+
return share.subscribe((event) => {
|
|
1841
|
+
if (event === null) return;
|
|
1842
|
+
if (event.kind === "sign-requested") {
|
|
1843
|
+
say(`\u270E signature requested (${event.context}) \u2014 touch your key if it blinks`);
|
|
1844
|
+
} else if (event.kind === "signed") say(`\u2713 signed (${event.seconds.toFixed(1)}s)`);
|
|
1845
|
+
else if (event.kind === "not-signed") say(`\u2717 not signed \u2014 ${event.message}`);
|
|
1846
|
+
});
|
|
1847
|
+
}, [ctx.agentShare]);
|
|
1848
|
+
useEffect2(() => {
|
|
1849
|
+
if (data === void 0 || adoptOfferDecided.current) return;
|
|
1850
|
+
if (homeProject !== void 0) return;
|
|
1851
|
+
adoptOfferDecided.current = true;
|
|
1852
|
+
setAdoptOffer(deriveAdoptOffer(cwdFacts(ctx.cwd)));
|
|
1853
|
+
}, [data, homeProject]);
|
|
1854
|
+
const projectScrollRef = useRef2(null);
|
|
1855
|
+
const sessionScrollRef = useRef2(null);
|
|
1856
|
+
useEffect2(() => {
|
|
1857
|
+
keepRowVisible(projectScrollRef.current, projectIndex);
|
|
1858
|
+
}, [projectIndex]);
|
|
1859
|
+
useEffect2(() => {
|
|
1860
|
+
const rowHeight = (row) => row === void 0 || row.kind === "worktree" ? 1 : 1 + row.item.processes.length + row.item.services.length;
|
|
1861
|
+
let top = 0;
|
|
1862
|
+
for (const [index, row] of rows.entries()) {
|
|
1863
|
+
if (index === rowIndex) break;
|
|
1864
|
+
top += rowHeight(row);
|
|
1865
|
+
}
|
|
1866
|
+
keepSpanVisible(sessionScrollRef.current, top, rowHeight(rows[rowIndex]));
|
|
1867
|
+
}, [rowIndex, rows]);
|
|
1868
|
+
const attachFlow = async (session) => {
|
|
1869
|
+
const short = session.id.slice(0, 8);
|
|
1870
|
+
lockRef.current = true;
|
|
1871
|
+
renderer.suspend();
|
|
1872
|
+
process.stdout.write(`
|
|
1873
|
+
attached \xB7 ${session.harness} \xB7 ${short} \xB7 detach: Ctrl+]
|
|
1874
|
+
|
|
1875
|
+
`);
|
|
1876
|
+
let outcome;
|
|
1877
|
+
try {
|
|
1878
|
+
outcome = await ctx.attachTty(session.id, session.harness);
|
|
1879
|
+
if (outcome === "unavailable") {
|
|
1880
|
+
const detail = await ctx.api(
|
|
1881
|
+
"GET",
|
|
1882
|
+
`/sessions/${session.id}`
|
|
1883
|
+
);
|
|
1884
|
+
const liveProtocol = liveProtocolOf(detail.processes);
|
|
1885
|
+
const existing = liveShellOf(detail.processes);
|
|
1886
|
+
if (liveProtocol !== null) {
|
|
1887
|
+
process.stdout.write(`taking over from the protocol session \u2014 same conversation\u2026
|
|
1888
|
+
|
|
1889
|
+
`);
|
|
1890
|
+
await ctx.api("POST", `/sessions/${session.id}/handoff`, { to: "pty" });
|
|
1891
|
+
outcome = await ctx.attachTty(session.id, session.harness);
|
|
1892
|
+
} else if (existing !== null) {
|
|
1893
|
+
process.stdout.write(`no live terminal \u2014 rejoining the open shell
|
|
1894
|
+
|
|
1895
|
+
`);
|
|
1896
|
+
outcome = await ctx.attachTty(session.id, "shell", existing.id);
|
|
1897
|
+
} else {
|
|
1898
|
+
const shell = await ctx.api(
|
|
1899
|
+
"POST",
|
|
1900
|
+
`/sessions/${session.id}/shell`
|
|
1901
|
+
);
|
|
1902
|
+
process.stdout.write(`no live terminal \u2014 opened a shell in the workspace
|
|
1903
|
+
|
|
1904
|
+
`);
|
|
1905
|
+
outcome = await ctx.attachTty(session.id, "shell", shell.id);
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
} catch (error) {
|
|
1909
|
+
say(error instanceof Error ? error.message : String(error));
|
|
1910
|
+
return;
|
|
1911
|
+
} finally {
|
|
1912
|
+
renderer.resume();
|
|
1913
|
+
lockRef.current = false;
|
|
1914
|
+
}
|
|
1915
|
+
say(
|
|
1916
|
+
outcome === "unavailable" ? "attach unavailable \u2014 could not connect" : outcome === "detached" || outcome === "interrupted" ? `detached \u2014 ${short} keeps running` : outcome === "dropped" ? `disconnected \u2014 ${short} keeps running` : `session settled \xB7 ${short}`
|
|
1917
|
+
);
|
|
1918
|
+
refetch();
|
|
1919
|
+
};
|
|
1920
|
+
const settleRefetch = () => {
|
|
1921
|
+
if (queryClient.isMutating() === 1) refetch();
|
|
1922
|
+
};
|
|
1923
|
+
const workbench = () => queryClient.getQueryData(WORKBENCH_KEY);
|
|
1924
|
+
const patchWorkbench = (f) => {
|
|
1925
|
+
const current = workbench();
|
|
1926
|
+
if (current !== void 0) queryClient.setQueryData(WORKBENCH_KEY, f(current));
|
|
1927
|
+
};
|
|
1928
|
+
const attachIfIdle = async (session) => {
|
|
1929
|
+
if (lockRef.current || modalRef.current) {
|
|
1930
|
+
say(`session ready \xB7 ${session.id.slice(0, 8)} \u2014 enter attaches`);
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
await attachFlow(session);
|
|
1934
|
+
};
|
|
1935
|
+
const launchMutation = useMutation2({
|
|
1936
|
+
mutationFn: async (vars) => {
|
|
1937
|
+
const argv = HARNESS_COMMANDS[vars.harness];
|
|
1938
|
+
if (argv === void 0) throw new Error(`unknown harness "${vars.harness}"`);
|
|
1939
|
+
const session = await ctx.api("POST", `/projects/${vars.projectId}/sessions`, {
|
|
1940
|
+
harness: vars.harness,
|
|
1941
|
+
label: null,
|
|
1942
|
+
name: vars.name,
|
|
1943
|
+
base: vars.base
|
|
1944
|
+
});
|
|
1945
|
+
await ctx.api("POST", `/sessions/${session.id}/launch`, { argv });
|
|
1946
|
+
return session;
|
|
1947
|
+
},
|
|
1948
|
+
onMutate: async (vars) => {
|
|
1949
|
+
await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
|
|
1950
|
+
patchWorkbench(
|
|
1951
|
+
(current) => prependSession(current, vars.projectId, {
|
|
1952
|
+
id: vars.pendingKey,
|
|
1953
|
+
harness: vars.harness,
|
|
1954
|
+
label: null,
|
|
1955
|
+
branch: vars.name === null ? "provisioning\u2026" : `mend/${vars.name}`,
|
|
1956
|
+
baseSha: "",
|
|
1957
|
+
baseRef: vars.base,
|
|
1958
|
+
status: "starting",
|
|
1959
|
+
summary: null,
|
|
1960
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1961
|
+
})
|
|
1962
|
+
);
|
|
1963
|
+
selectSession(vars.pendingKey);
|
|
1964
|
+
setBusy(`provisioning ${vars.harness} workspace \u2014 a first launch builds the harness image \xB7`);
|
|
1965
|
+
setBusyStarted(Date.now());
|
|
1966
|
+
},
|
|
1967
|
+
onError: (error, vars) => {
|
|
1968
|
+
patchWorkbench((current) => removeSession(current, vars.projectId, vars.pendingKey));
|
|
1969
|
+
setBusy(null);
|
|
1970
|
+
say(errorText(error));
|
|
1971
|
+
},
|
|
1972
|
+
onSuccess: async (session, vars) => {
|
|
1973
|
+
patchWorkbench(
|
|
1974
|
+
(current) => replaceSession(current, vars.projectId, vars.pendingKey, session)
|
|
1975
|
+
);
|
|
1976
|
+
selectSession(session.id);
|
|
1977
|
+
setBusy(null);
|
|
1978
|
+
await attachIfIdle(session);
|
|
1979
|
+
},
|
|
1980
|
+
onSettled: settleRefetch
|
|
1981
|
+
});
|
|
1982
|
+
const resumeMutation = useMutation2({
|
|
1983
|
+
mutationFn: (vars) => ctx.api("POST", `/sessions/${vars.session.id}/resume`, {
|
|
1984
|
+
harness: vars.harness
|
|
1985
|
+
}),
|
|
1986
|
+
onMutate: async (vars) => {
|
|
1987
|
+
await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
|
|
1988
|
+
patchWorkbench(
|
|
1989
|
+
(current) => mapWorkbenchSessions(
|
|
1990
|
+
current,
|
|
1991
|
+
(session) => session.id === vars.session.id ? { ...session, status: "starting" } : session
|
|
1992
|
+
)
|
|
1993
|
+
);
|
|
1994
|
+
selectSession(vars.session.id);
|
|
1995
|
+
setBusy(
|
|
1996
|
+
`resuming ${vars.session.id.slice(0, 8)} \u2014 a fresh workspace restores the saved state \xB7`
|
|
1997
|
+
);
|
|
1998
|
+
setBusyStarted(Date.now());
|
|
1999
|
+
},
|
|
2000
|
+
onError: (error) => {
|
|
2001
|
+
setBusy(null);
|
|
2002
|
+
say(errorText(error));
|
|
2003
|
+
refetch();
|
|
2004
|
+
},
|
|
2005
|
+
onSuccess: async (resumed, vars) => {
|
|
2006
|
+
patchWorkbench(
|
|
2007
|
+
(current) => replaceSession(current, vars.projectId, vars.session.id, resumed)
|
|
2008
|
+
);
|
|
2009
|
+
selectSession(resumed.id);
|
|
2010
|
+
setBusy(null);
|
|
2011
|
+
await attachIfIdle(resumed);
|
|
2012
|
+
},
|
|
2013
|
+
onSettled: settleRefetch
|
|
2014
|
+
});
|
|
2015
|
+
const ADOPT_AUTH_MODES = [
|
|
2016
|
+
{
|
|
2017
|
+
mode: "ambient",
|
|
2018
|
+
label: "ambient",
|
|
2019
|
+
hint: "the server machine's own git/ssh setup"
|
|
2020
|
+
},
|
|
2021
|
+
{
|
|
2022
|
+
mode: "mend-key",
|
|
2023
|
+
label: "mend-key",
|
|
2024
|
+
hint: "the machine's Mend deploy key \u2014 add its public half on the git host"
|
|
2025
|
+
},
|
|
2026
|
+
{
|
|
2027
|
+
mode: "bridge",
|
|
2028
|
+
label: "bridge",
|
|
2029
|
+
hint: "your OWN ssh-agent, relayed while `mend keys share` runs here"
|
|
2030
|
+
}
|
|
2031
|
+
];
|
|
2032
|
+
const adoptMutation = useMutation2({
|
|
2033
|
+
mutationFn: (offer) => submitDashboardAdoption(ctx.api, {
|
|
2034
|
+
name: offer.name,
|
|
2035
|
+
source: offer.source,
|
|
2036
|
+
gitAuthMode: ADOPT_AUTH_MODES[offer.modeIndex]?.mode ?? "ambient"
|
|
2037
|
+
}),
|
|
2038
|
+
onMutate: (offer) => {
|
|
2039
|
+
setAdoptOffer(null);
|
|
2040
|
+
setBusy(`adopting ${offer.name} \u2014 cloning into the store \xB7`);
|
|
2041
|
+
setBusyStarted(Date.now());
|
|
2042
|
+
},
|
|
2043
|
+
onError: (error) => {
|
|
2044
|
+
setBusy(null);
|
|
2045
|
+
say(errorText(error));
|
|
2046
|
+
},
|
|
2047
|
+
onSuccess: (result) => {
|
|
2048
|
+
setBusy(null);
|
|
2049
|
+
if (result.kind === "invalid-source") {
|
|
2050
|
+
say(result.message);
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
const project = result.project;
|
|
2054
|
+
say(`adopted \xB7 ${project.name} \u2014 n starts a worktree`);
|
|
2055
|
+
setProjectKey(project.id);
|
|
2056
|
+
setSessionKey(null);
|
|
2057
|
+
},
|
|
2058
|
+
onSettled: settleRefetch
|
|
2059
|
+
});
|
|
2060
|
+
const renameMutation = useMutation2({
|
|
2061
|
+
mutationFn: (vars) => ctx.api("POST", `/sessions/${vars.session.id}/label`, { label: vars.label }),
|
|
2062
|
+
onMutate: async (vars) => {
|
|
2063
|
+
await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
|
|
2064
|
+
patchWorkbench(
|
|
2065
|
+
(current) => mapWorkbenchSessions(
|
|
2066
|
+
current,
|
|
2067
|
+
(session) => session.id === vars.session.id ? { ...session, label: vars.label } : session
|
|
2068
|
+
)
|
|
2069
|
+
);
|
|
2070
|
+
say(
|
|
2071
|
+
vars.label === null ? `label cleared \xB7 ${vars.session.id.slice(0, 8)}` : `labeled \xB7 ${vars.label}`
|
|
2072
|
+
);
|
|
2073
|
+
},
|
|
2074
|
+
onError: (error) => {
|
|
2075
|
+
say(errorText(error));
|
|
2076
|
+
refetch();
|
|
2077
|
+
},
|
|
2078
|
+
onSettled: settleRefetch
|
|
2079
|
+
});
|
|
2080
|
+
const stopMutation = useMutation2({
|
|
2081
|
+
mutationFn: (session) => ctx.api("POST", `/sessions/${session.id}/stop`),
|
|
2082
|
+
onMutate: async (session) => {
|
|
2083
|
+
await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
|
|
2084
|
+
patchWorkbench((current) => markSessionStopped(current, session.id));
|
|
2085
|
+
say(`stopped \xB7 ${worktreeDisplayName(session)} \u2014 the record and review remain`);
|
|
2086
|
+
},
|
|
2087
|
+
onError: (error) => {
|
|
2088
|
+
say(errorText(error));
|
|
2089
|
+
refetch();
|
|
2090
|
+
},
|
|
2091
|
+
onSettled: settleRefetch
|
|
2092
|
+
});
|
|
2093
|
+
const launchInWorktreeMutation = useMutation2({
|
|
2094
|
+
mutationFn: async (vars) => {
|
|
2095
|
+
const argv = HARNESS_COMMANDS[vars.harness];
|
|
2096
|
+
if (argv === void 0) throw new Error(`unknown harness "${vars.harness}"`);
|
|
2097
|
+
const session = await ctx.api("POST", `/worktrees/${vars.worktreeId}/sessions`, {
|
|
2098
|
+
harness: vars.harness,
|
|
2099
|
+
label: null
|
|
2100
|
+
});
|
|
2101
|
+
await ctx.api("POST", `/sessions/${session.id}/launch`, { argv });
|
|
2102
|
+
return session;
|
|
2103
|
+
},
|
|
2104
|
+
onMutate: async (vars) => {
|
|
2105
|
+
await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
|
|
2106
|
+
patchWorkbench(
|
|
2107
|
+
(current) => prependSession(current, vars.projectId, {
|
|
2108
|
+
id: vars.pendingKey,
|
|
2109
|
+
worktreeId: vars.worktreeId,
|
|
2110
|
+
harness: vars.harness,
|
|
2111
|
+
label: null,
|
|
2112
|
+
branch: "joining\u2026",
|
|
2113
|
+
baseSha: "",
|
|
2114
|
+
baseRef: null,
|
|
2115
|
+
status: "starting",
|
|
2116
|
+
summary: null,
|
|
2117
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2118
|
+
})
|
|
2119
|
+
);
|
|
2120
|
+
selectSession(vars.pendingKey);
|
|
2121
|
+
setBusy(`starting ${vars.harness} in the worktree \xB7`);
|
|
2122
|
+
setBusyStarted(Date.now());
|
|
2123
|
+
},
|
|
2124
|
+
onError: (error, vars) => {
|
|
2125
|
+
patchWorkbench((current) => removeSession(current, vars.projectId, vars.pendingKey));
|
|
2126
|
+
setBusy(null);
|
|
2127
|
+
say(errorText(error));
|
|
2128
|
+
},
|
|
2129
|
+
onSuccess: async (session, vars) => {
|
|
2130
|
+
patchWorkbench(
|
|
2131
|
+
(current) => replaceSession(current, vars.projectId, vars.pendingKey, session)
|
|
2132
|
+
);
|
|
2133
|
+
selectSession(session.id);
|
|
2134
|
+
setBusy(null);
|
|
2135
|
+
await attachIfIdle(session);
|
|
2136
|
+
},
|
|
2137
|
+
onSettled: settleRefetch
|
|
2138
|
+
});
|
|
2139
|
+
const removeWorktreeMutation = useMutation2({
|
|
2140
|
+
mutationFn: (group) => group.id !== null ? ctx.api("DELETE", `/worktrees/${group.id}`) : ctx.api("DELETE", `/sessions/${group.sessions[0]?.session.id ?? ""}`),
|
|
2141
|
+
onMutate: async (group) => {
|
|
2142
|
+
await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
|
|
2143
|
+
const projectId = selectedProject?.project.id;
|
|
2144
|
+
if (projectId !== void 0) {
|
|
2145
|
+
patchWorkbench((current) => removeWorktreeGroup(current, projectId, group));
|
|
2146
|
+
}
|
|
2147
|
+
say(`removing worktree \xB7 ${group.name}`);
|
|
2148
|
+
},
|
|
2149
|
+
onError: (error) => {
|
|
2150
|
+
say(errorText(error));
|
|
2151
|
+
refetch();
|
|
2152
|
+
},
|
|
2153
|
+
onSuccess: (_result, group) => {
|
|
2154
|
+
say(`removed \xB7 ${group.name}`);
|
|
2155
|
+
refetch();
|
|
2156
|
+
},
|
|
2157
|
+
onSettled: settleRefetch
|
|
2158
|
+
});
|
|
2159
|
+
const removeSessionMutation = useMutation2({
|
|
2160
|
+
mutationFn: (session) => ctx.api("DELETE", `/sessions/${session.id}`),
|
|
2161
|
+
onMutate: async (session) => {
|
|
2162
|
+
await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
|
|
2163
|
+
const projectId = selectedProject?.project.id;
|
|
2164
|
+
if (projectId !== void 0) {
|
|
2165
|
+
patchWorkbench((current) => removeSession(current, projectId, session.id));
|
|
2166
|
+
}
|
|
2167
|
+
say(`removing session \xB7 ${worktreeDisplayName(session)}`);
|
|
2168
|
+
},
|
|
2169
|
+
onError: (error) => {
|
|
2170
|
+
say(errorText(error));
|
|
2171
|
+
refetch();
|
|
2172
|
+
},
|
|
2173
|
+
onSuccess: (_result, session) => {
|
|
2174
|
+
say(`removed \xB7 ${worktreeDisplayName(session)} \u2014 the worktree remains`);
|
|
2175
|
+
refetch();
|
|
2176
|
+
},
|
|
2177
|
+
onSettled: settleRefetch
|
|
2178
|
+
});
|
|
2179
|
+
const [removeArmed, setRemoveArmed] = useState2(null);
|
|
2180
|
+
const armRemove = () => {
|
|
2181
|
+
if (selectedRow?.kind === "session") {
|
|
2182
|
+
const session = selectedRow.item.session;
|
|
2183
|
+
if (isPendingId(session.id)) return;
|
|
2184
|
+
if (AGENT_LIVE_STATUSES.has(session.status)) {
|
|
2185
|
+
say(`the agent is still working \u2014 kill it first (\u21E7K) \xB7 ${worktreeDisplayName(session)}`);
|
|
2186
|
+
return;
|
|
2187
|
+
}
|
|
2188
|
+
if (removeArmed === session.id) {
|
|
2189
|
+
setRemoveArmed(null);
|
|
2190
|
+
removeSessionMutation.mutate(session);
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
setRemoveArmed(session.id);
|
|
2194
|
+
say(
|
|
2195
|
+
`press again to remove session \xB7 ${worktreeDisplayName(session)} \u2014 its record goes, the worktree stays`
|
|
2196
|
+
);
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
const group = selectedGroup;
|
|
2200
|
+
if (group === null || group.sessions.some((item) => isPendingId(item.session.id))) return;
|
|
2201
|
+
if (group.live > 0) {
|
|
2202
|
+
say(
|
|
2203
|
+
`${group.live} session${group.live === 1 ? "" : "s"} live \u2014 stop them first \xB7 ${group.name}`
|
|
2204
|
+
);
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
if (removeArmed === group.key) {
|
|
2208
|
+
setRemoveArmed(null);
|
|
2209
|
+
removeWorktreeMutation.mutate(group);
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
setRemoveArmed(group.key);
|
|
2213
|
+
const facts = group.sessions.length === 1 ? "its session and change go with it" : `${group.sessions.length} sessions and the change go with it`;
|
|
2214
|
+
say(`press again to remove worktree \xB7 ${group.name} \u2014 ${facts}`);
|
|
2215
|
+
};
|
|
2216
|
+
const armStop = () => {
|
|
2217
|
+
if (selectedRow?.kind === "worktree") {
|
|
2218
|
+
const group = selectedRow.group;
|
|
2219
|
+
const live = group.sessions.filter((item2) => LIVE_STATUSES.has(item2.session.status));
|
|
2220
|
+
if (live.length === 0) {
|
|
2221
|
+
say("nothing to stop \u2014 the worktree is settled");
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
if (stopArmed === `wt:${group.key}`) {
|
|
2225
|
+
setStopArmed(null);
|
|
2226
|
+
for (const item2 of live) stopMutation.mutate(item2.session);
|
|
2227
|
+
return;
|
|
2228
|
+
}
|
|
2229
|
+
setStopArmed(`wt:${group.key}`);
|
|
2230
|
+
say(
|
|
2231
|
+
`press again to stop ${live.length} live session${live.length === 1 ? "" : "s"} \xB7 ${group.name}`
|
|
2232
|
+
);
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
2235
|
+
const item = selectedSession;
|
|
2236
|
+
if (item === null || isPendingId(item.session.id)) return;
|
|
2237
|
+
if (!LIVE_STATUSES.has(item.session.status)) {
|
|
2238
|
+
say("nothing to stop \u2014 the session is settled");
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
2241
|
+
if (stopArmed === item.session.id) {
|
|
2242
|
+
setStopArmed(null);
|
|
2243
|
+
stopMutation.mutate(item.session);
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
2246
|
+
setStopArmed(item.session.id);
|
|
2247
|
+
say(`press again to stop \xB7 ${worktreeDisplayName(item.session)}`);
|
|
2248
|
+
};
|
|
2249
|
+
const openCreateModal = (projectId) => {
|
|
2250
|
+
setCreating({
|
|
2251
|
+
projectId,
|
|
2252
|
+
step: "name",
|
|
2253
|
+
name: "",
|
|
2254
|
+
branches: null,
|
|
2255
|
+
query: "",
|
|
2256
|
+
baseIndex: 0,
|
|
2257
|
+
base: null,
|
|
2258
|
+
joins: false,
|
|
2259
|
+
harnessIndex: 0
|
|
2260
|
+
});
|
|
2261
|
+
const prefill = projectId === homeProject?.id ? ctx.cwdBranch : null;
|
|
2262
|
+
void ctx.api("GET", `/projects/${projectId}/branches`).then(
|
|
2263
|
+
(branches) => setCreating((current) => {
|
|
2264
|
+
if (current === null) return current;
|
|
2265
|
+
const match = prefill === null ? void 0 : branches.find((candidate) => candidate.name === prefill);
|
|
2266
|
+
const ordered = filterBranches(branches, "");
|
|
2267
|
+
return {
|
|
2268
|
+
...current,
|
|
2269
|
+
branches,
|
|
2270
|
+
base: match === void 0 || match.isDefault ? null : match.name,
|
|
2271
|
+
baseIndex: match === void 0 ? 0 : Math.max(
|
|
2272
|
+
0,
|
|
2273
|
+
ordered.findIndex((candidate) => candidate.name === match.name)
|
|
2274
|
+
)
|
|
2275
|
+
};
|
|
2276
|
+
}),
|
|
2277
|
+
() => (
|
|
2278
|
+
// An unreadable list falls back to the default base; the base step
|
|
2279
|
+
// then shows only the default and enter moves on.
|
|
2280
|
+
setCreating((current) => current === null ? current : { ...current, branches: [] })
|
|
2281
|
+
)
|
|
2282
|
+
);
|
|
2283
|
+
};
|
|
2284
|
+
const advanceFromName = (current, raw) => {
|
|
2285
|
+
const slug = normalizeProjectName(raw.trim());
|
|
2286
|
+
const name = raw.trim() === "" ? "" : slug;
|
|
2287
|
+
const joins = name !== "" && (workbench()?.details.get(current.projectId)?.worktrees ?? []).some(
|
|
2288
|
+
(candidate) => candidate.name === name
|
|
2289
|
+
);
|
|
2290
|
+
return {
|
|
2291
|
+
...current,
|
|
2292
|
+
name,
|
|
2293
|
+
joins,
|
|
2294
|
+
...joins ? { base: null } : {},
|
|
2295
|
+
step: joins ? "harness" : "base"
|
|
2296
|
+
};
|
|
2297
|
+
};
|
|
2298
|
+
const submitCreateName = (value) => {
|
|
2299
|
+
setCreating((current) => current === null ? current : advanceFromName(current, value));
|
|
2300
|
+
};
|
|
2301
|
+
const submitCreateBase = () => {
|
|
2302
|
+
setCreating((current) => current === null ? current : advanceFromBase(current));
|
|
2303
|
+
};
|
|
2304
|
+
const submitCreateHarness = () => {
|
|
2305
|
+
const current = creating;
|
|
2306
|
+
if (current === null) return;
|
|
2307
|
+
const choice = deriveHarnesses(null)[current.harnessIndex];
|
|
2308
|
+
if (choice?.harness == null) return;
|
|
2309
|
+
setCreating(null);
|
|
2310
|
+
setFocus("sessions");
|
|
2311
|
+
launchMutation.mutate({
|
|
2312
|
+
projectId: current.projectId,
|
|
2313
|
+
harness: choice.harness,
|
|
2314
|
+
name: current.name === "" ? null : current.name,
|
|
2315
|
+
base: current.base,
|
|
2316
|
+
pendingKey: pendingId()
|
|
2317
|
+
});
|
|
2318
|
+
};
|
|
2319
|
+
const resumeSession = (projectId, session, harness) => {
|
|
2320
|
+
setFocus("sessions");
|
|
2321
|
+
resumeMutation.mutate({ projectId, session, harness });
|
|
2322
|
+
};
|
|
2323
|
+
const submitRename = (session, value) => {
|
|
2324
|
+
setEditing(null);
|
|
2325
|
+
const label = value.trim() === "" ? null : value.trim();
|
|
2326
|
+
renameMutation.mutate({ session, label });
|
|
2327
|
+
};
|
|
2328
|
+
const openPicker = (session, worktree) => {
|
|
2329
|
+
setPicker(worktree === void 0 ? { session } : { session, worktree });
|
|
2330
|
+
setPickerIndex(0);
|
|
2331
|
+
};
|
|
2332
|
+
const moveSelection = (delta) => {
|
|
2333
|
+
if (picker !== null) {
|
|
2334
|
+
setPickerIndex((current) => Math.max(0, Math.min(pickerItems.length - 1, current + delta)));
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
if (focus === "projects") {
|
|
2338
|
+
if (projectItems.length === 0) return;
|
|
2339
|
+
const next2 = Math.max(0, Math.min(projectItems.length - 1, projectIndex + delta));
|
|
2340
|
+
const item = projectItems[next2];
|
|
2341
|
+
if (item !== void 0) {
|
|
2342
|
+
setProjectKey(item.project.id);
|
|
2343
|
+
setSessionKey(null);
|
|
2344
|
+
}
|
|
2345
|
+
return;
|
|
2346
|
+
}
|
|
2347
|
+
if (rows.length === 0) return;
|
|
2348
|
+
const next = Math.max(0, Math.min(rows.length - 1, rowIndex + delta));
|
|
2349
|
+
const row = rows[next];
|
|
2350
|
+
if (row !== void 0) setSessionKey(rowKeyOf(row));
|
|
2351
|
+
};
|
|
2352
|
+
const activate = () => {
|
|
2353
|
+
if (picker !== null) {
|
|
2354
|
+
const choice = pickerItems[pickerIndex];
|
|
2355
|
+
const projectId = selectedProject?.project.id;
|
|
2356
|
+
if (choice === void 0 || projectId === void 0) return;
|
|
2357
|
+
setPicker(null);
|
|
2358
|
+
if (picker.worktree !== void 0 && picker.worktree.id !== null) {
|
|
2359
|
+
if (choice.harness !== null) {
|
|
2360
|
+
setFocus("sessions");
|
|
2361
|
+
launchInWorktreeMutation.mutate({
|
|
2362
|
+
projectId,
|
|
2363
|
+
worktreeId: picker.worktree.id,
|
|
2364
|
+
harness: choice.harness,
|
|
2365
|
+
pendingKey: pendingId()
|
|
2366
|
+
});
|
|
2367
|
+
}
|
|
2368
|
+
} else if (picker.session !== null) {
|
|
2369
|
+
resumeSession(projectId, picker.session, choice.harness);
|
|
2370
|
+
}
|
|
2371
|
+
return;
|
|
2372
|
+
}
|
|
2373
|
+
if (focus === "projects") {
|
|
2374
|
+
setFocus("sessions");
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
if (selectedRow?.kind === "worktree") {
|
|
2378
|
+
const live = selectedRow.group.sessions.find(
|
|
2379
|
+
(item2) => LIVE_STATUSES.has(item2.session.status)
|
|
2380
|
+
);
|
|
2381
|
+
if (live !== void 0) {
|
|
2382
|
+
void attachFlow(live.session);
|
|
2383
|
+
} else if (selectedRow.group.id !== null) {
|
|
2384
|
+
openPicker(null, selectedRow.group);
|
|
2385
|
+
}
|
|
2386
|
+
return;
|
|
2387
|
+
}
|
|
2388
|
+
const item = selectedSession;
|
|
2389
|
+
if (item === null) return;
|
|
2390
|
+
if (isPendingId(item.session.id)) {
|
|
2391
|
+
say("still provisioning \u2014 the row fills in when the workspace answers");
|
|
2392
|
+
} else if (LIVE_STATUSES.has(item.session.status)) {
|
|
2393
|
+
void attachFlow(item.session);
|
|
2394
|
+
} else {
|
|
2395
|
+
openPicker(item.session);
|
|
2396
|
+
}
|
|
2397
|
+
};
|
|
2398
|
+
useKeyboard2((key) => {
|
|
2399
|
+
if (reviewing !== null) return;
|
|
2400
|
+
if (lockRef.current) return;
|
|
2401
|
+
if (key.ctrl && key.name === "c") return onQuit();
|
|
2402
|
+
if (adoptOffer !== null) {
|
|
2403
|
+
if (key.name === "return" || key.name === "linefeed" || key.name === "y") {
|
|
2404
|
+
adoptMutation.mutate(adoptOffer);
|
|
2405
|
+
} else if (key.name === "escape" || key.name === "q") {
|
|
2406
|
+
setAdoptOffer(null);
|
|
2407
|
+
say("not adopted \xB7 use mend adopt <url> any time");
|
|
2408
|
+
} else if (key.name === "right" || key.name === "l" || key.name === "tab" || key.name === "left" || key.name === "h") {
|
|
2409
|
+
const delta = key.name === "left" || key.name === "h" ? -1 : 1;
|
|
2410
|
+
setAdoptOffer(
|
|
2411
|
+
(current) => current === null ? current : {
|
|
2412
|
+
...current,
|
|
2413
|
+
modeIndex: (current.modeIndex + delta + ADOPT_AUTH_MODES.length) % ADOPT_AUTH_MODES.length
|
|
2414
|
+
}
|
|
2415
|
+
);
|
|
2416
|
+
}
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
if (editing !== null) {
|
|
2420
|
+
if (key.name === "escape") setEditing(null);
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
if (creating !== null) {
|
|
2424
|
+
if (key.name === "escape") {
|
|
2425
|
+
setCreating((current) => {
|
|
2426
|
+
if (current === null) return current;
|
|
2427
|
+
if (current.step === "harness") {
|
|
2428
|
+
return { ...current, step: current.joins ? "name" : "base" };
|
|
2429
|
+
}
|
|
2430
|
+
if (current.step === "base") return { ...current, step: "name" };
|
|
2431
|
+
say("new worktree cancelled");
|
|
2432
|
+
return null;
|
|
2433
|
+
});
|
|
2434
|
+
return;
|
|
2435
|
+
}
|
|
2436
|
+
if (key.name === "tab" || key.name === "backtab") {
|
|
2437
|
+
const backward = key.name === "backtab" || key.shift === true;
|
|
2438
|
+
setCreating((current) => {
|
|
2439
|
+
if (current === null) return current;
|
|
2440
|
+
if (backward) {
|
|
2441
|
+
if (current.step === "harness") {
|
|
2442
|
+
return { ...current, step: current.joins ? "name" : "base" };
|
|
2443
|
+
}
|
|
2444
|
+
if (current.step === "base") return { ...current, step: "name" };
|
|
2445
|
+
return current;
|
|
2446
|
+
}
|
|
2447
|
+
if (current.step === "name") return advanceFromName(current, current.name);
|
|
2448
|
+
if (current.step === "base") return advanceFromBase(current);
|
|
2449
|
+
return { ...current, step: "name" };
|
|
2450
|
+
});
|
|
2451
|
+
return;
|
|
2452
|
+
}
|
|
2453
|
+
const delta = key.name === "down" || key.ctrl === true && key.name === "n" ? 1 : key.name === "up" || key.ctrl === true && key.name === "p" ? -1 : 0;
|
|
2454
|
+
if (delta !== 0) {
|
|
2455
|
+
setCreating((current) => {
|
|
2456
|
+
if (current === null) return current;
|
|
2457
|
+
if (current.step === "base") {
|
|
2458
|
+
const matches = filterBranches(current.branches ?? [], current.query).length;
|
|
2459
|
+
return {
|
|
2460
|
+
...current,
|
|
2461
|
+
baseIndex: Math.max(0, Math.min(matches - 1, current.baseIndex + delta))
|
|
2462
|
+
};
|
|
2463
|
+
}
|
|
2464
|
+
if (current.step === "harness") {
|
|
2465
|
+
const count = deriveHarnesses(null).length;
|
|
2466
|
+
return {
|
|
2467
|
+
...current,
|
|
2468
|
+
harnessIndex: Math.max(0, Math.min(count - 1, current.harnessIndex + delta))
|
|
2469
|
+
};
|
|
2470
|
+
}
|
|
2471
|
+
return current;
|
|
2472
|
+
});
|
|
2473
|
+
return;
|
|
2474
|
+
}
|
|
2475
|
+
if (creating.step === "harness" && (key.name === "return" || key.name === "linefeed" || key.name === "l")) {
|
|
2476
|
+
submitCreateHarness();
|
|
2477
|
+
}
|
|
2478
|
+
return;
|
|
2479
|
+
}
|
|
2480
|
+
if (picker !== null) {
|
|
2481
|
+
switch (key.name) {
|
|
2482
|
+
case "down":
|
|
2483
|
+
case "j":
|
|
2484
|
+
return moveSelection(1);
|
|
2485
|
+
case "up":
|
|
2486
|
+
case "k":
|
|
2487
|
+
return moveSelection(-1);
|
|
2488
|
+
case "return":
|
|
2489
|
+
case "linefeed":
|
|
2490
|
+
case "l":
|
|
2491
|
+
return activate();
|
|
2492
|
+
case "escape":
|
|
2493
|
+
case "q":
|
|
2494
|
+
case "h":
|
|
2495
|
+
return setPicker(null);
|
|
2496
|
+
default:
|
|
2497
|
+
return;
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
if (key.shift === true && key.name === "k") return armStop();
|
|
2501
|
+
if (key.shift === true && key.name === "d") return armRemove();
|
|
2502
|
+
switch (key.name) {
|
|
2503
|
+
case "q":
|
|
2504
|
+
return onQuit();
|
|
2505
|
+
case "down":
|
|
2506
|
+
case "j":
|
|
2507
|
+
return moveSelection(1);
|
|
2508
|
+
case "up":
|
|
2509
|
+
case "k":
|
|
2510
|
+
return moveSelection(-1);
|
|
2511
|
+
case "x":
|
|
2512
|
+
return armStop();
|
|
2513
|
+
case "return":
|
|
2514
|
+
case "linefeed":
|
|
2515
|
+
return activate();
|
|
2516
|
+
case "l":
|
|
2517
|
+
case "right":
|
|
2518
|
+
if (focus === "projects") setFocus("sessions");
|
|
2519
|
+
return;
|
|
2520
|
+
case "h":
|
|
2521
|
+
case "left":
|
|
2522
|
+
case "-":
|
|
2523
|
+
case "backspace":
|
|
2524
|
+
if (showProjectsPane) setFocus("projects");
|
|
2525
|
+
return;
|
|
2526
|
+
case "tab":
|
|
2527
|
+
if (showProjectsPane) setFocus(focus === "projects" ? "sessions" : "projects");
|
|
2528
|
+
return;
|
|
2529
|
+
case "n":
|
|
2530
|
+
if (selectedProject !== null) openCreateModal(selectedProject.project.id);
|
|
2531
|
+
return;
|
|
2532
|
+
case "s": {
|
|
2533
|
+
const group = selectedGroup;
|
|
2534
|
+
if (group === null) return;
|
|
2535
|
+
if (group.id === null) {
|
|
2536
|
+
say("this server predates shared worktrees \u2014 n starts a new one");
|
|
2537
|
+
return;
|
|
2538
|
+
}
|
|
2539
|
+
openPicker(null, group);
|
|
2540
|
+
return;
|
|
2541
|
+
}
|
|
2542
|
+
case "e": {
|
|
2543
|
+
const item = selectedSession;
|
|
2544
|
+
if (item !== null && !isPendingId(item.session.id)) setEditing(item.session);
|
|
2545
|
+
return;
|
|
2546
|
+
}
|
|
2547
|
+
case "o": {
|
|
2548
|
+
const item = selectedSession;
|
|
2549
|
+
if (item !== null && !isPendingId(item.session.id)) {
|
|
2550
|
+
openUrl(`${ctx.config.url}/sessions/${item.session.id}`);
|
|
2551
|
+
say(`opened \xB7 ${ctx.config.url}/sessions/${item.session.id.slice(0, 8)}\u2026`);
|
|
2552
|
+
}
|
|
2553
|
+
return;
|
|
2554
|
+
}
|
|
2555
|
+
case "v": {
|
|
2556
|
+
const item = selectedSession;
|
|
2557
|
+
if (item === null) return;
|
|
2558
|
+
if (isPendingId(item.session.id)) {
|
|
2559
|
+
say("still provisioning \u2014 nothing to review yet");
|
|
2560
|
+
return;
|
|
2561
|
+
}
|
|
2562
|
+
const target = reviewTargetForSession(
|
|
2563
|
+
item.session,
|
|
2564
|
+
selectedGroup?.annotation ?? item.annotation,
|
|
2565
|
+
selectedProject?.project.name ?? "project"
|
|
2566
|
+
);
|
|
2567
|
+
if (target === null) {
|
|
2568
|
+
say("this session has no reviewable change yet");
|
|
2569
|
+
return;
|
|
2570
|
+
}
|
|
2571
|
+
setReviewing(target);
|
|
2572
|
+
return;
|
|
2573
|
+
}
|
|
2574
|
+
case "r":
|
|
2575
|
+
say("refreshing\u2026");
|
|
2576
|
+
refetch();
|
|
2577
|
+
return;
|
|
2578
|
+
default:
|
|
2579
|
+
return;
|
|
2580
|
+
}
|
|
2581
|
+
});
|
|
2582
|
+
const liveTotal = projectItems.reduce((sum, item) => sum + item.live, 0);
|
|
2583
|
+
const nameWidth = Math.min(16, Math.max(...projectItems.map((p) => p.project.name.length), 4));
|
|
2584
|
+
const sessionsTitle = selectedProject === null ? "sessions" : `sessions \u2014 ${selectedProject.project.name}${selectedProject.live > 0 ? ` \xB7 ${selectedProject.live} live` : ""}`;
|
|
2585
|
+
const detailTitle = selectedSession === null || selectedGroup === null ? "session" : `worktree \u2014 ${selectedGroup.name} \xB7 ${selectedSession.session.harness} ${selectedSession.session.id.slice(0, 8)}`;
|
|
2586
|
+
const pickerTitle = picker === null ? "" : picker.worktree !== void 0 ? `new session in ${picker.worktree.name} \u2014 pick a harness` : picker.session === null ? "new session \u2014 pick a harness" : `resume ${picker.session.id.slice(0, 8)} \u2014 pick a harness`;
|
|
2587
|
+
const creatingHeight = 2 + 1 + 1 + 6 + 1 + deriveHarnesses(null).length;
|
|
2588
|
+
const footerText = adoptOffer !== null ? " enter adopt \xB7 \u2190\u2192 auth mode \xB7 esc not now" : editing !== null ? " enter save \xB7 esc cancel" : creating !== null ? creating.step === "name" ? " enter continue \xB7 esc cancel" : creating.step === "base" ? " type to filter \xB7 \u2191\u2193 move \xB7 enter choose base \xB7 esc back" : " \u2191\u2193 move \xB7 enter launch \xB7 esc back" : picker !== null ? " \u2191\u2193 move \xB7 enter start \xB7 esc cancel" : focus === "projects" ? " \u2191\u2193 move \xB7 enter/l sessions \xB7 \u21E5 panes \xB7 r refresh \xB7 q quit" : " \u2191\u2193 move \xB7 enter attach/resume \xB7 n new worktree \xB7 s session here \xB7 \u21E7K kill \xB7 \u21E7D remove \xB7 v review \xB7 e rename \xB7 o web \xB7 q quit";
|
|
2589
|
+
const loadFailure = data === void 0 && failureReason !== null ? failureReason instanceof Error ? failureReason.message : String(failureReason) : null;
|
|
2590
|
+
if (reviewing !== null) {
|
|
2591
|
+
return /* @__PURE__ */ jsx2(
|
|
2592
|
+
ReviewScreen,
|
|
2593
|
+
{
|
|
2594
|
+
ctx,
|
|
2595
|
+
projectName: reviewing.projectName,
|
|
2596
|
+
session: reviewing.session,
|
|
2597
|
+
changeId: reviewing.changeId,
|
|
2598
|
+
onBack: () => {
|
|
2599
|
+
setReviewing(null);
|
|
2600
|
+
refetch();
|
|
2601
|
+
},
|
|
2602
|
+
onQuit
|
|
2603
|
+
}
|
|
2604
|
+
);
|
|
2605
|
+
}
|
|
2606
|
+
return /* @__PURE__ */ jsxs2("box", { flexGrow: 1, flexDirection: "column", backgroundColor: "transparent", children: [
|
|
2607
|
+
/* @__PURE__ */ jsxs2("box", { height: 1, flexDirection: "row", justifyContent: "space-between", children: [
|
|
2608
|
+
/* @__PURE__ */ jsxs2("text", { height: 1, bg: "transparent", children: [
|
|
2609
|
+
/* @__PURE__ */ jsx2("span", { fg: INK, children: " mend" }),
|
|
2610
|
+
/* @__PURE__ */ jsxs2("span", { fg: MUTED, children: [
|
|
2611
|
+
" ",
|
|
2612
|
+
projectItems.length,
|
|
2613
|
+
" project",
|
|
2614
|
+
projectItems.length === 1 ? "" : "s"
|
|
2615
|
+
] }),
|
|
2616
|
+
/* @__PURE__ */ jsx2("span", { fg: FAINT, children: " \xB7 " }),
|
|
2617
|
+
/* @__PURE__ */ jsxs2("span", { fg: liveTotal > 0 ? MUTED : FAINT, children: [
|
|
2618
|
+
liveTotal,
|
|
2619
|
+
" live"
|
|
2620
|
+
] }),
|
|
2621
|
+
shareState === "off" ? null : /* @__PURE__ */ jsxs2(Fragment2, { children: [
|
|
2622
|
+
/* @__PURE__ */ jsx2("span", { fg: FAINT, children: " \xB7 " }),
|
|
2623
|
+
/* @__PURE__ */ jsx2("span", { fg: shareState === "connected" ? MUTED : FAINT, children: shareState === "connected" ? "agent shared" : "agent share reconnecting" })
|
|
2624
|
+
] })
|
|
2625
|
+
] }),
|
|
2626
|
+
/* @__PURE__ */ jsx2("text", { height: 1, fg: FAINT, bg: "transparent", children: `${ctx.config.url} ` })
|
|
2627
|
+
] }),
|
|
2628
|
+
/* @__PURE__ */ jsxs2("box", { flexGrow: 1, flexShrink: 1, minHeight: 0, flexDirection: "row", children: [
|
|
2629
|
+
showProjectsPane ? /* @__PURE__ */ jsx2(Pane, { title: "projects", focused: focus === "projects", width: PROJECTS_PANE_WIDTH, children: /* @__PURE__ */ jsxs2(
|
|
2630
|
+
"scrollbox",
|
|
2631
|
+
{
|
|
2632
|
+
ref: projectScrollRef,
|
|
2633
|
+
flexGrow: 1,
|
|
2634
|
+
flexShrink: 1,
|
|
2635
|
+
minHeight: 0,
|
|
2636
|
+
style: paneScrollStyle,
|
|
2637
|
+
children: [
|
|
2638
|
+
projectItems.map((item, index) => /* @__PURE__ */ jsx2(
|
|
2639
|
+
ProjectRow,
|
|
2640
|
+
{
|
|
2641
|
+
item,
|
|
2642
|
+
selected: index === projectIndex,
|
|
2643
|
+
nameWidth
|
|
2644
|
+
},
|
|
2645
|
+
item.project.id
|
|
2646
|
+
)),
|
|
2647
|
+
data !== void 0 && projectItems.length === 0 ? /* @__PURE__ */ jsx2("text", { height: 1, fg: FAINT, bg: "transparent", children: " none adopted yet" }) : null
|
|
2648
|
+
]
|
|
2649
|
+
}
|
|
2650
|
+
) }) : null,
|
|
2651
|
+
/* @__PURE__ */ jsx2(Pane, { title: sessionsTitle, focused: focus === "sessions", grow: true, children: /* @__PURE__ */ jsxs2(
|
|
2652
|
+
"scrollbox",
|
|
2653
|
+
{
|
|
2654
|
+
ref: sessionScrollRef,
|
|
2655
|
+
flexGrow: 1,
|
|
2656
|
+
flexShrink: 1,
|
|
2657
|
+
minHeight: 0,
|
|
2658
|
+
style: paneScrollStyle,
|
|
2659
|
+
children: [
|
|
2660
|
+
rows.map(
|
|
2661
|
+
(row, index) => row.kind === "worktree" ? /* @__PURE__ */ jsx2(
|
|
2662
|
+
WorktreeHeaderRow,
|
|
2663
|
+
{
|
|
2664
|
+
group: row.group,
|
|
2665
|
+
selected: index === rowIndex
|
|
2666
|
+
},
|
|
2667
|
+
rowKeyOf(row)
|
|
2668
|
+
) : /* @__PURE__ */ jsx2(
|
|
2669
|
+
SessionChildRow,
|
|
2670
|
+
{
|
|
2671
|
+
item: row.item,
|
|
2672
|
+
selected: index === rowIndex
|
|
2673
|
+
},
|
|
2674
|
+
rowKeyOf(row)
|
|
2675
|
+
)
|
|
2676
|
+
),
|
|
2677
|
+
data !== void 0 && rows.length === 0 ? /* @__PURE__ */ jsx2("text", { height: 1, fg: FAINT, bg: "transparent", children: " no sessions yet \u2014 n starts one" }) : null,
|
|
2678
|
+
loadFailure !== null ? /* @__PURE__ */ jsx2("text", { height: 1, fg: INK_2, bg: "transparent", children: ` ${loadFailure} \u2014 retrying` }) : null
|
|
2679
|
+
]
|
|
2680
|
+
}
|
|
2681
|
+
) })
|
|
2682
|
+
] }),
|
|
2683
|
+
picker !== null ? /* @__PURE__ */ jsx2(Pane, { title: pickerTitle, focused: true, height: 2 + pickerItems.length, children: pickerItems.map((item, index) => /* @__PURE__ */ jsx2(HarnessRow, { item, selected: index === pickerIndex }, String(item.harness))) }) : editing !== null ? /* @__PURE__ */ jsx2(
|
|
2684
|
+
"box",
|
|
2685
|
+
{
|
|
2686
|
+
border: true,
|
|
2687
|
+
borderStyle: "rounded",
|
|
2688
|
+
borderColor: COBALT,
|
|
2689
|
+
title: ` label \u2014 ${editing.harness} ${editing.id.slice(0, 8)} `,
|
|
2690
|
+
titleAlignment: "left",
|
|
2691
|
+
backgroundColor: "transparent",
|
|
2692
|
+
height: 3,
|
|
2693
|
+
flexShrink: 0,
|
|
2694
|
+
children: /* @__PURE__ */ jsx2(
|
|
2695
|
+
"input",
|
|
2696
|
+
{
|
|
2697
|
+
focused: true,
|
|
2698
|
+
value: editing.label ?? "",
|
|
2699
|
+
placeholder: "a few words for what this session is doing (empty clears)",
|
|
2700
|
+
backgroundColor: "transparent",
|
|
2701
|
+
focusedBackgroundColor: "transparent",
|
|
2702
|
+
textColor: INK,
|
|
2703
|
+
focusedTextColor: INK,
|
|
2704
|
+
placeholderColor: FAINT,
|
|
2705
|
+
cursorColor: INK,
|
|
2706
|
+
flexGrow: 1,
|
|
2707
|
+
onSubmit: (value) => {
|
|
2708
|
+
if (typeof value === "string") submitRename(editing, value);
|
|
2709
|
+
}
|
|
2710
|
+
}
|
|
2711
|
+
)
|
|
2712
|
+
}
|
|
2713
|
+
) : showDetail ? /* @__PURE__ */ jsx2(Pane, { title: detailTitle, focused: false, height: 6, children: /* @__PURE__ */ jsx2(SessionDetail, { item: selectedSession }) }) : null,
|
|
2714
|
+
adoptOffer !== null ? /* @__PURE__ */ jsxs2(
|
|
2715
|
+
"box",
|
|
2716
|
+
{
|
|
2717
|
+
position: "absolute",
|
|
2718
|
+
zIndex: 11,
|
|
2719
|
+
left: Math.max(1, Math.floor((terminalCols - Math.min(70, terminalCols - 4)) / 2)),
|
|
2720
|
+
top: Math.max(1, Math.floor((terminalRows - 7) / 2)),
|
|
2721
|
+
width: Math.min(70, terminalCols - 4),
|
|
2722
|
+
height: 7,
|
|
2723
|
+
border: true,
|
|
2724
|
+
borderStyle: "rounded",
|
|
2725
|
+
borderColor: COBALT,
|
|
2726
|
+
title: " adopt this repository URL? ",
|
|
2727
|
+
titleAlignment: "left",
|
|
2728
|
+
backgroundColor: SURFACE,
|
|
2729
|
+
flexDirection: "column",
|
|
2730
|
+
children: [
|
|
2731
|
+
/* @__PURE__ */ jsxs2("text", { height: 1, bg: SURFACE, children: [
|
|
2732
|
+
/* @__PURE__ */ jsx2("span", { children: " " }),
|
|
2733
|
+
/* @__PURE__ */ jsx2("span", { fg: INK, children: adoptOffer.name }),
|
|
2734
|
+
/* @__PURE__ */ jsx2("span", { fg: FAINT, children: " \xB7 not in the store yet" })
|
|
2735
|
+
] }),
|
|
2736
|
+
/* @__PURE__ */ jsx2("text", { height: 1, bg: SURFACE, fg: MUTED, children: ` ${adoptOffer.source}` }),
|
|
2737
|
+
/* @__PURE__ */ jsxs2("text", { height: 1, bg: SURFACE, children: [
|
|
2738
|
+
/* @__PURE__ */ jsx2("span", { fg: FAINT, children: " auth " }),
|
|
2739
|
+
ADOPT_AUTH_MODES.map((candidate, index) => /* @__PURE__ */ jsxs2("span", { children: [
|
|
2740
|
+
index > 0 ? /* @__PURE__ */ jsx2("span", { fg: FAINT, children: " \xB7 " }) : null,
|
|
2741
|
+
/* @__PURE__ */ jsx2("span", { fg: index === adoptOffer.modeIndex ? COBALT : FAINT, children: index === adoptOffer.modeIndex ? `\u25B8 ${candidate.label}` : candidate.label })
|
|
2742
|
+
] }, candidate.mode))
|
|
2743
|
+
] }),
|
|
2744
|
+
/* @__PURE__ */ jsx2("text", { height: 1, bg: SURFACE, fg: FAINT, children: ` ${ADOPT_AUTH_MODES[adoptOffer.modeIndex]?.hint ?? ""}` }),
|
|
2745
|
+
/* @__PURE__ */ jsx2("text", { height: 1, bg: SURFACE, fg: FAINT, children: " enter adopt \xB7 \u2190\u2192 auth mode \xB7 esc not now" })
|
|
2746
|
+
]
|
|
2747
|
+
}
|
|
2748
|
+
) : null,
|
|
2749
|
+
creating !== null ? /* @__PURE__ */ jsxs2(
|
|
2750
|
+
"box",
|
|
2751
|
+
{
|
|
2752
|
+
position: "absolute",
|
|
2753
|
+
zIndex: 10,
|
|
2754
|
+
left: Math.max(1, Math.floor((terminalCols - Math.min(74, terminalCols - 4)) / 2)),
|
|
2755
|
+
top: Math.max(1, Math.floor((terminalRows - creatingHeight) / 2)),
|
|
2756
|
+
width: Math.min(74, terminalCols - 4),
|
|
2757
|
+
height: creatingHeight,
|
|
2758
|
+
border: true,
|
|
2759
|
+
borderStyle: "rounded",
|
|
2760
|
+
borderColor: COBALT,
|
|
2761
|
+
title: ` new worktree \u2014 ${selectedProject?.project.name ?? "project"} `,
|
|
2762
|
+
titleAlignment: "left",
|
|
2763
|
+
backgroundColor: SURFACE,
|
|
2764
|
+
flexDirection: "column",
|
|
2765
|
+
children: [
|
|
2766
|
+
/* @__PURE__ */ jsxs2("box", { height: 1, flexShrink: 0, flexDirection: "row", backgroundColor: SURFACE, children: [
|
|
2767
|
+
/* @__PURE__ */ jsxs2("text", { height: 1, bg: SURFACE, children: [
|
|
2768
|
+
/* @__PURE__ */ jsx2(Gutter, { selected: creating.step === "name" }),
|
|
2769
|
+
/* @__PURE__ */ jsx2("span", { fg: FAINT, children: "name " }),
|
|
2770
|
+
creating.step !== "name" ? /* @__PURE__ */ jsx2("span", { fg: INK, children: creating.name === "" ? "auto" : creating.name }) : null,
|
|
2771
|
+
creating.joins ? /* @__PURE__ */ jsx2("span", { fg: FAINT, children: " \xB7 joins the existing worktree" }) : null
|
|
2772
|
+
] }),
|
|
2773
|
+
creating.step === "name" ? /* @__PURE__ */ jsx2(
|
|
2774
|
+
"input",
|
|
2775
|
+
{
|
|
2776
|
+
focused: true,
|
|
2777
|
+
value: creating.name,
|
|
2778
|
+
placeholder: "e.g. fix-auth (empty = auto \xB7 an existing name joins it)",
|
|
2779
|
+
backgroundColor: SURFACE,
|
|
2780
|
+
focusedBackgroundColor: SURFACE,
|
|
2781
|
+
textColor: INK,
|
|
2782
|
+
focusedTextColor: INK,
|
|
2783
|
+
placeholderColor: FAINT,
|
|
2784
|
+
cursorColor: INK,
|
|
2785
|
+
flexGrow: 1,
|
|
2786
|
+
onInput: (value) => {
|
|
2787
|
+
const clean = value.replace(/\t/g, "");
|
|
2788
|
+
setCreating(
|
|
2789
|
+
(current) => current === null ? current : { ...current, name: clean }
|
|
2790
|
+
);
|
|
2791
|
+
},
|
|
2792
|
+
onSubmit: (value) => {
|
|
2793
|
+
if (typeof value === "string") submitCreateName(value);
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
) : null
|
|
2797
|
+
] }),
|
|
2798
|
+
/* @__PURE__ */ jsxs2("box", { height: 1, flexShrink: 0, flexDirection: "row", backgroundColor: SURFACE, children: [
|
|
2799
|
+
/* @__PURE__ */ jsxs2("text", { height: 1, bg: SURFACE, children: [
|
|
2800
|
+
/* @__PURE__ */ jsx2(Gutter, { selected: creating.step === "base" }),
|
|
2801
|
+
/* @__PURE__ */ jsx2("span", { fg: FAINT, children: "base " }),
|
|
2802
|
+
creating.step !== "base" ? /* @__PURE__ */ jsx2("span", { fg: creating.step === "name" && !creating.joins ? FAINT : INK, children: creating.joins ? "fixed by the existing worktree" : creating.base ?? "default branch" }) : null
|
|
2803
|
+
] }),
|
|
2804
|
+
creating.step === "base" ? creating.branches === null ? /* @__PURE__ */ jsx2("text", { height: 1, bg: SURFACE, fg: FAINT, children: "reading branches\u2026" }) : /* @__PURE__ */ jsx2(
|
|
2805
|
+
"input",
|
|
2806
|
+
{
|
|
2807
|
+
focused: true,
|
|
2808
|
+
value: "",
|
|
2809
|
+
placeholder: "type to filter (enter = highlighted; empty = default)",
|
|
2810
|
+
backgroundColor: SURFACE,
|
|
2811
|
+
focusedBackgroundColor: SURFACE,
|
|
2812
|
+
textColor: INK,
|
|
2813
|
+
focusedTextColor: INK,
|
|
2814
|
+
placeholderColor: FAINT,
|
|
2815
|
+
cursorColor: INK,
|
|
2816
|
+
flexGrow: 1,
|
|
2817
|
+
onInput: (value) => {
|
|
2818
|
+
const clean = value.replace(/\t/g, "");
|
|
2819
|
+
setCreating(
|
|
2820
|
+
(current) => current === null ? current : { ...current, query: clean, baseIndex: 0 }
|
|
2821
|
+
);
|
|
2822
|
+
},
|
|
2823
|
+
onSubmit: () => submitCreateBase()
|
|
2824
|
+
}
|
|
2825
|
+
) : null
|
|
2826
|
+
] }),
|
|
2827
|
+
Array.from({ length: 6 }, (_, index) => {
|
|
2828
|
+
const branch = creating.joins || creating.branches === null ? void 0 : filterBranches(creating.branches, creating.query)[index];
|
|
2829
|
+
const active = creating.step === "base";
|
|
2830
|
+
if (branch === void 0) {
|
|
2831
|
+
return /* @__PURE__ */ jsx2("text", { height: 1, bg: SURFACE, fg: FAINT, children: index === 0 && active && creating.branches !== null ? " no branch matches \u2014 enter uses the default" : " " }, `slot-${index}`);
|
|
2832
|
+
}
|
|
2833
|
+
const highlighted = active && index === creating.baseIndex;
|
|
2834
|
+
return /* @__PURE__ */ jsx2(
|
|
2835
|
+
"box",
|
|
2836
|
+
{
|
|
2837
|
+
height: 1,
|
|
2838
|
+
flexShrink: 0,
|
|
2839
|
+
backgroundColor: highlighted ? WASH : SURFACE,
|
|
2840
|
+
children: /* @__PURE__ */ jsxs2("text", { height: 1, bg: highlighted ? WASH : SURFACE, children: [
|
|
2841
|
+
/* @__PURE__ */ jsx2("span", { fg: FAINT, children: " " }),
|
|
2842
|
+
/* @__PURE__ */ jsx2(Gutter, { selected: highlighted }),
|
|
2843
|
+
/* @__PURE__ */ jsx2("span", { fg: active ? INK : FAINT, children: branch.name.slice(0, 40).padEnd(41) }),
|
|
2844
|
+
/* @__PURE__ */ jsx2("span", { fg: FAINT, children: branch.sha.slice(0, 8).padEnd(10) }),
|
|
2845
|
+
/* @__PURE__ */ jsx2("span", { fg: active ? MUTED : FAINT, children: timeAgo(branch.committedAt).replace(" ago", "").padEnd(6) }),
|
|
2846
|
+
branch.isDefault ? /* @__PURE__ */ jsx2("span", { fg: FAINT, children: "default" }) : null
|
|
2847
|
+
] })
|
|
2848
|
+
},
|
|
2849
|
+
branch.name
|
|
2850
|
+
);
|
|
2851
|
+
}),
|
|
2852
|
+
/* @__PURE__ */ jsxs2("text", { height: 1, bg: SURFACE, children: [
|
|
2853
|
+
/* @__PURE__ */ jsx2(Gutter, { selected: creating.step === "harness" }),
|
|
2854
|
+
/* @__PURE__ */ jsx2("span", { fg: FAINT, children: "harness" })
|
|
2855
|
+
] }),
|
|
2856
|
+
deriveHarnesses(null).map((item, index) => {
|
|
2857
|
+
const active = creating.step === "harness";
|
|
2858
|
+
const highlighted = active && index === creating.harnessIndex;
|
|
2859
|
+
return /* @__PURE__ */ jsx2(
|
|
2860
|
+
"box",
|
|
2861
|
+
{
|
|
2862
|
+
height: 1,
|
|
2863
|
+
flexShrink: 0,
|
|
2864
|
+
backgroundColor: highlighted ? WASH : SURFACE,
|
|
2865
|
+
children: /* @__PURE__ */ jsxs2("text", { height: 1, bg: highlighted ? WASH : SURFACE, children: [
|
|
2866
|
+
/* @__PURE__ */ jsx2("span", { fg: FAINT, children: " " }),
|
|
2867
|
+
/* @__PURE__ */ jsx2(Gutter, { selected: highlighted }),
|
|
2868
|
+
/* @__PURE__ */ jsx2("span", { fg: active ? INK : FAINT, children: item.label.padEnd(12) }),
|
|
2869
|
+
/* @__PURE__ */ jsx2("span", { fg: FAINT, children: active ? item.hint : "" })
|
|
2870
|
+
] })
|
|
2871
|
+
},
|
|
2872
|
+
String(item.harness)
|
|
2873
|
+
);
|
|
2874
|
+
})
|
|
2875
|
+
]
|
|
2876
|
+
}
|
|
2877
|
+
) : null,
|
|
2878
|
+
/* @__PURE__ */ jsx2(StatusLine, { busy, busyStarted, status }),
|
|
2879
|
+
/* @__PURE__ */ jsx2("text", { height: 1, fg: FAINT, bg: "transparent", children: footerText })
|
|
2880
|
+
] });
|
|
2881
|
+
};
|
|
2882
|
+
var runDashboard = async (ctx) => {
|
|
2883
|
+
const renderer = await createCliRenderer({ exitOnCtrlC: false });
|
|
2884
|
+
const queryClient = new QueryClient();
|
|
2885
|
+
const controller = new AbortController();
|
|
2886
|
+
let eventTimer = null;
|
|
2887
|
+
const quit = () => {
|
|
2888
|
+
controller.abort();
|
|
2889
|
+
if (eventTimer !== null) clearTimeout(eventTimer);
|
|
2890
|
+
queryClient.clear();
|
|
2891
|
+
process.nextTick(() => renderer.destroy());
|
|
2892
|
+
};
|
|
2893
|
+
const pendingFamilies = /* @__PURE__ */ new Set();
|
|
2894
|
+
const flushInvalidations = () => {
|
|
2895
|
+
eventTimer = null;
|
|
2896
|
+
if (queryClient.isMutating() > 0) {
|
|
2897
|
+
eventTimer = setTimeout(flushInvalidations, 250);
|
|
2898
|
+
return;
|
|
2899
|
+
}
|
|
2900
|
+
const families = [...pendingFamilies];
|
|
2901
|
+
pendingFamilies.clear();
|
|
2902
|
+
for (const family of families) {
|
|
2903
|
+
void queryClient.invalidateQueries({ queryKey: [family] });
|
|
2904
|
+
}
|
|
2905
|
+
};
|
|
2906
|
+
const scheduleInvalidate = (families) => {
|
|
2907
|
+
if (families.length === 0) return;
|
|
2908
|
+
for (const family of families) pendingFamilies.add(family);
|
|
2909
|
+
if (eventTimer === null) eventTimer = setTimeout(flushInvalidations, 250);
|
|
2910
|
+
};
|
|
2911
|
+
const watch = async () => {
|
|
2912
|
+
while (!controller.signal.aborted) {
|
|
2913
|
+
try {
|
|
2914
|
+
const headers = {};
|
|
2915
|
+
if (ctx.config.token !== null) headers["authorization"] = `Bearer ${ctx.config.token}`;
|
|
2916
|
+
const response = await fetch(`${ctx.config.url}/api/events`, {
|
|
2917
|
+
headers,
|
|
2918
|
+
signal: controller.signal
|
|
2919
|
+
});
|
|
2920
|
+
if (!response.ok || response.body === null) throw new Error(String(response.status));
|
|
2921
|
+
const reader = response.body.getReader();
|
|
2922
|
+
const parser = createSseParser();
|
|
2923
|
+
const decoder = new TextDecoder();
|
|
2924
|
+
for (; ; ) {
|
|
2925
|
+
const { done, value } = await reader.read();
|
|
2926
|
+
if (done) break;
|
|
2927
|
+
for (const payload of parser.push(decoder.decode(value, { stream: true }))) {
|
|
2928
|
+
scheduleInvalidate(eventFamilies(payload));
|
|
2929
|
+
}
|
|
2930
|
+
}
|
|
2931
|
+
} catch {
|
|
2932
|
+
if (controller.signal.aborted) return;
|
|
2933
|
+
}
|
|
2934
|
+
pendingFamilies.add("workbench").add("review");
|
|
2935
|
+
if (eventTimer === null && !controller.signal.aborted) {
|
|
2936
|
+
eventTimer = setTimeout(flushInvalidations, 250);
|
|
2937
|
+
}
|
|
2938
|
+
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
2939
|
+
}
|
|
2940
|
+
};
|
|
2941
|
+
void watch();
|
|
2942
|
+
createRoot(renderer).render(
|
|
2943
|
+
/* @__PURE__ */ jsx2(QueryClientProvider, { client: queryClient, children: /* @__PURE__ */ jsx2(App, { ctx, onQuit: quit }) })
|
|
2944
|
+
);
|
|
2945
|
+
await new Promise((resolve) => {
|
|
2946
|
+
renderer.once(CliRenderEvents.DESTROY, () => resolve());
|
|
2947
|
+
});
|
|
2948
|
+
};
|
|
2949
|
+
export {
|
|
2950
|
+
runDashboard
|
|
2951
|
+
};
|