@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
package/dist/dashboard.js DELETED
@@ -1,1200 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
2
- import { CliRenderEvents, createCliRenderer } from "@opentui/core";
3
- import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
4
- import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient, } from "@tanstack/react-query";
5
- import { useEffect, useRef, useState, useSyncExternalStore } from "react";
6
- import { deriveHarnesses, deriveProjects, advanceFromBase, deriveRows, deriveWorktrees, filterBranches, foldGroupStatus, rowKeyOf, worktreeDisplayName, fetchWorkbench, liveProtocolOf, liveShellOf, mapWorkbenchSessions, markSessionStopped, removeWorktreeGroup, prependSession, removeSession, replaceSession, WORKBENCH_KEY, } from "./dashboard-model.js";
7
- import { reviewTargetForSession } from "./review-workflow.js";
8
- import { ReviewScreen } from "./review.js";
9
- import { cwdFacts, HARNESS_COMMANDS, isPendingId, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, pendingId, } from "./shared.js";
10
- import { openUrl } from "./terminal.js";
11
- import { COBALT, FAINT, INK, INK_2, MUTED, RED, RULE, SURFACE, WASH } from "./tui-theme.js";
12
- // Near-mono on purpose: a status is a word, and only an observed failure
13
- // earns color. Live states read at full ink; settled ones recede.
14
- const STATUS_COLOR = {
15
- starting: INK_2,
16
- running: INK,
17
- waiting: INK_2,
18
- idle: INK_2,
19
- completed: FAINT,
20
- failed: RED,
21
- stopped: FAINT,
22
- };
23
- const timeAgo = (iso) => {
24
- const ms = Date.now() - new Date(iso).getTime();
25
- if (!Number.isFinite(ms) || ms < 0)
26
- return "";
27
- const minutes = Math.floor(ms / 60_000);
28
- if (minutes < 1)
29
- return "just now";
30
- if (minutes < 60)
31
- return `${minutes}m ago`;
32
- const hours = Math.floor(minutes / 60);
33
- if (hours < 24)
34
- return `${hours}h ago`;
35
- return `${Math.floor(hours / 24)}d ago`;
36
- };
37
- import { createSseParser, eventFamilies } from "./workbench-events.js";
38
- /** The agent itself is still working: never remove from under it. Idle (agent gone) may go. */
39
- const AGENT_LIVE_STATUSES = new Set(["starting", "running", "waiting"]);
40
- // ─── panes and rows ─────────────────────────────────────────────────────────
41
- const Gutter = ({ selected }) => (_jsx("span", { fg: selected ? COBALT : FAINT, children: selected ? "▌ " : " " }));
42
- /**
43
- * A drawn pane: rounded border, a title in the frame, cobalt when the
44
- * keyboard lives here. Everything the dashboard shows sits in one of these.
45
- */
46
- const Pane = ({ title, focused, children, width, height, grow, }) => (_jsx("box", { border: true, borderStyle: "rounded", borderColor: focused ? COBALT : RULE, title: ` ${title} `, titleAlignment: "left", backgroundColor: "transparent", ...(width === undefined ? {} : { width, flexShrink: 0, minHeight: 0 }), ...(height === undefined ? {} : { height, flexShrink: 0 }), ...(grow === true ? { flexGrow: 1, flexShrink: 1, minHeight: 0, minWidth: 0 } : {}), flexDirection: "column", children: children }));
47
- const paneScrollStyle = {
48
- rootOptions: { backgroundColor: "transparent", border: false },
49
- wrapperOptions: { backgroundColor: "transparent" },
50
- viewportOptions: { backgroundColor: "transparent" },
51
- contentOptions: { backgroundColor: "transparent" },
52
- };
53
- const ProjectRow = ({ item, selected, nameWidth, }) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { fg: INK, children: item.project.name.padEnd(nameWidth) }), _jsx("span", { fg: item.total === 0 ? FAINT : MUTED, children: ` ${item.total}` }), item.live > 0 ? _jsx("span", { fg: MUTED, children: ` · ${item.live} live` }) : null, item.open > 0 ? _jsx("span", { fg: MUTED, children: ` · ${item.open}` }) : null] }) }));
54
- const ProcessLines = ({ processes, services, indent, }) => (_jsxs(_Fragment, { children: [processes.map((process) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: FAINT, children: indent }), _jsx("span", { fg: INK_2, children: process.kind === "shell"
55
- ? (process.label ?? "shell")
56
- : (process.harness ?? process.kind) }), _jsxs("span", { fg: FAINT, children: [process.kind === "agent-protocol"
57
- ? " · protocol"
58
- : process.kind === "agent-external"
59
- ? " · external"
60
- : "", ` ${process.status}`] })] }) }, process.id))), services.map((service) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: FAINT, children: indent }), _jsx("span", { fg: service.status === "reachable" ? INK_2 : MUTED, children: `${service.label ?? service.id.slice(0, 6)} :${service.workspacePort ?? "?"}${service.protocol === "udp" ? "u" : ""}→${service.hostPort ?? "?"}` }), _jsx("span", { fg: FAINT, children: ` ${service.status}` })] }) }, service.id)))] }));
61
- /** A worktree's header: the place, its folded status, its member facts. */
62
- const WorktreeHeaderRow = ({ group, selected, }) => {
63
- const folded = foldGroupStatus(group);
64
- const color = group.live > 0 ? (STATUS_COLOR[folded] ?? MUTED) : FAINT;
65
- const age = timeAgo(group.sessions.at(-1)?.session.createdAt ?? group.createdAt).replace(" ago", "");
66
- const open = group.annotation?.openComments ?? 0;
67
- return (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { fg: INK, children: group.name.slice(0, 30).padEnd(31) }), _jsx("span", { fg: color, children: (group.live > 0 ? folded : "settled").padEnd(10) }), _jsx("span", { fg: FAINT, children: age.padEnd(9) }), _jsx("span", { fg: MUTED, children: `${group.sessions.length} session${group.sessions.length === 1 ? "" : "s"}` }), open > 0 ? _jsx("span", { fg: MUTED, children: ` · ${open} open` }) : null] }) }));
68
- };
69
- /** One conversation inside a worktree — the label is its identity. */
70
- const SessionChildRow = ({ item, selected, }) => {
71
- const { session, services, processes } = item;
72
- const color = STATUS_COLOR[session.status] ?? MUTED;
73
- const age = timeAgo(session.createdAt).replace(" ago", "");
74
- const name = session.label ?? `session ${session.id.slice(0, 8)}`;
75
- return (_jsxs("box", { flexShrink: 0, flexDirection: "column", backgroundColor: "transparent", children: [_jsx("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { fg: FAINT, children: " └ " }), _jsx("span", { fg: INK, children: name.slice(0, 26).padEnd(27) }), _jsx("span", { fg: color, children: session.status.padEnd(10) }), _jsx("span", { fg: FAINT, children: age.padEnd(9) }), _jsx("span", { fg: MUTED, children: session.harness.padEnd(10) })] }) }), _jsx(ProcessLines, { processes: processes, services: services, indent: " └ " })] }));
76
- };
77
- const HarnessRow = ({ item, selected, }) => (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { fg: INK, children: item.label.padEnd(12) }), _jsx("span", { fg: FAINT, children: item.hint })] }) }));
78
- /**
79
- * The detail panel: everything about the selected session that the one-line
80
- * rows no longer carry — branch and base, services, the review state, the
81
- * summary. The panes above stay scannable because this panel holds the depth.
82
- */
83
- const SessionDetail = ({ item }) => {
84
- if (item === null) {
85
- return (_jsx("text", { height: 1, bg: "transparent", fg: FAINT, children: " no session selected — n starts one" }));
86
- }
87
- const { session, annotation, services } = item;
88
- const color = STATUS_COLOR[session.status] ?? MUTED;
89
- const summary = session.summary?.split("\n")[0] ?? null;
90
- return (_jsxs(_Fragment, { children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { children: " " }), _jsx("span", { fg: color, children: session.status }), _jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsx("span", { fg: MUTED, children: session.branch }), _jsxs("span", { fg: FAINT, children: [session.baseSha === "" ? "" : ` vs ${session.baseRef ?? session.baseSha.slice(0, 12)}`, " \u00B7 started ", timeAgo(session.createdAt)] })] }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { children: " " }), services.length === 0 ? (_jsx("span", { fg: FAINT, children: "no services running" })) : (services.slice(0, 3).map((service, index) => (_jsxs("span", { children: [index > 0 ? _jsx("span", { fg: FAINT, children: " \u00B7 " }) : null, _jsx("span", { fg: service.status === "reachable" ? INK_2 : MUTED, children: `${service.label ?? service.id.slice(0, 6)} :${service.workspacePort ?? "?"}${service.protocol === "udp" ? "u" : ""}→${service.hostPort ?? "?"} ${service.status}` })] }, service.id)))), services.length > 3 ? _jsx("span", { fg: FAINT, children: ` · +${services.length - 3} more` }) : null] }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { children: " " }), annotation === undefined || annotation.openComments === 0 ? (_jsx("span", { fg: FAINT, children: "no open review comments" })) : (_jsxs("span", { fg: INK_2, children: [annotation.openComments, " open comment", annotation.openComments === 1 ? "" : "s"] })), annotation?.pendingFollowUp === true ? (_jsxs(_Fragment, { children: [_jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsx("span", { fg: INK_2, children: "follow-up pending" })] })) : null, annotation?.changeId != null ? (_jsxs(_Fragment, { children: [_jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsx("span", { fg: MUTED, children: "v reviews the change" })] })) : null] }), _jsx("text", { height: 1, bg: "transparent", fg: summary === null ? FAINT : MUTED, children: ` ${summary ?? "no summary yet"}` })] }));
91
- };
92
- const StatusLine = ({ busy, busyStarted, status, }) => {
93
- const [now, setNow] = useState(() => Date.now());
94
- // Timers are the one thing React cannot express declaratively: a 1s tick
95
- // while busy, and one repaint to clear an expired status message.
96
- useEffect(() => {
97
- if (busy !== null) {
98
- const timer = setInterval(() => setNow(Date.now()), 1000);
99
- return () => clearInterval(timer);
100
- }
101
- if (status !== null) {
102
- const timer = setTimeout(() => setNow(Date.now()), 5100);
103
- return () => clearTimeout(timer);
104
- }
105
- return undefined;
106
- }, [busy, status]);
107
- const text = busy !== null
108
- ? ` ${busy} ${Math.max(0, Math.round((now - busyStarted) / 1000))}s`
109
- : status !== null && Date.now() - status.at < 5000
110
- ? ` ${status.text}`
111
- : "";
112
- return (_jsx("text", { height: 1, fg: INK_2, bg: "transparent", children: text }));
113
- };
114
- const errorText = (error) => error instanceof Error ? error.message : String(error);
115
- /** Keep a 1-line-per-row selection inside its scrollbox's real viewport. */
116
- const keepSpanVisible = (scroll, top, height) => {
117
- if (scroll === null)
118
- return;
119
- const viewH = Math.max(1, scroll.viewport.height);
120
- if (top < scroll.scrollTop)
121
- scroll.scrollTo(top);
122
- else if (top + height > scroll.scrollTop + viewH)
123
- scroll.scrollTo(top + height - viewH);
124
- };
125
- const keepRowVisible = (scroll, index) => keepSpanVisible(scroll, index, 1);
126
- const PROJECTS_PANE_WIDTH = 30;
127
- /** The header's share fact when no share runs. */
128
- const noShare = () => "off";
129
- const App = ({ ctx, onQuit }) => {
130
- const renderer = useRenderer();
131
- const queryClient = useQueryClient();
132
- const { data, failureReason } = useQuery({
133
- queryKey: WORKBENCH_KEY,
134
- queryFn: () => fetchWorkbench(ctx),
135
- // A dev server mid-restart is normal: keep trying, once a second.
136
- retry: true,
137
- retryDelay: 1000,
138
- });
139
- const [focusState, setFocus] = useState("sessions");
140
- const [projectKey, setProjectKey] = useState(null);
141
- /** A row key (`wt:<id>` | `s:<id>`), so selection survives regrouping. */
142
- const [sessionKey, setSessionKey] = useState(null);
143
- const [picker, setPicker] = useState(null);
144
- const [pickerIndex, setPickerIndex] = useState(0);
145
- const [busy, setBusy] = useState(null);
146
- const [busyStarted, setBusyStarted] = useState(0);
147
- const [status, setStatus] = useState(null);
148
- const shareState = useSyncExternalStore((onChange) => ctx.agentShare?.subscribe(() => onChange()) ?? (() => { }), ctx.agentShare === null ? noShare : ctx.agentShare.snapshot);
149
- const [editing, setEditing] = useState(null);
150
- /** Session id a stop is armed against; the second press fires it. */
151
- const [stopArmed, setStopArmed] = useState(null);
152
- /**
153
- * The whole worktree creation is ONE modal: name, base (fuzzy over the
154
- * project's branches), harness — every step visible, the active one
155
- * expanded. Enter advances, esc steps back (cancels from the name).
156
- */
157
- const [creating, setCreating] = useState(null);
158
- /**
159
- * The cwd is a git repo the store doesn't know: offer to adopt it, once
160
- * per dashboard run. Enter adopts, esc declines and stays quiet.
161
- */
162
- const [adoptOffer, setAdoptOffer] = useState(null);
163
- const adoptOfferDecided = useRef(false);
164
- const [reviewing, setReviewing] = useState(null);
165
- /** Set synchronously around attach/launch so keystrokes can't double-fire. */
166
- const lockRef = useRef(false);
167
- /** Latest modal state for mutation callbacks — closures there go stale. */
168
- const modalRef = useRef(false);
169
- modalRef.current = editing !== null || reviewing !== null || picker !== null || creating !== null;
170
- const { width: terminalCols, height: terminalRows } = useTerminalDimensions();
171
- const showProjectsPane = terminalCols >= 72;
172
- const showDetail = terminalRows >= 18;
173
- const focus = showProjectsPane ? focusState : "sessions";
174
- // ── pane data ──
175
- const projectItems = deriveProjects(data);
176
- const homeProject = data === undefined ? undefined : matchProjectByCwd(data.projects, cwdFacts(ctx.cwd));
177
- const projectIndexRaw = projectKey === null ? -1 : projectItems.findIndex((p) => p.project.id === projectKey);
178
- const projectIndex = projectIndexRaw !== -1
179
- ? projectIndexRaw
180
- : Math.max(0, homeProject === undefined
181
- ? 0
182
- : projectItems.findIndex((p) => p.project.id === homeProject.id));
183
- const selectedProject = projectItems[projectIndex] ?? null;
184
- const worktreeGroups = deriveWorktrees(data, selectedProject?.project.id ?? null);
185
- const rows = deriveRows(worktreeGroups);
186
- const rowIndexRaw = sessionKey === null ? -1 : rows.findIndex((row) => rowKeyOf(row) === sessionKey);
187
- const rowIndex = rowIndexRaw === -1 ? 0 : rowIndexRaw;
188
- const selectedRow = rows[rowIndex] ?? null;
189
- const selectedGroup = selectedRow?.group ?? null;
190
- // A worktree header still names a concrete conversation for session verbs:
191
- // the newest live member, else the newest at all.
192
- const selectedSession = selectedRow === null
193
- ? null
194
- : selectedRow.kind === "session"
195
- ? selectedRow.item
196
- : (selectedRow.group.sessions.find((item) => LIVE_STATUSES.has(item.session.status)) ??
197
- selectedRow.group.sessions[0] ??
198
- null);
199
- const pickerItems = picker === null ? [] : deriveHarnesses(picker.session);
200
- const selectSession = (id) => setSessionKey(`s:${id}`);
201
- const say = (text) => setStatus({ text, at: Date.now() });
202
- const refetch = () => void queryClient.invalidateQueries({ queryKey: WORKBENCH_KEY });
203
- // Signature requests are facts from outside React — the status line says them.
204
- useEffect(() => {
205
- const share = ctx.agentShare;
206
- if (share === null)
207
- return;
208
- return share.subscribe((event) => {
209
- if (event === null)
210
- return;
211
- if (event.kind === "sign-requested") {
212
- say(`✎ signature requested (${event.context}) — touch your key if it blinks`);
213
- }
214
- else if (event.kind === "signed")
215
- say(`✓ signed (${event.seconds.toFixed(1)}s)`);
216
- else if (event.kind === "not-signed")
217
- say(`✗ not signed — ${event.message}`);
218
- });
219
- }, [ctx.agentShare]);
220
- // A repo underfoot that the store has never met: raise the adopt offer the
221
- // first time the workbench answers and no project matches the cwd.
222
- useEffect(() => {
223
- if (data === undefined || adoptOfferDecided.current)
224
- return;
225
- if (homeProject !== undefined)
226
- return;
227
- const facts = cwdFacts(ctx.cwd);
228
- if (facts.repoRoot === null)
229
- return;
230
- adoptOfferDecided.current = true;
231
- setAdoptOffer({
232
- root: facts.repoRoot,
233
- source: facts.originUrl ?? facts.repoRoot,
234
- name: normalizeProjectName(facts.repoRoot.split("/").at(-1) ?? "project"),
235
- modeIndex: 0,
236
- });
237
- // homeProject/data identity is enough; ctx.cwd never changes in a run.
238
- // eslint-disable-next-line react-hooks/exhaustive-deps
239
- }, [data, homeProject]);
240
- // Keep each pane's selection on screen — the one imperative escape hatch.
241
- const projectScrollRef = useRef(null);
242
- const sessionScrollRef = useRef(null);
243
- useEffect(() => {
244
- keepRowVisible(projectScrollRef.current, projectIndex);
245
- }, [projectIndex]);
246
- useEffect(() => {
247
- // Rows vary in height (header = 1; a session row carries its process and
248
- // service fact lines), so the scroll target is the row's y offset.
249
- const rowHeight = (row) => row === undefined || row.kind === "worktree"
250
- ? 1
251
- : 1 + row.item.processes.length + row.item.services.length;
252
- let top = 0;
253
- for (const [index, row] of rows.entries()) {
254
- if (index === rowIndex)
255
- break;
256
- top += rowHeight(row);
257
- }
258
- keepSpanVisible(sessionScrollRef.current, top, rowHeight(rows[rowIndex]));
259
- }, [rowIndex, rows]);
260
- const attachFlow = async (session) => {
261
- const short = session.id.slice(0, 8);
262
- lockRef.current = true;
263
- renderer.suspend();
264
- process.stdout.write(`\nattached · ${session.harness} · ${short} · detach: Ctrl+]\n\n`);
265
- let outcome;
266
- try {
267
- outcome = await ctx.attachTty(session.id, session.harness);
268
- if (outcome === "unavailable") {
269
- // A live session whose terminal ended (idle: a workspace held open,
270
- // no PTY behind it). Enter still means "get me in" — REJOIN the shell
271
- // already holding the workspace when one is live; only open a fresh
272
- // one when nothing is attachable (stacking a new bash per attempt is
273
- // how a session ends up held open by orphan shells).
274
- const detail = await ctx.api("GET", `/sessions/${session.id}`);
275
- const liveProtocol = liveProtocolOf(detail.processes);
276
- const existing = liveShellOf(detail.processes);
277
- if (liveProtocol !== null) {
278
- // A phone pickup holds the session in protocol mode — no PTY behind
279
- // it. Take it over: end the protocol agent, resume the same
280
- // conversation as a TUI, then attach to that.
281
- process.stdout.write(`taking over from the protocol session — same conversation…\n\n`);
282
- await ctx.api("POST", `/sessions/${session.id}/handoff`, { to: "pty" });
283
- outcome = await ctx.attachTty(session.id, session.harness);
284
- }
285
- else if (existing !== null) {
286
- process.stdout.write(`no live terminal — rejoining the open shell\n\n`);
287
- outcome = await ctx.attachTty(session.id, "shell", existing.id);
288
- }
289
- else {
290
- const shell = await ctx.api("POST", `/sessions/${session.id}/shell`);
291
- process.stdout.write(`no live terminal — opened a shell in the workspace\n\n`);
292
- outcome = await ctx.attachTty(session.id, "shell", shell.id);
293
- }
294
- }
295
- }
296
- catch (error) {
297
- say(error instanceof Error ? error.message : String(error));
298
- return;
299
- }
300
- finally {
301
- renderer.resume();
302
- lockRef.current = false;
303
- }
304
- say(outcome === "unavailable"
305
- ? "attach unavailable — could not connect"
306
- : outcome === "detached" || outcome === "interrupted"
307
- ? `detached — ${short} keeps running`
308
- : outcome === "dropped"
309
- ? `disconnected — ${short} keeps running`
310
- : `session settled · ${short}`);
311
- refetch();
312
- };
313
- /** Converge on the server's truth once, after the LAST in-flight mutation. */
314
- const settleRefetch = () => {
315
- if (queryClient.isMutating() === 1)
316
- refetch();
317
- };
318
- /** The current cache entry, for surgical optimistic edits. */
319
- const workbench = () => queryClient.getQueryData(WORKBENCH_KEY);
320
- const patchWorkbench = (f) => {
321
- const current = workbench();
322
- if (current !== undefined)
323
- queryClient.setQueryData(WORKBENCH_KEY, f(current));
324
- };
325
- /** Attaching yanks the terminal; only do it unasked when nothing else is open. */
326
- const attachIfIdle = async (session) => {
327
- if (lockRef.current || modalRef.current) {
328
- say(`session ready · ${session.id.slice(0, 8)} — enter attaches`);
329
- return;
330
- }
331
- await attachFlow(session);
332
- };
333
- // A new session appears as a `starting` row the moment enter is pressed;
334
- // the keyboard stays free while the workspace provisions, and the terminal
335
- // attaches when the launch answers — unless something else has the screen.
336
- const launchMutation = useMutation({
337
- mutationFn: async (vars) => {
338
- const argv = HARNESS_COMMANDS[vars.harness];
339
- if (argv === undefined)
340
- throw new Error(`unknown harness "${vars.harness}"`);
341
- const session = await ctx.api("POST", `/projects/${vars.projectId}/sessions`, {
342
- harness: vars.harness,
343
- label: null,
344
- name: vars.name,
345
- base: vars.base,
346
- });
347
- await ctx.api("POST", `/sessions/${session.id}/launch`, { argv });
348
- return session;
349
- },
350
- onMutate: async (vars) => {
351
- await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
352
- patchWorkbench((current) => prependSession(current, vars.projectId, {
353
- id: vars.pendingKey,
354
- harness: vars.harness,
355
- label: null,
356
- branch: vars.name === null ? "provisioning…" : `mend/${vars.name}`,
357
- baseSha: "",
358
- baseRef: vars.base,
359
- status: "starting",
360
- summary: null,
361
- createdAt: new Date().toISOString(),
362
- }));
363
- selectSession(vars.pendingKey);
364
- setBusy(`provisioning ${vars.harness} workspace — a first launch builds the harness image ·`);
365
- setBusyStarted(Date.now());
366
- },
367
- onError: (error, vars) => {
368
- patchWorkbench((current) => removeSession(current, vars.projectId, vars.pendingKey));
369
- setBusy(null);
370
- say(errorText(error));
371
- },
372
- onSuccess: async (session, vars) => {
373
- patchWorkbench((current) => replaceSession(current, vars.projectId, vars.pendingKey, session));
374
- selectSession(session.id);
375
- setBusy(null);
376
- await attachIfIdle(session);
377
- },
378
- onSettled: settleRefetch,
379
- });
380
- const resumeMutation = useMutation({
381
- mutationFn: (vars) => ctx.api("POST", `/sessions/${vars.session.id}/resume`, {
382
- harness: vars.harness,
383
- }),
384
- onMutate: async (vars) => {
385
- await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
386
- patchWorkbench((current) => mapWorkbenchSessions(current, (session) => session.id === vars.session.id ? { ...session, status: "starting" } : session));
387
- selectSession(vars.session.id);
388
- setBusy(`resuming ${vars.session.id.slice(0, 8)} — a fresh workspace restores the saved state ·`);
389
- setBusyStarted(Date.now());
390
- },
391
- onError: (error) => {
392
- setBusy(null);
393
- say(errorText(error));
394
- refetch();
395
- },
396
- onSuccess: async (resumed, vars) => {
397
- patchWorkbench((current) => replaceSession(current, vars.projectId, vars.session.id, resumed));
398
- selectSession(resumed.id);
399
- setBusy(null);
400
- await attachIfIdle(resumed);
401
- },
402
- onSettled: settleRefetch,
403
- });
404
- const ADOPT_AUTH_MODES = [
405
- {
406
- mode: "ambient",
407
- label: "ambient",
408
- hint: "the server machine's own git/ssh setup",
409
- },
410
- {
411
- mode: "mend-key",
412
- label: "mend-key",
413
- hint: "the machine's Mend deploy key — add its public half on the git host",
414
- },
415
- {
416
- mode: "bridge",
417
- label: "bridge",
418
- hint: "your OWN ssh-agent, relayed while `mend keys share` runs here",
419
- },
420
- ];
421
- const adoptMutation = useMutation({
422
- mutationFn: (offer) => ctx.api("POST", "/projects", {
423
- name: offer.name,
424
- source: offer.source,
425
- gitAuthMode: ADOPT_AUTH_MODES[offer.modeIndex]?.mode ?? "ambient",
426
- }),
427
- onMutate: (offer) => {
428
- setAdoptOffer(null);
429
- setBusy(`adopting ${offer.name} — cloning into the store ·`);
430
- setBusyStarted(Date.now());
431
- },
432
- onError: (error) => {
433
- setBusy(null);
434
- say(errorText(error));
435
- },
436
- onSuccess: (project) => {
437
- setBusy(null);
438
- say(`adopted · ${project.name} — n starts a worktree`);
439
- setProjectKey(project.id);
440
- setSessionKey(null);
441
- },
442
- onSettled: settleRefetch,
443
- });
444
- // The label lands in the row before the server answers; an error puts the
445
- // truth back on the next refetch.
446
- const renameMutation = useMutation({
447
- mutationFn: (vars) => ctx.api("POST", `/sessions/${vars.session.id}/label`, { label: vars.label }),
448
- onMutate: async (vars) => {
449
- await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
450
- patchWorkbench((current) => mapWorkbenchSessions(current, (session) => session.id === vars.session.id ? { ...session, label: vars.label } : session));
451
- say(vars.label === null
452
- ? `label cleared · ${vars.session.id.slice(0, 8)}`
453
- : `labeled · ${vars.label}`);
454
- },
455
- onError: (error) => {
456
- say(errorText(error));
457
- refetch();
458
- },
459
- onSettled: settleRefetch,
460
- });
461
- // Stops are explicit — and armed: the first press names what a second press
462
- // will stop; moving the selection re-arms against the newly selected row.
463
- const stopMutation = useMutation({
464
- mutationFn: (session) => ctx.api("POST", `/sessions/${session.id}/stop`),
465
- onMutate: async (session) => {
466
- await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
467
- // The row settles and its live process/service facts drop in one paint.
468
- patchWorkbench((current) => markSessionStopped(current, session.id));
469
- say(`stopped · ${worktreeDisplayName(session)} — the record and review remain`);
470
- },
471
- onError: (error) => {
472
- say(errorText(error));
473
- refetch();
474
- },
475
- onSettled: settleRefetch,
476
- });
477
- // A new conversation inside an existing worktree — the `s` key's flow.
478
- const launchInWorktreeMutation = useMutation({
479
- mutationFn: async (vars) => {
480
- const argv = HARNESS_COMMANDS[vars.harness];
481
- if (argv === undefined)
482
- throw new Error(`unknown harness "${vars.harness}"`);
483
- const session = await ctx.api("POST", `/worktrees/${vars.worktreeId}/sessions`, {
484
- harness: vars.harness,
485
- label: null,
486
- });
487
- await ctx.api("POST", `/sessions/${session.id}/launch`, { argv });
488
- return session;
489
- },
490
- onMutate: async (vars) => {
491
- await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
492
- patchWorkbench((current) => prependSession(current, vars.projectId, {
493
- id: vars.pendingKey,
494
- worktreeId: vars.worktreeId,
495
- harness: vars.harness,
496
- label: null,
497
- branch: "joining…",
498
- baseSha: "",
499
- baseRef: null,
500
- status: "starting",
501
- summary: null,
502
- createdAt: new Date().toISOString(),
503
- }));
504
- selectSession(vars.pendingKey);
505
- setBusy(`starting ${vars.harness} in the worktree ·`);
506
- setBusyStarted(Date.now());
507
- },
508
- onError: (error, vars) => {
509
- patchWorkbench((current) => removeSession(current, vars.projectId, vars.pendingKey));
510
- setBusy(null);
511
- say(errorText(error));
512
- },
513
- onSuccess: async (session, vars) => {
514
- patchWorkbench((current) => replaceSession(current, vars.projectId, vars.pendingKey, session));
515
- selectSession(session.id);
516
- setBusy(null);
517
- await attachIfIdle(session);
518
- },
519
- onSettled: settleRefetch,
520
- });
521
- // The one explicit destructive act. Against a pre-worktree server the
522
- // session delete IS the old combined removal — same key, old semantics.
523
- const removeWorktreeMutation = useMutation({
524
- mutationFn: (group) => group.id !== null
525
- ? ctx.api("DELETE", `/worktrees/${group.id}`)
526
- : ctx.api("DELETE", `/sessions/${group.sessions[0]?.session.id ?? ""}`),
527
- onMutate: async (group) => {
528
- await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
529
- const projectId = selectedProject?.project.id;
530
- // The group leaves the list before the server answers; an error refetches truth.
531
- if (projectId !== undefined) {
532
- patchWorkbench((current) => removeWorktreeGroup(current, projectId, group));
533
- }
534
- say(`removing worktree · ${group.name}`);
535
- },
536
- onError: (error) => {
537
- say(errorText(error));
538
- refetch();
539
- },
540
- onSuccess: (_result, group) => {
541
- say(`removed · ${group.name}`);
542
- refetch();
543
- },
544
- onSettled: settleRefetch,
545
- });
546
- /**
547
- * One session leaves; the worktree stays. The server stops an idle session's shells and
548
- * settles it on the way out, so a session killed a moment ago removes without a second stop.
549
- */
550
- const removeSessionMutation = useMutation({
551
- mutationFn: (session) => ctx.api("DELETE", `/sessions/${session.id}`),
552
- onMutate: async (session) => {
553
- await queryClient.cancelQueries({ queryKey: WORKBENCH_KEY });
554
- const projectId = selectedProject?.project.id;
555
- if (projectId !== undefined) {
556
- patchWorkbench((current) => removeSession(current, projectId, session.id));
557
- }
558
- say(`removing session · ${worktreeDisplayName(session)}`);
559
- },
560
- onError: (error) => {
561
- say(errorText(error));
562
- refetch();
563
- },
564
- onSuccess: (_result, session) => {
565
- say(`removed · ${worktreeDisplayName(session)} — the worktree remains`);
566
- refetch();
567
- },
568
- onSettled: settleRefetch,
569
- });
570
- /** Removal is armed like stops: the first press states the facts. */
571
- const [removeArmed, setRemoveArmed] = useState(null);
572
- const armRemove = () => {
573
- // A session row removes THAT session; a worktree header removes the whole worktree.
574
- if (selectedRow?.kind === "session") {
575
- const session = selectedRow.item.session;
576
- if (isPendingId(session.id))
577
- return;
578
- if (AGENT_LIVE_STATUSES.has(session.status)) {
579
- say(`the agent is still working — kill it first (⇧K) · ${worktreeDisplayName(session)}`);
580
- return;
581
- }
582
- if (removeArmed === session.id) {
583
- setRemoveArmed(null);
584
- removeSessionMutation.mutate(session);
585
- return;
586
- }
587
- setRemoveArmed(session.id);
588
- say(`press again to remove session · ${worktreeDisplayName(session)} — its record goes, the worktree stays`);
589
- return;
590
- }
591
- const group = selectedGroup;
592
- if (group === null || group.sessions.some((item) => isPendingId(item.session.id)))
593
- return;
594
- if (group.live > 0) {
595
- say(`${group.live} session${group.live === 1 ? "" : "s"} live — stop them first · ${group.name}`);
596
- return;
597
- }
598
- if (removeArmed === group.key) {
599
- setRemoveArmed(null);
600
- removeWorktreeMutation.mutate(group);
601
- return;
602
- }
603
- setRemoveArmed(group.key);
604
- const facts = group.sessions.length === 1
605
- ? "its session and change go with it"
606
- : `${group.sessions.length} sessions and the change go with it`;
607
- say(`press again to remove worktree · ${group.name} — ${facts}`);
608
- };
609
- const armStop = () => {
610
- // A worktree header arms a stop of EVERY live conversation in it.
611
- if (selectedRow?.kind === "worktree") {
612
- const group = selectedRow.group;
613
- const live = group.sessions.filter((item) => LIVE_STATUSES.has(item.session.status));
614
- if (live.length === 0) {
615
- say("nothing to stop — the worktree is settled");
616
- return;
617
- }
618
- if (stopArmed === `wt:${group.key}`) {
619
- setStopArmed(null);
620
- for (const item of live)
621
- stopMutation.mutate(item.session);
622
- return;
623
- }
624
- setStopArmed(`wt:${group.key}`);
625
- say(`press again to stop ${live.length} live session${live.length === 1 ? "" : "s"} · ${group.name}`);
626
- return;
627
- }
628
- const item = selectedSession;
629
- if (item === null || isPendingId(item.session.id))
630
- return;
631
- if (!LIVE_STATUSES.has(item.session.status)) {
632
- say("nothing to stop — the session is settled");
633
- return;
634
- }
635
- if (stopArmed === item.session.id) {
636
- setStopArmed(null);
637
- stopMutation.mutate(item.session);
638
- return;
639
- }
640
- setStopArmed(item.session.id);
641
- say(`press again to stop · ${worktreeDisplayName(item.session)}`);
642
- };
643
- /** Open the creation modal; the branch list starts loading immediately. */
644
- const openCreateModal = (projectId) => {
645
- setCreating({
646
- projectId,
647
- step: "name",
648
- name: "",
649
- branches: null,
650
- query: "",
651
- baseIndex: 0,
652
- base: null,
653
- joins: false,
654
- harnessIndex: 0,
655
- });
656
- // The checkout mend ran in names its branch; creating in THAT project
657
- // prefills the base with it — the list highlights it once loaded.
658
- const prefill = projectId === homeProject?.id ? ctx.cwdBranch : null;
659
- void ctx.api("GET", `/projects/${projectId}/branches`).then((branches) => setCreating((current) => {
660
- if (current === null)
661
- return current;
662
- const match = prefill === null ? undefined : branches.find((candidate) => candidate.name === prefill);
663
- const ordered = filterBranches(branches, "");
664
- return {
665
- ...current,
666
- branches,
667
- base: match === undefined || match.isDefault ? null : match.name,
668
- baseIndex: match === undefined
669
- ? 0
670
- : Math.max(0, ordered.findIndex((candidate) => candidate.name === match.name)),
671
- };
672
- }), () =>
673
- // An unreadable list falls back to the default base; the base step
674
- // then shows only the default and enter moves on.
675
- setCreating((current) => (current === null ? current : { ...current, branches: [] })));
676
- };
677
- /** Commit the name: an existing name JOINS its worktree — base is fixed. */
678
- const advanceFromName = (current, raw) => {
679
- const slug = normalizeProjectName(raw.trim());
680
- const name = raw.trim() === "" ? "" : slug;
681
- const joins = name !== "" &&
682
- (workbench()?.details.get(current.projectId)?.worktrees ?? []).some((candidate) => candidate.name === name);
683
- // A join fixes the base; otherwise whatever is chosen (the checkout
684
- // prefill included) rides along untouched.
685
- return {
686
- ...current,
687
- name,
688
- joins,
689
- ...(joins ? { base: null } : {}),
690
- step: joins ? "harness" : "base",
691
- };
692
- };
693
- const submitCreateName = (value) => {
694
- setCreating((current) => (current === null ? current : advanceFromName(current, value)));
695
- };
696
- const submitCreateBase = () => {
697
- setCreating((current) => (current === null ? current : advanceFromBase(current)));
698
- };
699
- /** Enter on the harness: everything is chosen — launch. */
700
- const submitCreateHarness = () => {
701
- const current = creating;
702
- if (current === null)
703
- return;
704
- const choice = deriveHarnesses(null)[current.harnessIndex];
705
- if (choice?.harness == null)
706
- return;
707
- setCreating(null);
708
- setFocus("sessions");
709
- launchMutation.mutate({
710
- projectId: current.projectId,
711
- harness: choice.harness,
712
- name: current.name === "" ? null : current.name,
713
- base: current.base,
714
- pendingKey: pendingId(),
715
- });
716
- };
717
- const resumeSession = (projectId, session, harness) => {
718
- setFocus("sessions");
719
- resumeMutation.mutate({ projectId, session, harness });
720
- };
721
- const submitRename = (session, value) => {
722
- setEditing(null);
723
- const label = value.trim() === "" ? null : value.trim();
724
- renameMutation.mutate({ session, label });
725
- };
726
- const openPicker = (session, worktree) => {
727
- setPicker(worktree === undefined ? { session } : { session, worktree });
728
- setPickerIndex(0);
729
- };
730
- const moveSelection = (delta) => {
731
- if (picker !== null) {
732
- setPickerIndex((current) => Math.max(0, Math.min(pickerItems.length - 1, current + delta)));
733
- return;
734
- }
735
- if (focus === "projects") {
736
- if (projectItems.length === 0)
737
- return;
738
- const next = Math.max(0, Math.min(projectItems.length - 1, projectIndex + delta));
739
- const item = projectItems[next];
740
- if (item !== undefined) {
741
- setProjectKey(item.project.id);
742
- setSessionKey(null);
743
- }
744
- return;
745
- }
746
- if (rows.length === 0)
747
- return;
748
- const next = Math.max(0, Math.min(rows.length - 1, rowIndex + delta));
749
- const row = rows[next];
750
- if (row !== undefined)
751
- setSessionKey(rowKeyOf(row));
752
- };
753
- const activate = () => {
754
- if (picker !== null) {
755
- const choice = pickerItems[pickerIndex];
756
- const projectId = selectedProject?.project.id;
757
- if (choice === undefined || projectId === undefined)
758
- return;
759
- setPicker(null);
760
- if (picker.worktree !== undefined && picker.worktree.id !== null) {
761
- if (choice.harness !== null) {
762
- setFocus("sessions");
763
- launchInWorktreeMutation.mutate({
764
- projectId,
765
- worktreeId: picker.worktree.id,
766
- harness: choice.harness,
767
- pendingKey: pendingId(),
768
- });
769
- }
770
- }
771
- else if (picker.session !== null) {
772
- resumeSession(projectId, picker.session, choice.harness);
773
- }
774
- return;
775
- }
776
- if (focus === "projects") {
777
- setFocus("sessions");
778
- return;
779
- }
780
- if (selectedRow?.kind === "worktree") {
781
- // Enter on the place: attach its newest live conversation, or open a
782
- // new one when nothing is live.
783
- const live = selectedRow.group.sessions.find((item) => LIVE_STATUSES.has(item.session.status));
784
- if (live !== undefined) {
785
- void attachFlow(live.session);
786
- }
787
- else if (selectedRow.group.id !== null) {
788
- openPicker(null, selectedRow.group);
789
- }
790
- return;
791
- }
792
- const item = selectedSession;
793
- if (item === null)
794
- return;
795
- if (isPendingId(item.session.id)) {
796
- say("still provisioning — the row fills in when the workspace answers");
797
- }
798
- else if (LIVE_STATUSES.has(item.session.status)) {
799
- void attachFlow(item.session);
800
- }
801
- else {
802
- openPicker(item.session);
803
- }
804
- };
805
- useKeyboard((key) => {
806
- if (reviewing !== null)
807
- return;
808
- if (lockRef.current)
809
- return;
810
- if (key.ctrl && key.name === "c")
811
- return onQuit();
812
- if (adoptOffer !== null) {
813
- if (key.name === "return" || key.name === "linefeed" || key.name === "y") {
814
- adoptMutation.mutate(adoptOffer);
815
- }
816
- else if (key.name === "escape" || key.name === "q") {
817
- setAdoptOffer(null);
818
- say("not adopted — mend adopt does it any time");
819
- }
820
- else if (key.name === "right" ||
821
- key.name === "l" ||
822
- key.name === "tab" ||
823
- key.name === "left" ||
824
- key.name === "h") {
825
- const delta = key.name === "left" || key.name === "h" ? -1 : 1;
826
- setAdoptOffer((current) => current === null
827
- ? current
828
- : {
829
- ...current,
830
- modeIndex: (current.modeIndex + delta + ADOPT_AUTH_MODES.length) % ADOPT_AUTH_MODES.length,
831
- });
832
- }
833
- return;
834
- }
835
- if (editing !== null) {
836
- // The input owns the keyboard; only escape leaves without saving.
837
- if (key.name === "escape")
838
- setEditing(null);
839
- return;
840
- }
841
- if (creating !== null) {
842
- // Inputs own the characters (name text, base filter); this handler owns
843
- // the arrows, esc (a step back; cancel from the name), and the harness
844
- // step's enter. The name and base enters land through their input's
845
- // submit so typed-but-unflushed text never races.
846
- if (key.name === "escape") {
847
- setCreating((current) => {
848
- if (current === null)
849
- return current;
850
- if (current.step === "harness") {
851
- return { ...current, step: current.joins ? "name" : "base" };
852
- }
853
- if (current.step === "base")
854
- return { ...current, step: "name" };
855
- say("new worktree cancelled");
856
- return null;
857
- });
858
- return;
859
- }
860
- // Tab walks the steps without the enter ceremony: forward commits the
861
- // active field (the mirrored name, the highlighted branch), shift+tab
862
- // steps back, and from the harness it wraps around to the name.
863
- if (key.name === "tab" || key.name === "backtab") {
864
- const backward = key.name === "backtab" || key.shift === true;
865
- setCreating((current) => {
866
- if (current === null)
867
- return current;
868
- if (backward) {
869
- if (current.step === "harness") {
870
- return { ...current, step: current.joins ? "name" : "base" };
871
- }
872
- if (current.step === "base")
873
- return { ...current, step: "name" };
874
- return current;
875
- }
876
- if (current.step === "name")
877
- return advanceFromName(current, current.name);
878
- if (current.step === "base")
879
- return advanceFromBase(current);
880
- return { ...current, step: "name" };
881
- });
882
- return;
883
- }
884
- const delta = key.name === "down" || (key.ctrl === true && key.name === "n")
885
- ? 1
886
- : key.name === "up" || (key.ctrl === true && key.name === "p")
887
- ? -1
888
- : 0;
889
- if (delta !== 0) {
890
- setCreating((current) => {
891
- if (current === null)
892
- return current;
893
- if (current.step === "base") {
894
- const matches = filterBranches(current.branches ?? [], current.query).length;
895
- return {
896
- ...current,
897
- baseIndex: Math.max(0, Math.min(matches - 1, current.baseIndex + delta)),
898
- };
899
- }
900
- if (current.step === "harness") {
901
- const count = deriveHarnesses(null).length;
902
- return {
903
- ...current,
904
- harnessIndex: Math.max(0, Math.min(count - 1, current.harnessIndex + delta)),
905
- };
906
- }
907
- return current;
908
- });
909
- return;
910
- }
911
- if (creating.step === "harness" &&
912
- (key.name === "return" || key.name === "linefeed" || key.name === "l")) {
913
- submitCreateHarness();
914
- }
915
- return;
916
- }
917
- if (picker !== null) {
918
- switch (key.name) {
919
- case "down":
920
- case "j":
921
- return moveSelection(1);
922
- case "up":
923
- case "k":
924
- return moveSelection(-1);
925
- case "return":
926
- case "linefeed":
927
- case "l":
928
- return activate();
929
- case "escape":
930
- case "q":
931
- case "h":
932
- return setPicker(null);
933
- default:
934
- return;
935
- }
936
- }
937
- // Shift+K (and x below): stop the selected session. Lowercase k stays
938
- // vim-up; the shift is the deliberateness the arm-confirm then doubles.
939
- if (key.shift === true && key.name === "k")
940
- return armStop();
941
- // Shift+D: remove the selected worktree — the one explicit destructive act.
942
- if (key.shift === true && key.name === "d")
943
- return armRemove();
944
- switch (key.name) {
945
- case "q":
946
- return onQuit();
947
- case "down":
948
- case "j":
949
- return moveSelection(1);
950
- case "up":
951
- case "k":
952
- return moveSelection(-1);
953
- case "x":
954
- return armStop();
955
- case "return":
956
- case "linefeed":
957
- return activate();
958
- case "l":
959
- case "right":
960
- if (focus === "projects")
961
- setFocus("sessions");
962
- return;
963
- case "h":
964
- case "left":
965
- case "-":
966
- case "backspace":
967
- if (showProjectsPane)
968
- setFocus("projects");
969
- return;
970
- case "tab":
971
- if (showProjectsPane)
972
- setFocus(focus === "projects" ? "sessions" : "projects");
973
- return;
974
- case "n":
975
- // The whole creation is one modal: name, base, harness.
976
- if (selectedProject !== null)
977
- openCreateModal(selectedProject.project.id);
978
- return;
979
- case "s": {
980
- // A new conversation inside the selected worktree.
981
- const group = selectedGroup;
982
- if (group === null)
983
- return;
984
- if (group.id === null) {
985
- say("this server predates shared worktrees — n starts a new one");
986
- return;
987
- }
988
- openPicker(null, group);
989
- return;
990
- }
991
- case "e": {
992
- const item = selectedSession;
993
- if (item !== null && !isPendingId(item.session.id))
994
- setEditing(item.session);
995
- return;
996
- }
997
- case "o": {
998
- const item = selectedSession;
999
- if (item !== null && !isPendingId(item.session.id)) {
1000
- openUrl(`${ctx.config.url}/sessions/${item.session.id}`);
1001
- say(`opened · ${ctx.config.url}/sessions/${item.session.id.slice(0, 8)}…`);
1002
- }
1003
- return;
1004
- }
1005
- case "v": {
1006
- const item = selectedSession;
1007
- if (item === null)
1008
- return;
1009
- if (isPendingId(item.session.id)) {
1010
- say("still provisioning — nothing to review yet");
1011
- return;
1012
- }
1013
- const target = reviewTargetForSession(item.session, selectedGroup?.annotation ?? item.annotation, selectedProject?.project.name ?? "project");
1014
- if (target === null) {
1015
- say("this session has no reviewable change yet");
1016
- return;
1017
- }
1018
- setReviewing(target);
1019
- return;
1020
- }
1021
- case "r":
1022
- say("refreshing…");
1023
- refetch();
1024
- return;
1025
- default:
1026
- return;
1027
- }
1028
- });
1029
- // ── chrome ──
1030
- const liveTotal = projectItems.reduce((sum, item) => sum + item.live, 0);
1031
- const nameWidth = Math.min(16, Math.max(...projectItems.map((p) => p.project.name.length), 4));
1032
- const sessionsTitle = selectedProject === null
1033
- ? "sessions"
1034
- : `sessions — ${selectedProject.project.name}${selectedProject.live > 0 ? ` · ${selectedProject.live} live` : ""}`;
1035
- const detailTitle = selectedSession === null || selectedGroup === null
1036
- ? "session"
1037
- : `worktree — ${selectedGroup.name} · ${selectedSession.session.harness} ${selectedSession.session.id.slice(0, 8)}`;
1038
- const pickerTitle = picker === null
1039
- ? ""
1040
- : picker.worktree !== undefined
1041
- ? `new session in ${picker.worktree.name} — pick a harness`
1042
- : picker.session === null
1043
- ? "new session — pick a harness"
1044
- : `resume ${picker.session.id.slice(0, 8)} — pick a harness`;
1045
- // One big fixed-size modal: every step visible at once, nothing shifts as
1046
- // focus moves through name → base → harness.
1047
- const creatingHeight = 2 + 1 + 1 + 6 + 1 + deriveHarnesses(null).length;
1048
- const footerText = adoptOffer !== null
1049
- ? " enter adopt · ←→ auth mode · esc not now"
1050
- : editing !== null
1051
- ? " enter save · esc cancel"
1052
- : creating !== null
1053
- ? creating.step === "name"
1054
- ? " enter continue · esc cancel"
1055
- : creating.step === "base"
1056
- ? " type to filter · ↑↓ move · enter choose base · esc back"
1057
- : " ↑↓ move · enter launch · esc back"
1058
- : picker !== null
1059
- ? " ↑↓ move · enter start · esc cancel"
1060
- : focus === "projects"
1061
- ? " ↑↓ move · enter/l sessions · ⇥ panes · r refresh · q quit"
1062
- : " ↑↓ move · enter attach/resume · n new worktree · s session here · ⇧K kill · ⇧D remove · v review · e rename · o web · q quit";
1063
- const loadFailure = data === undefined && failureReason !== null
1064
- ? failureReason instanceof Error
1065
- ? failureReason.message
1066
- : String(failureReason)
1067
- : null;
1068
- if (reviewing !== null) {
1069
- return (_jsx(ReviewScreen, { ctx: ctx, projectName: reviewing.projectName, session: reviewing.session, changeId: reviewing.changeId, onBack: () => {
1070
- setReviewing(null);
1071
- refetch();
1072
- }, onQuit: onQuit }));
1073
- }
1074
- return (_jsxs("box", { flexGrow: 1, flexDirection: "column", backgroundColor: "transparent", children: [_jsxs("box", { height: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: INK, children: " mend" }), _jsxs("span", { fg: MUTED, children: [" ", projectItems.length, " project", projectItems.length === 1 ? "" : "s"] }), _jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsxs("span", { fg: liveTotal > 0 ? MUTED : FAINT, children: [liveTotal, " live"] }), shareState === "off" ? null : (_jsxs(_Fragment, { children: [_jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsx("span", { fg: shareState === "connected" ? MUTED : FAINT, children: shareState === "connected" ? "agent shared" : "agent share reconnecting" })] }))] }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: `${ctx.config.url} ` })] }), _jsxs("box", { flexGrow: 1, flexShrink: 1, minHeight: 0, flexDirection: "row", children: [showProjectsPane ? (_jsx(Pane, { title: "projects", focused: focus === "projects", width: PROJECTS_PANE_WIDTH, children: _jsxs("scrollbox", { ref: projectScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [projectItems.map((item, index) => (_jsx(ProjectRow, { item: item, selected: index === projectIndex, nameWidth: nameWidth }, item.project.id))), data !== undefined && projectItems.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " none adopted yet" })) : null] }) })) : null, _jsx(Pane, { title: sessionsTitle, focused: focus === "sessions", grow: true, children: _jsxs("scrollbox", { ref: sessionScrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: paneScrollStyle, children: [rows.map((row, index) => row.kind === "worktree" ? (_jsx(WorktreeHeaderRow, { group: row.group, selected: index === rowIndex }, rowKeyOf(row))) : (_jsx(SessionChildRow, { item: row.item, selected: index === rowIndex }, rowKeyOf(row)))), data !== undefined && rows.length === 0 ? (_jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: " no sessions yet — n starts one" })) : null, loadFailure !== null ? (_jsx("text", { height: 1, fg: INK_2, bg: "transparent", children: ` ${loadFailure} — retrying` })) : null] }) })] }), picker !== null ? (_jsx(Pane, { title: pickerTitle, focused: true, height: 2 + pickerItems.length, children: pickerItems.map((item, index) => (_jsx(HarnessRow, { item: item, selected: index === pickerIndex }, String(item.harness)))) })) : editing !== null ? (_jsx("box", { border: true, borderStyle: "rounded", borderColor: COBALT, title: ` label — ${editing.harness} ${editing.id.slice(0, 8)} `, titleAlignment: "left", backgroundColor: "transparent", height: 3, flexShrink: 0, children: _jsx("input", { focused: true, value: editing.label ?? "", placeholder: "a few words for what this session is doing (empty clears)", backgroundColor: "transparent", focusedBackgroundColor: "transparent", textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onSubmit: (value) => {
1075
- if (typeof value === "string")
1076
- submitRename(editing, value);
1077
- } }) })) : showDetail ? (_jsx(Pane, { title: detailTitle, focused: false, height: 6, children: _jsx(SessionDetail, { item: selectedSession }) })) : null, adoptOffer !== null ? (_jsxs("box", { position: "absolute", zIndex: 11, left: Math.max(1, Math.floor((terminalCols - Math.min(70, terminalCols - 4)) / 2)), top: Math.max(1, Math.floor((terminalRows - 7) / 2)), width: Math.min(70, terminalCols - 4), height: 7, border: true, borderStyle: "rounded", borderColor: COBALT, title: " adopt this repository? ", titleAlignment: "left", backgroundColor: SURFACE, flexDirection: "column", children: [_jsxs("text", { height: 1, bg: SURFACE, children: [_jsx("span", { children: " " }), _jsx("span", { fg: INK, children: adoptOffer.name }), _jsx("span", { fg: FAINT, children: " · not in the store yet" })] }), _jsx("text", { height: 1, bg: SURFACE, fg: MUTED, children: ` ${adoptOffer.source}` }), _jsxs("text", { height: 1, bg: SURFACE, children: [_jsx("span", { fg: FAINT, children: " auth " }), ADOPT_AUTH_MODES.map((candidate, index) => (_jsxs("span", { children: [index > 0 ? _jsx("span", { fg: FAINT, children: " · " }) : null, _jsx("span", { fg: index === adoptOffer.modeIndex ? COBALT : FAINT, children: index === adoptOffer.modeIndex ? `▸ ${candidate.label}` : candidate.label })] }, candidate.mode)))] }), _jsx("text", { height: 1, bg: SURFACE, fg: FAINT, children: ` ${ADOPT_AUTH_MODES[adoptOffer.modeIndex]?.hint ?? ""}` }), _jsx("text", { height: 1, bg: SURFACE, fg: FAINT, children: adoptOffer.source === adoptOffer.root
1078
- ? " enter adopt from this path — needs the server on THIS machine · esc not now"
1079
- : " enter adopt · ←→ auth mode · esc not now" })] })) : null, creating !== null ? (_jsxs("box", { position: "absolute", zIndex: 10, left: Math.max(1, Math.floor((terminalCols - Math.min(74, terminalCols - 4)) / 2)), top: Math.max(1, Math.floor((terminalRows - creatingHeight) / 2)), width: Math.min(74, terminalCols - 4), height: creatingHeight, border: true, borderStyle: "rounded", borderColor: COBALT, title: ` new worktree — ${selectedProject?.project.name ?? "project"} `, titleAlignment: "left", backgroundColor: SURFACE, flexDirection: "column", children: [_jsxs("box", { height: 1, flexShrink: 0, flexDirection: "row", backgroundColor: SURFACE, children: [_jsxs("text", { height: 1, bg: SURFACE, children: [_jsx(Gutter, { selected: creating.step === "name" }), _jsx("span", { fg: FAINT, children: "name " }), creating.step !== "name" ? (_jsx("span", { fg: INK, children: creating.name === "" ? "auto" : creating.name })) : null, creating.joins ? _jsx("span", { fg: FAINT, children: " · joins the existing worktree" }) : null] }), creating.step === "name" ? (_jsx("input", { focused: true, value: creating.name, placeholder: "e.g. fix-auth (empty = auto \u00B7 an existing name joins it)", backgroundColor: SURFACE, focusedBackgroundColor: SURFACE, textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onInput: (value) => {
1080
- const clean = value.replace(/\t/g, "");
1081
- setCreating((current) => current === null ? current : { ...current, name: clean });
1082
- }, onSubmit: (value) => {
1083
- if (typeof value === "string")
1084
- submitCreateName(value);
1085
- } })) : null] }), _jsxs("box", { height: 1, flexShrink: 0, flexDirection: "row", backgroundColor: SURFACE, children: [_jsxs("text", { height: 1, bg: SURFACE, children: [_jsx(Gutter, { selected: creating.step === "base" }), _jsx("span", { fg: FAINT, children: "base " }), creating.step !== "base" ? (_jsx("span", { fg: creating.step === "name" && !creating.joins ? FAINT : INK, children: creating.joins
1086
- ? "fixed by the existing worktree"
1087
- : (creating.base ?? "default branch") })) : null] }), creating.step === "base" ? (creating.branches === null ? (_jsx("text", { height: 1, bg: SURFACE, fg: FAINT, children: "reading branches\u2026" })) : (_jsx("input", { focused: true, value: "", placeholder: "type to filter (enter = highlighted; empty = default)", backgroundColor: SURFACE, focusedBackgroundColor: SURFACE, textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onInput: (value) => {
1088
- const clean = value.replace(/\t/g, "");
1089
- setCreating((current) => current === null ? current : { ...current, query: clean, baseIndex: 0 });
1090
- }, onSubmit: () => submitCreateBase() }))) : null] }), Array.from({ length: 6 }, (_, index) => {
1091
- const branch = creating.joins || creating.branches === null
1092
- ? undefined
1093
- : filterBranches(creating.branches, creating.query)[index];
1094
- const active = creating.step === "base";
1095
- if (branch === undefined) {
1096
- return (_jsx("text", { height: 1, bg: SURFACE, fg: FAINT, children: index === 0 && active && creating.branches !== null
1097
- ? " no branch matches — enter uses the default"
1098
- : " " }, `slot-${index}`));
1099
- }
1100
- const highlighted = active && index === creating.baseIndex;
1101
- return (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: highlighted ? WASH : SURFACE, children: _jsxs("text", { height: 1, bg: highlighted ? WASH : SURFACE, children: [_jsx("span", { fg: FAINT, children: " " }), _jsx(Gutter, { selected: highlighted }), _jsx("span", { fg: active ? INK : FAINT, children: branch.name.slice(0, 40).padEnd(41) }), _jsx("span", { fg: FAINT, children: branch.sha.slice(0, 8).padEnd(10) }), _jsx("span", { fg: active ? MUTED : FAINT, children: timeAgo(branch.committedAt).replace(" ago", "").padEnd(6) }), branch.isDefault ? _jsx("span", { fg: FAINT, children: "default" }) : null] }) }, branch.name));
1102
- }), _jsxs("text", { height: 1, bg: SURFACE, children: [_jsx(Gutter, { selected: creating.step === "harness" }), _jsx("span", { fg: FAINT, children: "harness" })] }), deriveHarnesses(null).map((item, index) => {
1103
- const active = creating.step === "harness";
1104
- const highlighted = active && index === creating.harnessIndex;
1105
- return (_jsx("box", { height: 1, flexShrink: 0, backgroundColor: highlighted ? WASH : SURFACE, children: _jsxs("text", { height: 1, bg: highlighted ? WASH : SURFACE, children: [_jsx("span", { fg: FAINT, children: " " }), _jsx(Gutter, { selected: highlighted }), _jsx("span", { fg: active ? INK : FAINT, children: item.label.padEnd(12) }), _jsx("span", { fg: FAINT, children: active ? item.hint : "" })] }) }, String(item.harness)));
1106
- })] })) : null, _jsx(StatusLine, { busy: busy, busyStarted: busyStarted, status: status }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: footerText })] }));
1107
- };
1108
- // ─── entry ──────────────────────────────────────────────────────────────────
1109
- export const runDashboard = async (ctx) => {
1110
- const renderer = await createCliRenderer({ exitOnCtrlC: false });
1111
- const queryClient = new QueryClient();
1112
- const controller = new AbortController();
1113
- let eventTimer = null;
1114
- const quit = () => {
1115
- controller.abort();
1116
- if (eventTimer !== null)
1117
- clearTimeout(eventTimer);
1118
- // Drop cached queries so their gc/retry timers stop pinning the event loop.
1119
- queryClient.clear();
1120
- // The canonical opentui shutdown (its own exitOnCtrlC path does exactly
1121
- // this): destroy on the next tick and let the process end on its own. A
1122
- // process.exit() here would race destroy()'s deferred finalize — the
1123
- // kitty keyboard protocol stays pushed and the shell is left eating
1124
- // ^[[..;..:.u key-release sequences.
1125
- process.nextTick(() => renderer.destroy());
1126
- };
1127
- // The SSE stream invalidates the cache from outside React. Frames are
1128
- // parsed and each pointer event stales only its query families — the
1129
- // 25-second heartbeat and per-record-line progress invalidate nothing, so
1130
- // an idle dashboard makes no requests and a busy session doesn't turn push
1131
- // into a refetch loop. The flush waits out in-flight mutations: their
1132
- // onSettled refetch converges on the same truth without a mid-write
1133
- // snapshot clobbering an optimistic row.
1134
- const pendingFamilies = new Set();
1135
- const flushInvalidations = () => {
1136
- eventTimer = null;
1137
- if (queryClient.isMutating() > 0) {
1138
- eventTimer = setTimeout(flushInvalidations, 250);
1139
- return;
1140
- }
1141
- const families = [...pendingFamilies];
1142
- pendingFamilies.clear();
1143
- for (const family of families) {
1144
- void queryClient.invalidateQueries({ queryKey: [family] });
1145
- }
1146
- };
1147
- const scheduleInvalidate = (families) => {
1148
- if (families.length === 0)
1149
- return;
1150
- for (const family of families)
1151
- pendingFamilies.add(family);
1152
- if (eventTimer === null)
1153
- eventTimer = setTimeout(flushInvalidations, 250);
1154
- };
1155
- const watch = async () => {
1156
- while (!controller.signal.aborted) {
1157
- try {
1158
- const headers = {};
1159
- if (ctx.config.token !== null)
1160
- headers["authorization"] = `Bearer ${ctx.config.token}`;
1161
- const response = await fetch(`${ctx.config.url}/api/events`, {
1162
- headers,
1163
- signal: controller.signal,
1164
- });
1165
- if (!response.ok || response.body === null)
1166
- throw new Error(String(response.status));
1167
- const reader = response.body.getReader();
1168
- const parser = createSseParser();
1169
- const decoder = new TextDecoder();
1170
- for (;;) {
1171
- const { done, value } = await reader.read();
1172
- if (done)
1173
- break;
1174
- for (const payload of parser.push(decoder.decode(value, { stream: true }))) {
1175
- scheduleInvalidate(eventFamilies(payload));
1176
- }
1177
- }
1178
- }
1179
- catch {
1180
- if (controller.signal.aborted)
1181
- return;
1182
- }
1183
- // A dropped stream may have swallowed events — re-read once on reconnect.
1184
- pendingFamilies.add("workbench").add("review");
1185
- if (eventTimer === null && !controller.signal.aborted) {
1186
- eventTimer = setTimeout(flushInvalidations, 250);
1187
- }
1188
- await new Promise((resolve) => setTimeout(resolve, 2000));
1189
- }
1190
- };
1191
- void watch();
1192
- createRoot(renderer).render(_jsx(QueryClientProvider, { client: queryClient, children: _jsx(App, { ctx: ctx, onQuit: quit }) }));
1193
- // finalizeDestroy() emits DESTROY synchronously mid-teardown; this
1194
- // continuation is a microtask, so it runs after the terminal is fully
1195
- // restored. Settling here lets main()'s top-level await finish and the
1196
- // process exit 0 naturally — no process.exit, no unsettled-await code 13.
1197
- await new Promise((resolve) => {
1198
- renderer.once(CliRenderEvents.DESTROY, () => resolve());
1199
- });
1200
- };