@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.
Files changed (80) hide show
  1. package/README.md +133 -6
  2. package/dist/chunk-4MX7ZC34.js +27894 -0
  3. package/dist/dashboard-YEU7SDYB.js +2951 -0
  4. package/dist/main.js +6917 -2580
  5. package/man/mend-accounts.1 +1 -1
  6. package/man/mend-adopt.1 +4 -4
  7. package/man/mend-attach.1 +1 -1
  8. package/man/mend-codex.1 +1 -1
  9. package/man/mend-completions.1 +1 -1
  10. package/man/mend-connect.1 +1 -1
  11. package/man/mend-continue.1 +1 -1
  12. package/man/mend-doctor.1 +1 -1
  13. package/man/mend-dotfiles-sync.1 +1 -1
  14. package/man/mend-dotfiles.1 +1 -1
  15. package/man/mend-env-cluster.1 +1 -1
  16. package/man/mend-env-load.1 +1 -1
  17. package/man/mend-env-show.1 +1 -1
  18. package/man/mend-help.1 +1 -1
  19. package/man/mend-keys-init.1 +1 -1
  20. package/man/mend-keys-mode.1 +1 -1
  21. package/man/mend-keys-share.1 +1 -1
  22. package/man/mend-keys-show.1 +1 -1
  23. package/man/mend-login.1 +1 -1
  24. package/man/mend-logout.1 +1 -1
  25. package/man/mend-man.1 +1 -1
  26. package/man/mend-pair.1 +1 -1
  27. package/man/mend-projects.1 +1 -1
  28. package/man/mend-refresh.1 +1 -1
  29. package/man/mend-rejoin.1 +1 -1
  30. package/man/mend-resume.1 +1 -1
  31. package/man/mend-run.1 +1 -1
  32. package/man/mend-server-logs.1 +15 -0
  33. package/man/mend-server-restart.1 +15 -0
  34. package/man/mend-server-setup.1 +64 -0
  35. package/man/mend-server-start.1 +15 -0
  36. package/man/mend-server-status.1 +11 -0
  37. package/man/mend-server-stop.1 +11 -0
  38. package/man/mend-server-upgrade.1 +30 -0
  39. package/man/mend-server.1 +11 -0
  40. package/man/mend-service-add.1 +1 -1
  41. package/man/mend-service-connect.1 +1 -1
  42. package/man/mend-service-init.1 +1 -1
  43. package/man/mend-service-list.1 +1 -1
  44. package/man/mend-service-logs.1 +1 -1
  45. package/man/mend-service-restart.1 +1 -1
  46. package/man/mend-service-run.1 +1 -1
  47. package/man/mend-service-stop.1 +1 -1
  48. package/man/mend-sessions.1 +1 -1
  49. package/man/mend-shell.1 +1 -1
  50. package/man/mend-skills-push.1 +1 -1
  51. package/man/mend-skills.1 +1 -1
  52. package/man/mend-ssh-setup.1 +6 -3
  53. package/man/mend-ssh.1 +1 -1
  54. package/man/mend-stop.1 +1 -1
  55. package/man/mend-ui.1 +1 -1
  56. package/man/mend-version.1 +1 -1
  57. package/man/mend-worktrees.1 +1 -1
  58. package/man/mend.1 +26 -2
  59. package/package.json +7 -3
  60. package/dist/agent-share.js +0 -150
  61. package/dist/clipboard.js +0 -73
  62. package/dist/dashboard-model.js +0 -308
  63. package/dist/dashboard.js +0 -1200
  64. package/dist/doctor.js +0 -233
  65. package/dist/dotfiles.js +0 -90
  66. package/dist/env.js +0 -17
  67. package/dist/help.js +0 -839
  68. package/dist/login.js +0 -240
  69. package/dist/pair.js +0 -83
  70. package/dist/review-model.js +0 -212
  71. package/dist/review-workflow.js +0 -8
  72. package/dist/review.js +0 -690
  73. package/dist/service-init.js +0 -227
  74. package/dist/shared.js +0 -338
  75. package/dist/skills.js +0 -134
  76. package/dist/ssh-setup.js +0 -181
  77. package/dist/terminal.js +0 -8
  78. package/dist/tui-theme.js +0 -41
  79. package/dist/version.js +0 -48
  80. 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
+ };