@sealant/mend 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +133 -6
- package/dist/chunk-4MX7ZC34.js +27894 -0
- package/dist/dashboard-YEU7SDYB.js +2951 -0
- package/dist/main.js +6917 -2580
- package/man/mend-accounts.1 +1 -1
- package/man/mend-adopt.1 +4 -4
- package/man/mend-attach.1 +1 -1
- package/man/mend-codex.1 +1 -1
- package/man/mend-completions.1 +1 -1
- package/man/mend-connect.1 +1 -1
- package/man/mend-continue.1 +1 -1
- package/man/mend-doctor.1 +1 -1
- package/man/mend-dotfiles-sync.1 +1 -1
- package/man/mend-dotfiles.1 +1 -1
- package/man/mend-env-cluster.1 +1 -1
- package/man/mend-env-load.1 +1 -1
- package/man/mend-env-show.1 +1 -1
- package/man/mend-help.1 +1 -1
- package/man/mend-keys-init.1 +1 -1
- package/man/mend-keys-mode.1 +1 -1
- package/man/mend-keys-share.1 +1 -1
- package/man/mend-keys-show.1 +1 -1
- package/man/mend-login.1 +1 -1
- package/man/mend-logout.1 +1 -1
- package/man/mend-man.1 +1 -1
- package/man/mend-pair.1 +1 -1
- package/man/mend-projects.1 +1 -1
- package/man/mend-refresh.1 +1 -1
- package/man/mend-rejoin.1 +1 -1
- package/man/mend-resume.1 +1 -1
- package/man/mend-run.1 +1 -1
- package/man/mend-server-logs.1 +15 -0
- package/man/mend-server-restart.1 +15 -0
- package/man/mend-server-setup.1 +64 -0
- package/man/mend-server-start.1 +15 -0
- package/man/mend-server-status.1 +11 -0
- package/man/mend-server-stop.1 +11 -0
- package/man/mend-server-upgrade.1 +30 -0
- package/man/mend-server.1 +11 -0
- package/man/mend-service-add.1 +1 -1
- package/man/mend-service-connect.1 +1 -1
- package/man/mend-service-init.1 +1 -1
- package/man/mend-service-list.1 +1 -1
- package/man/mend-service-logs.1 +1 -1
- package/man/mend-service-restart.1 +1 -1
- package/man/mend-service-run.1 +1 -1
- package/man/mend-service-stop.1 +1 -1
- package/man/mend-sessions.1 +1 -1
- package/man/mend-shell.1 +1 -1
- package/man/mend-skills-push.1 +1 -1
- package/man/mend-skills.1 +1 -1
- package/man/mend-ssh-setup.1 +6 -3
- package/man/mend-ssh.1 +1 -1
- package/man/mend-stop.1 +1 -1
- package/man/mend-ui.1 +1 -1
- package/man/mend-version.1 +1 -1
- package/man/mend-worktrees.1 +1 -1
- package/man/mend.1 +26 -2
- package/package.json +7 -3
- package/dist/agent-share.js +0 -150
- package/dist/clipboard.js +0 -73
- package/dist/dashboard-model.js +0 -308
- package/dist/dashboard.js +0 -1200
- package/dist/doctor.js +0 -233
- package/dist/dotfiles.js +0 -90
- package/dist/env.js +0 -17
- package/dist/help.js +0 -839
- package/dist/login.js +0 -240
- package/dist/pair.js +0 -83
- package/dist/review-model.js +0 -212
- package/dist/review-workflow.js +0 -8
- package/dist/review.js +0 -690
- package/dist/service-init.js +0 -227
- package/dist/shared.js +0 -338
- package/dist/skills.js +0 -134
- package/dist/ssh-setup.js +0 -181
- package/dist/terminal.js +0 -8
- package/dist/tui-theme.js +0 -41
- package/dist/version.js +0 -48
- package/dist/workbench-events.js +0 -72
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
|
-
};
|