@sealant/mend 0.1.0 → 0.1.1
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 +21 -3
- package/dist/dashboard.js +546 -0
- package/dist/main.js +409 -90
- package/dist/review-model.js +212 -0
- package/dist/review-workflow.js +15 -0
- package/dist/review.js +559 -0
- package/dist/shared.js +48 -0
- package/dist/terminal.js +8 -0
- package/dist/tui-theme.js +25 -0
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -11,6 +11,12 @@ npm install -g @sealant/mend
|
|
|
11
11
|
|
|
12
12
|
Requires Node 22+ and a running Mend server.
|
|
13
13
|
|
|
14
|
+
Run bare `mend` for the interactive dashboard. Highlight a session and press `v` to review its
|
|
15
|
+
accumulated change in the terminal: navigate files and hunks, switch between unified and split
|
|
16
|
+
diffs, reveal whitespace, select line ranges, add line or whole-change comments, inspect linked
|
|
17
|
+
evidence, and draft a follow-up for the same session. Press `y` to deliver a pending follow-up and
|
|
18
|
+
relaunch that session, or `o` to continue the review in the web app.
|
|
19
|
+
|
|
14
20
|
## Commands
|
|
15
21
|
|
|
16
22
|
```
|
|
@@ -20,7 +26,9 @@ mend run -- <command...> same, with an arbitrary command
|
|
|
20
26
|
mend attach <session-id-prefix> reattach this terminal to a running session
|
|
21
27
|
mend continue [session-id] resume a session with its pending review follow-up
|
|
22
28
|
mend resume [session-id] [--with h] rejoin a settled session (state restored; --with switches harness)
|
|
23
|
-
mend
|
|
29
|
+
mend rejoin [session-id] [--harness h] attach if live, otherwise resume; newest live wins
|
|
30
|
+
mend sessions [--all] [--project p] [--json]
|
|
31
|
+
mend status active sessions (alias of mend sessions)
|
|
24
32
|
```
|
|
25
33
|
|
|
26
34
|
## Configuration
|
|
@@ -29,8 +37,17 @@ mend status active sessions
|
|
|
29
37
|
| ------------------ | ------------------------------------------------- |
|
|
30
38
|
| `MEND_URL` | The Mend server (default `http://localhost:3105`) |
|
|
31
39
|
| `MEND_TOKEN` | Bearer token for that server |
|
|
40
|
+
| `MEND_DETACH_KEY` | Set to `none` when an outer multiplexer detaches |
|
|
32
41
|
| `~/.mend/cli.json` | `{ "url": ..., "token": ... }` — env vars win |
|
|
33
42
|
|
|
43
|
+
## Herdr
|
|
44
|
+
|
|
45
|
+
When Mend attaches Codex, Claude Code, or OpenCode inside a Herdr pane, it reports that harness to
|
|
46
|
+
Herdr for the lifetime of the attachment. The session therefore appears in Herdr's Agents sidebar
|
|
47
|
+
whether it was started from the `mend` dashboard or a one-shot CLI command. Mend supplies Herdr's
|
|
48
|
+
foreground-process hint rather than claiming lifecycle authority, so Herdr continues to derive
|
|
49
|
+
working, idle, and blocked from its native agent screen rules.
|
|
50
|
+
|
|
34
51
|
## How it works
|
|
35
52
|
|
|
36
53
|
Every session runs in its own git worktree over the adopted repository, inside a recorded workspace.
|
|
@@ -40,5 +57,6 @@ can reach the server. When a session settles, Mend harvests the harness's own st
|
|
|
40
57
|
so `mend resume` restores it natively (a Claude session resumes with its memory intact, even across
|
|
41
58
|
machines), and `--with` carries the conversation into a different harness.
|
|
42
59
|
|
|
43
|
-
The reviewable object is the session's accumulated local change — worktree versus base —
|
|
44
|
-
|
|
60
|
+
The reviewable object is the session's accumulated local change — worktree versus base — with the
|
|
61
|
+
session record beside it. Review it in the terminal dashboard or the Mend web app. No issue tracker
|
|
62
|
+
or pull request required.
|
|
@@ -0,0 +1,546 @@
|
|
|
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, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
5
|
+
import { useEffect, useRef, useState } from "react";
|
|
6
|
+
import { reviewTargetForSession } from "./review-workflow.js";
|
|
7
|
+
import { ReviewScreen } from "./review.js";
|
|
8
|
+
import { HARNESS_COMMANDS, LIVE_STATUSES, matchProjectByCwd } from "./shared.js";
|
|
9
|
+
import { openUrl } from "./terminal.js";
|
|
10
|
+
import { AMBER, BG, COBALT, FAINT, GREEN, INK, MUTED, RED, RULE, WASH } from "./tui-theme.js";
|
|
11
|
+
const STATUS_GLYPH = {
|
|
12
|
+
starting: "○",
|
|
13
|
+
running: "●",
|
|
14
|
+
waiting: "●",
|
|
15
|
+
idle: "●",
|
|
16
|
+
completed: "●",
|
|
17
|
+
failed: "●",
|
|
18
|
+
stopped: "○",
|
|
19
|
+
};
|
|
20
|
+
const STATUS_COLOR = {
|
|
21
|
+
starting: COBALT,
|
|
22
|
+
running: COBALT,
|
|
23
|
+
waiting: AMBER,
|
|
24
|
+
idle: COBALT,
|
|
25
|
+
completed: GREEN,
|
|
26
|
+
failed: RED,
|
|
27
|
+
stopped: FAINT,
|
|
28
|
+
};
|
|
29
|
+
const timeAgo = (iso) => {
|
|
30
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
31
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
32
|
+
return "";
|
|
33
|
+
const minutes = Math.floor(ms / 60_000);
|
|
34
|
+
if (minutes < 1)
|
|
35
|
+
return "just now";
|
|
36
|
+
if (minutes < 60)
|
|
37
|
+
return `${minutes}m ago`;
|
|
38
|
+
const hours = Math.floor(minutes / 60);
|
|
39
|
+
if (hours < 24)
|
|
40
|
+
return `${hours}h ago`;
|
|
41
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
42
|
+
};
|
|
43
|
+
const WORKBENCH_KEY = ["workbench"];
|
|
44
|
+
const fetchWorkbench = async (ctx) => {
|
|
45
|
+
const projects = await ctx.api("GET", "/projects");
|
|
46
|
+
const fetched = await Promise.all(projects.map((project) => ctx.api("GET", `/projects/${project.id}`)));
|
|
47
|
+
return { projects, details: new Map(fetched.map((detail) => [detail.project.id, detail])) };
|
|
48
|
+
};
|
|
49
|
+
const itemKey = (item) => item.kind === "session"
|
|
50
|
+
? item.session.id
|
|
51
|
+
: item.kind === "project"
|
|
52
|
+
? item.project.id
|
|
53
|
+
: String(item.harness);
|
|
54
|
+
const itemHeight = (view) => (view.kind === "sessions" ? 2 : 1);
|
|
55
|
+
const homeView = (cwd, data) => {
|
|
56
|
+
const home = matchProjectByCwd(data.projects, cwd);
|
|
57
|
+
return home === undefined ? { kind: "projects" } : { kind: "sessions", projectId: home.id };
|
|
58
|
+
};
|
|
59
|
+
const deriveItems = (view, data) => {
|
|
60
|
+
if (data === undefined)
|
|
61
|
+
return [];
|
|
62
|
+
if (view.kind === "projects") {
|
|
63
|
+
return data.projects.map((project) => {
|
|
64
|
+
const detail = data.details.get(project.id);
|
|
65
|
+
const sessions = detail?.sessions ?? [];
|
|
66
|
+
const live = sessions.filter((s) => LIVE_STATUSES.has(s.status)).length;
|
|
67
|
+
const open = (detail?.annotations ?? []).reduce((sum, a) => sum + a.openComments, 0);
|
|
68
|
+
return { kind: "project", project, total: sessions.length, live, open };
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (view.kind === "sessions") {
|
|
72
|
+
const detail = data.details.get(view.projectId);
|
|
73
|
+
if (detail === undefined)
|
|
74
|
+
return [];
|
|
75
|
+
return detail.sessions
|
|
76
|
+
.toSorted((a, b) => {
|
|
77
|
+
const aLive = LIVE_STATUSES.has(a.status) ? 1 : 0;
|
|
78
|
+
const bLive = LIVE_STATUSES.has(b.status) ? 1 : 0;
|
|
79
|
+
if (aLive !== bLive)
|
|
80
|
+
return bLive - aLive;
|
|
81
|
+
return b.createdAt.localeCompare(a.createdAt);
|
|
82
|
+
})
|
|
83
|
+
.map((session) => ({
|
|
84
|
+
kind: "session",
|
|
85
|
+
session,
|
|
86
|
+
annotation: detail.annotations.find((a) => a.sessionId === session.id),
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
const resuming = view.session;
|
|
90
|
+
if (resuming !== null) {
|
|
91
|
+
const others = Object.keys(HARNESS_COMMANDS).filter((h) => h !== resuming.harness);
|
|
92
|
+
return [
|
|
93
|
+
{
|
|
94
|
+
kind: "harness",
|
|
95
|
+
harness: null,
|
|
96
|
+
label: resuming.harness,
|
|
97
|
+
hint: "same harness — native resume, conversation intact",
|
|
98
|
+
},
|
|
99
|
+
...others.map((harness) => ({
|
|
100
|
+
kind: "harness",
|
|
101
|
+
harness,
|
|
102
|
+
label: harness,
|
|
103
|
+
hint: "the conversation crosses as a distilled prompt",
|
|
104
|
+
})),
|
|
105
|
+
];
|
|
106
|
+
}
|
|
107
|
+
return Object.keys(HARNESS_COMMANDS).map((harness) => ({
|
|
108
|
+
kind: "harness",
|
|
109
|
+
harness,
|
|
110
|
+
label: harness,
|
|
111
|
+
hint: `mend ${harness} — new worktree, recorded session`,
|
|
112
|
+
}));
|
|
113
|
+
};
|
|
114
|
+
// ─── rows: JSX spans instead of StyledText chunks ───────────────────────────
|
|
115
|
+
const Gutter = ({ selected }) => (_jsx("span", { fg: selected ? COBALT : FAINT, children: selected ? "▌ " : " " }));
|
|
116
|
+
/** Facts joined with dim separators — label, review state, age. */
|
|
117
|
+
const Facts = ({ facts }) => {
|
|
118
|
+
if (facts.length === 0)
|
|
119
|
+
return _jsx("span", { fg: FAINT, children: "\u2014" });
|
|
120
|
+
return (_jsx(_Fragment, { children: facts.map((fact, index) => (
|
|
121
|
+
// eslint-disable-next-line react/no-array-index-key -- order is the identity here
|
|
122
|
+
_jsxs("span", { children: [index > 0 ? _jsx("span", { fg: FAINT, children: " \u00B7 " }) : null, fact] }, index))) }));
|
|
123
|
+
};
|
|
124
|
+
const SessionRow = ({ item, selected, }) => {
|
|
125
|
+
const { session, annotation } = item;
|
|
126
|
+
const color = STATUS_COLOR[session.status] ?? MUTED;
|
|
127
|
+
const glyph = STATUS_GLYPH[session.status] ?? "·";
|
|
128
|
+
const facts = [];
|
|
129
|
+
const age = timeAgo(session.createdAt);
|
|
130
|
+
if (age !== "")
|
|
131
|
+
facts.push(_jsx("span", { fg: FAINT, children: age }));
|
|
132
|
+
if (session.label !== null)
|
|
133
|
+
facts.push(_jsx("span", { fg: MUTED, children: session.label }));
|
|
134
|
+
if (annotation !== undefined && annotation.openComments > 0) {
|
|
135
|
+
facts.push(_jsxs("span", { fg: AMBER, children: [annotation.openComments, " open comment", annotation.openComments === 1 ? "" : "s"] }));
|
|
136
|
+
}
|
|
137
|
+
if (annotation !== undefined && annotation.pendingFollowUp) {
|
|
138
|
+
facts.push(_jsx("span", { fg: AMBER, children: "follow-up pending" }));
|
|
139
|
+
}
|
|
140
|
+
if (session.summary !== null && facts.length < 3) {
|
|
141
|
+
facts.push(_jsx("span", { fg: FAINT, children: session.summary.split("\n")[0]?.slice(0, 60) ?? "" }));
|
|
142
|
+
}
|
|
143
|
+
return (_jsxs("box", { height: 2, flexDirection: "column", flexShrink: 0, backgroundColor: selected ? WASH : "transparent", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsxs("span", { fg: color, children: [glyph, " "] }), _jsx("span", { fg: INK, children: session.harness.padEnd(9) }), _jsx("span", { fg: FAINT, children: session.id.slice(0, 8) }), _jsx("span", { fg: color, children: ` ${session.status.padEnd(10)}` }), _jsx("span", { fg: MUTED, children: session.branch })] }), _jsxs("text", { height: 1, bg: "transparent", children: [_jsx(Gutter, { selected: selected }), _jsx("span", { children: " " }), _jsx(Facts, { facts: facts })] })] }));
|
|
144
|
+
};
|
|
145
|
+
const ProjectRow = ({ item, selected, nameWidth, branchWidth, }) => {
|
|
146
|
+
const { project, total, live, open } = item;
|
|
147
|
+
const counts = total === 0 ? "no sessions" : `${total} session${total === 1 ? "" : "s"}`;
|
|
148
|
+
const liveText = live > 0 ? ` · ${live} live` : "";
|
|
149
|
+
const openText = open > 0 ? ` · ${open} open` : "";
|
|
150
|
+
const pad = " ".repeat(Math.max(1, 24 - counts.length - liveText.length - openText.length));
|
|
151
|
+
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: project.name.padEnd(nameWidth) }), _jsx("span", { children: " " }), _jsx("span", { fg: MUTED, children: project.defaultBranch.padEnd(branchWidth) }), _jsx("span", { children: " " }), _jsx("span", { fg: total === 0 ? FAINT : MUTED, children: counts }), liveText === "" ? null : _jsx("span", { fg: COBALT, children: liveText }), openText === "" ? null : _jsx("span", { fg: AMBER, children: openText }), _jsxs("span", { fg: FAINT, children: [pad, project.storePath] })] }) }));
|
|
152
|
+
};
|
|
153
|
+
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 })] }) }));
|
|
154
|
+
const StatusLine = ({ busy, busyStarted, status, }) => {
|
|
155
|
+
const [now, setNow] = useState(() => Date.now());
|
|
156
|
+
// Timers are the one thing React cannot express declaratively: a 1s tick
|
|
157
|
+
// while busy, and one repaint to clear an expired status message.
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
if (busy !== null) {
|
|
160
|
+
const timer = setInterval(() => setNow(Date.now()), 1000);
|
|
161
|
+
return () => clearInterval(timer);
|
|
162
|
+
}
|
|
163
|
+
if (status !== null) {
|
|
164
|
+
const timer = setTimeout(() => setNow(Date.now()), 5100);
|
|
165
|
+
return () => clearTimeout(timer);
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}, [busy, status]);
|
|
169
|
+
const text = busy !== null
|
|
170
|
+
? ` ${busy} ${Math.max(0, Math.round((now - busyStarted) / 1000))}s`
|
|
171
|
+
: status !== null && Date.now() - status.at < 5000
|
|
172
|
+
? ` ${status.text}`
|
|
173
|
+
: "";
|
|
174
|
+
return (_jsx("text", { height: 1, fg: AMBER, bg: "transparent", children: text }));
|
|
175
|
+
};
|
|
176
|
+
// ─── the app ────────────────────────────────────────────────────────────────
|
|
177
|
+
const CHROME_ROWS = 7; // margin + header + margin + 2 borders + status + footer
|
|
178
|
+
const App = ({ ctx, onQuit }) => {
|
|
179
|
+
const renderer = useRenderer();
|
|
180
|
+
const queryClient = useQueryClient();
|
|
181
|
+
const { data, failureReason } = useQuery({
|
|
182
|
+
queryKey: WORKBENCH_KEY,
|
|
183
|
+
queryFn: () => fetchWorkbench(ctx),
|
|
184
|
+
// A dev server mid-restart is normal: keep trying, once a second.
|
|
185
|
+
retry: true,
|
|
186
|
+
retryDelay: 1000,
|
|
187
|
+
});
|
|
188
|
+
const [navigated, setNavigated] = useState(null);
|
|
189
|
+
const view = navigated ?? (data === undefined ? { kind: "projects" } : homeView(ctx.cwd, data));
|
|
190
|
+
const [selectedKey, setSelectedKey] = useState(null);
|
|
191
|
+
const [busy, setBusy] = useState(null);
|
|
192
|
+
const [busyStarted, setBusyStarted] = useState(0);
|
|
193
|
+
const [status, setStatus] = useState(null);
|
|
194
|
+
const [editing, setEditing] = useState(null);
|
|
195
|
+
const [reviewing, setReviewing] = useState(null);
|
|
196
|
+
/** Set synchronously around attach/launch so keystrokes can't double-fire. */
|
|
197
|
+
const lockRef = useRef(false);
|
|
198
|
+
const items = deriveItems(view, data);
|
|
199
|
+
const foundIndex = selectedKey === null ? -1 : items.findIndex((i) => itemKey(i) === selectedKey);
|
|
200
|
+
const selectedIndex = foundIndex === -1 ? 0 : foundIndex;
|
|
201
|
+
const say = (text) => setStatus({ text, at: Date.now() });
|
|
202
|
+
const refetch = () => void queryClient.invalidateQueries({ queryKey: WORKBENCH_KEY });
|
|
203
|
+
const setView = (next) => {
|
|
204
|
+
setNavigated(next);
|
|
205
|
+
setSelectedKey(null);
|
|
206
|
+
};
|
|
207
|
+
// Keep the selection on screen — the one imperative escape hatch.
|
|
208
|
+
const scrollRef = useRef(null);
|
|
209
|
+
const { height: terminalRows } = useTerminalDimensions();
|
|
210
|
+
const rowH = itemHeight(view);
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
const scroll = scrollRef.current;
|
|
213
|
+
if (scroll === null)
|
|
214
|
+
return;
|
|
215
|
+
const top = selectedIndex * rowH;
|
|
216
|
+
const viewH = Math.max(1, terminalRows - CHROME_ROWS - (editing === null ? 0 : 3));
|
|
217
|
+
if (top < scroll.scrollTop)
|
|
218
|
+
scroll.scrollTo(top);
|
|
219
|
+
else if (top + rowH > scroll.scrollTop + viewH)
|
|
220
|
+
scroll.scrollTo(top + rowH - viewH);
|
|
221
|
+
}, [selectedIndex, rowH, terminalRows, editing]);
|
|
222
|
+
const beginBusy = (label) => {
|
|
223
|
+
lockRef.current = true;
|
|
224
|
+
setBusy(label);
|
|
225
|
+
setBusyStarted(Date.now());
|
|
226
|
+
};
|
|
227
|
+
const endBusy = () => {
|
|
228
|
+
lockRef.current = false;
|
|
229
|
+
setBusy(null);
|
|
230
|
+
};
|
|
231
|
+
const attachFlow = async (session) => {
|
|
232
|
+
const short = session.id.slice(0, 8);
|
|
233
|
+
lockRef.current = true;
|
|
234
|
+
renderer.suspend();
|
|
235
|
+
process.stdout.write(`\nattached · ${session.harness} · ${short} · detach: Ctrl+]\n\n`);
|
|
236
|
+
let outcome;
|
|
237
|
+
try {
|
|
238
|
+
outcome = await ctx.attachTty(session.id, session.harness);
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
say(error instanceof Error ? error.message : String(error));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
finally {
|
|
245
|
+
renderer.resume();
|
|
246
|
+
lockRef.current = false;
|
|
247
|
+
}
|
|
248
|
+
say(outcome === "unavailable"
|
|
249
|
+
? "attach unavailable — could not connect"
|
|
250
|
+
: outcome === "detached"
|
|
251
|
+
? `detached — ${short} keeps running`
|
|
252
|
+
: `session settled · ${short}`);
|
|
253
|
+
refetch();
|
|
254
|
+
};
|
|
255
|
+
const startSession = async (projectId, harness) => {
|
|
256
|
+
const argv = HARNESS_COMMANDS[harness];
|
|
257
|
+
if (argv === undefined)
|
|
258
|
+
return;
|
|
259
|
+
setView({ kind: "sessions", projectId });
|
|
260
|
+
beginBusy(`provisioning ${harness} workspace — a first launch builds the harness image ·`);
|
|
261
|
+
try {
|
|
262
|
+
const session = await ctx.api("POST", `/projects/${projectId}/sessions`, {
|
|
263
|
+
harness,
|
|
264
|
+
label: null,
|
|
265
|
+
base: null,
|
|
266
|
+
});
|
|
267
|
+
await ctx.api("POST", `/sessions/${session.id}/launch`, { argv });
|
|
268
|
+
endBusy();
|
|
269
|
+
await attachFlow(session);
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
endBusy();
|
|
273
|
+
say(error instanceof Error ? error.message : String(error));
|
|
274
|
+
}
|
|
275
|
+
refetch();
|
|
276
|
+
};
|
|
277
|
+
const resumeSession = async (projectId, session, harness) => {
|
|
278
|
+
setView({ kind: "sessions", projectId });
|
|
279
|
+
setSelectedKey(session.id);
|
|
280
|
+
beginBusy(`resuming ${session.id.slice(0, 8)} — a fresh workspace restores the saved state ·`);
|
|
281
|
+
try {
|
|
282
|
+
const resumed = await ctx.api("POST", `/sessions/${session.id}/resume`, {
|
|
283
|
+
harness,
|
|
284
|
+
});
|
|
285
|
+
endBusy();
|
|
286
|
+
await attachFlow(resumed);
|
|
287
|
+
}
|
|
288
|
+
catch (error) {
|
|
289
|
+
endBusy();
|
|
290
|
+
say(error instanceof Error ? error.message : String(error));
|
|
291
|
+
}
|
|
292
|
+
refetch();
|
|
293
|
+
};
|
|
294
|
+
const submitRename = async (session, value) => {
|
|
295
|
+
setEditing(null);
|
|
296
|
+
const label = value.trim() === "" ? null : value.trim();
|
|
297
|
+
try {
|
|
298
|
+
await ctx.api("POST", `/sessions/${session.id}/label`, { label });
|
|
299
|
+
say(label === null ? `label cleared · ${session.id.slice(0, 8)}` : `labeled · ${label}`);
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
say(error instanceof Error ? error.message : String(error));
|
|
303
|
+
}
|
|
304
|
+
refetch();
|
|
305
|
+
};
|
|
306
|
+
const moveSelection = (delta) => {
|
|
307
|
+
if (items.length === 0)
|
|
308
|
+
return;
|
|
309
|
+
const next = Math.max(0, Math.min(items.length - 1, selectedIndex + delta));
|
|
310
|
+
const item = items[next];
|
|
311
|
+
if (item !== undefined)
|
|
312
|
+
setSelectedKey(itemKey(item));
|
|
313
|
+
};
|
|
314
|
+
const activate = () => {
|
|
315
|
+
const item = items[selectedIndex];
|
|
316
|
+
if (item === undefined)
|
|
317
|
+
return;
|
|
318
|
+
if (item.kind === "project") {
|
|
319
|
+
setView({ kind: "sessions", projectId: item.project.id });
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
if (item.kind === "session" && view.kind === "sessions") {
|
|
323
|
+
if (LIVE_STATUSES.has(item.session.status)) {
|
|
324
|
+
void attachFlow(item.session);
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
setView({ kind: "picker", projectId: view.projectId, session: item.session });
|
|
328
|
+
}
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (item.kind === "harness" && view.kind === "picker") {
|
|
332
|
+
const { projectId, session } = view;
|
|
333
|
+
if (session === null) {
|
|
334
|
+
if (item.harness !== null)
|
|
335
|
+
void startSession(projectId, item.harness);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
void resumeSession(projectId, session, item.harness);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
/** `-`, backspace, or vim `h`: one level up. */
|
|
343
|
+
const goUp = () => {
|
|
344
|
+
if (view.kind === "sessions")
|
|
345
|
+
setView({ kind: "projects" });
|
|
346
|
+
if (view.kind === "picker")
|
|
347
|
+
setView({ kind: "sessions", projectId: view.projectId });
|
|
348
|
+
};
|
|
349
|
+
useKeyboard((key) => {
|
|
350
|
+
if (reviewing !== null)
|
|
351
|
+
return;
|
|
352
|
+
if (lockRef.current)
|
|
353
|
+
return;
|
|
354
|
+
if (key.ctrl && key.name === "c")
|
|
355
|
+
return onQuit();
|
|
356
|
+
if (editing !== null) {
|
|
357
|
+
// The input owns the keyboard; only escape leaves without saving.
|
|
358
|
+
if (key.name === "escape")
|
|
359
|
+
setEditing(null);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
switch (key.name) {
|
|
363
|
+
case "q":
|
|
364
|
+
return onQuit();
|
|
365
|
+
case "down":
|
|
366
|
+
case "j":
|
|
367
|
+
return moveSelection(1);
|
|
368
|
+
case "up":
|
|
369
|
+
case "k":
|
|
370
|
+
return moveSelection(-1);
|
|
371
|
+
case "return":
|
|
372
|
+
case "linefeed":
|
|
373
|
+
case "l":
|
|
374
|
+
return activate();
|
|
375
|
+
case "-":
|
|
376
|
+
case "backspace":
|
|
377
|
+
case "h":
|
|
378
|
+
return goUp();
|
|
379
|
+
case "escape":
|
|
380
|
+
if (view.kind === "picker")
|
|
381
|
+
setView({ kind: "sessions", projectId: view.projectId });
|
|
382
|
+
return;
|
|
383
|
+
case "n":
|
|
384
|
+
if (view.kind === "sessions") {
|
|
385
|
+
setView({ kind: "picker", projectId: view.projectId, session: null });
|
|
386
|
+
}
|
|
387
|
+
return;
|
|
388
|
+
case "e": {
|
|
389
|
+
const item = items[selectedIndex];
|
|
390
|
+
if (view.kind === "sessions" && item !== undefined && item.kind === "session") {
|
|
391
|
+
setEditing(item.session);
|
|
392
|
+
}
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
case "o": {
|
|
396
|
+
const item = items[selectedIndex];
|
|
397
|
+
if (item !== undefined && item.kind === "session") {
|
|
398
|
+
openUrl(`${ctx.config.url}/sessions/${item.session.id}`);
|
|
399
|
+
say(`opened · ${ctx.config.url}/sessions/${item.session.id.slice(0, 8)}…`);
|
|
400
|
+
}
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
case "v": {
|
|
404
|
+
const item = items[selectedIndex];
|
|
405
|
+
if (view.kind !== "sessions" || item === undefined || item.kind !== "session")
|
|
406
|
+
return;
|
|
407
|
+
const target = reviewTargetForSession(item.session, item.annotation, detail?.project.name ?? "project");
|
|
408
|
+
if (target === null) {
|
|
409
|
+
say("this session has no reviewable change yet");
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
setReviewing(target);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
case "r":
|
|
416
|
+
say("refreshing…");
|
|
417
|
+
refetch();
|
|
418
|
+
return;
|
|
419
|
+
default:
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
// ── chrome derived from the view ──
|
|
424
|
+
const projects = data?.projects ?? [];
|
|
425
|
+
const liveTotal = data === undefined
|
|
426
|
+
? 0
|
|
427
|
+
: [...data.details.values()]
|
|
428
|
+
.flatMap((d) => d.sessions)
|
|
429
|
+
.filter((s) => LIVE_STATUSES.has(s.status)).length;
|
|
430
|
+
const detail = view.kind === "projects" ? undefined : data?.details.get(view.projectId);
|
|
431
|
+
const nameWidth = Math.max(...projects.map((p) => p.name.length), 4);
|
|
432
|
+
const branchWidth = Math.max(...projects.map((p) => p.defaultBranch.length), 4);
|
|
433
|
+
let title;
|
|
434
|
+
let headerContext;
|
|
435
|
+
let footerText;
|
|
436
|
+
if (view.kind === "projects") {
|
|
437
|
+
title = " projects ";
|
|
438
|
+
headerContext = (_jsxs(_Fragment, { children: [_jsxs("span", { fg: MUTED, children: [projects.length, " project", projects.length === 1 ? "" : "s"] }), _jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsxs("span", { fg: liveTotal > 0 ? COBALT : FAINT, children: [liveTotal, " live"] })] }));
|
|
439
|
+
footerText = " ↑↓/jk move · enter/l open project · r refresh · q quit";
|
|
440
|
+
}
|
|
441
|
+
else if (view.kind === "sessions") {
|
|
442
|
+
const name = detail?.project.name ?? "?";
|
|
443
|
+
const live = (detail?.sessions ?? []).filter((s) => LIVE_STATUSES.has(s.status)).length;
|
|
444
|
+
title = ` sessions — ${name} `;
|
|
445
|
+
headerContext = (_jsxs(_Fragment, { children: [_jsx("span", { fg: COBALT, children: name }), _jsxs("span", { fg: FAINT, children: [" ", detail?.project.defaultBranch ?? ""] }), _jsx("span", { fg: FAINT, children: " \u00B7 " }), _jsxs("span", { fg: live > 0 ? COBALT : FAINT, children: [live, " live"] })] }));
|
|
446
|
+
footerText =
|
|
447
|
+
" ↑↓/jk move · enter/l attach/resume · v review in TUI · o web · n new · e rename · h/- projects · q quit";
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
const forSession = view.session;
|
|
451
|
+
title =
|
|
452
|
+
forSession === null
|
|
453
|
+
? " new session — pick a harness "
|
|
454
|
+
: ` resume ${forSession.id.slice(0, 8)} — pick a harness `;
|
|
455
|
+
headerContext = (_jsx("span", { fg: MUTED, children: forSession === null
|
|
456
|
+
? "a fresh worktree, recorded from the first keystroke"
|
|
457
|
+
: "same worktree, restored state" }));
|
|
458
|
+
footerText = " ↑↓ move · enter start · esc/h back";
|
|
459
|
+
}
|
|
460
|
+
const loadFailure = data === undefined && failureReason !== null
|
|
461
|
+
? failureReason instanceof Error
|
|
462
|
+
? failureReason.message
|
|
463
|
+
: String(failureReason)
|
|
464
|
+
: null;
|
|
465
|
+
if (reviewing !== null) {
|
|
466
|
+
return (_jsx(ReviewScreen, { ctx: ctx, projectName: reviewing.projectName, session: reviewing.session, changeId: reviewing.changeId, onBack: () => {
|
|
467
|
+
setReviewing(null);
|
|
468
|
+
refetch();
|
|
469
|
+
}, onQuit: onQuit }));
|
|
470
|
+
}
|
|
471
|
+
return (_jsxs("box", { flexGrow: 1, flexDirection: "column", backgroundColor: BG, children: [_jsxs("box", { height: 1, marginTop: 1, flexDirection: "row", justifyContent: "space-between", backgroundColor: "transparent", children: [_jsxs("text", { height: 1, bg: "transparent", children: [_jsx("span", { fg: INK, children: " mend" }), _jsx("span", { children: " " }), headerContext] }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: `${ctx.config.url} ` })] }), _jsx("box", { border: true, borderStyle: "rounded", borderColor: RULE, title: title, titleAlignment: "left", backgroundColor: BG, flexGrow: 1, flexShrink: 1, minHeight: 0, marginTop: 1, children: _jsxs("scrollbox", { ref: scrollRef, flexGrow: 1, flexShrink: 1, minHeight: 0, style: {
|
|
472
|
+
rootOptions: { backgroundColor: BG, border: false },
|
|
473
|
+
wrapperOptions: { backgroundColor: BG },
|
|
474
|
+
viewportOptions: { backgroundColor: BG },
|
|
475
|
+
contentOptions: { backgroundColor: BG },
|
|
476
|
+
}, children: [items.map((item, index) => item.kind === "session" ? (_jsx(SessionRow, { item: item, selected: index === selectedIndex }, itemKey(item))) : item.kind === "project" ? (_jsx(ProjectRow, { item: item, selected: index === selectedIndex, nameWidth: nameWidth, branchWidth: branchWidth }, itemKey(item))) : (_jsx(HarnessRow, { item: item, selected: index === selectedIndex }, itemKey(item)))), view.kind === "sessions" && data !== undefined && items.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: AMBER, bg: "transparent", children: ` ${loadFailure} — retrying` })) : null] }) }), editing === null ? null : (_jsx("box", { border: true, borderStyle: "rounded", borderColor: COBALT, title: ` label — ${editing.harness} ${editing.id.slice(0, 8)} `, titleAlignment: "left", backgroundColor: BG, 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: BG, focusedBackgroundColor: BG, textColor: INK, focusedTextColor: INK, placeholderColor: FAINT, cursorColor: INK, flexGrow: 1, onSubmit: (value) => {
|
|
477
|
+
if (typeof value === "string")
|
|
478
|
+
void submitRename(editing, value);
|
|
479
|
+
} }) })), _jsx(StatusLine, { busy: busy, busyStarted: busyStarted, status: status }), _jsx("text", { height: 1, fg: FAINT, bg: "transparent", children: editing === null ? footerText : " enter save · esc cancel" })] }));
|
|
480
|
+
};
|
|
481
|
+
// ─── entry ──────────────────────────────────────────────────────────────────
|
|
482
|
+
export const runDashboard = async (ctx) => {
|
|
483
|
+
const renderer = await createCliRenderer({ exitOnCtrlC: false });
|
|
484
|
+
renderer.setBackgroundColor(BG);
|
|
485
|
+
const queryClient = new QueryClient();
|
|
486
|
+
const controller = new AbortController();
|
|
487
|
+
let eventTimer = null;
|
|
488
|
+
const quit = () => {
|
|
489
|
+
controller.abort();
|
|
490
|
+
if (eventTimer !== null)
|
|
491
|
+
clearTimeout(eventTimer);
|
|
492
|
+
// Drop cached queries so their gc/retry timers stop pinning the event loop.
|
|
493
|
+
queryClient.clear();
|
|
494
|
+
// The canonical opentui shutdown (its own exitOnCtrlC path does exactly
|
|
495
|
+
// this): destroy on the next tick and let the process end on its own. A
|
|
496
|
+
// process.exit() here would race destroy()'s deferred finalize — the
|
|
497
|
+
// kitty keyboard protocol stays pushed and the shell is left eating
|
|
498
|
+
// ^[[..;..:.u key-release sequences.
|
|
499
|
+
process.nextTick(() => renderer.destroy());
|
|
500
|
+
};
|
|
501
|
+
// The SSE stream invalidates the cache from outside React — any traffic
|
|
502
|
+
// (heartbeats included) means state may have moved, so re-read it.
|
|
503
|
+
const scheduleInvalidate = () => {
|
|
504
|
+
if (eventTimer !== null)
|
|
505
|
+
clearTimeout(eventTimer);
|
|
506
|
+
eventTimer = setTimeout(() => {
|
|
507
|
+
void queryClient.invalidateQueries();
|
|
508
|
+
}, 250);
|
|
509
|
+
};
|
|
510
|
+
const watch = async () => {
|
|
511
|
+
while (!controller.signal.aborted) {
|
|
512
|
+
try {
|
|
513
|
+
const headers = {};
|
|
514
|
+
if (ctx.config.token !== null)
|
|
515
|
+
headers["authorization"] = `Bearer ${ctx.config.token}`;
|
|
516
|
+
const response = await fetch(`${ctx.config.url}/api/events`, {
|
|
517
|
+
headers,
|
|
518
|
+
signal: controller.signal,
|
|
519
|
+
});
|
|
520
|
+
if (!response.ok || response.body === null)
|
|
521
|
+
throw new Error(String(response.status));
|
|
522
|
+
const reader = response.body.getReader();
|
|
523
|
+
for (;;) {
|
|
524
|
+
const { done } = await reader.read();
|
|
525
|
+
if (done)
|
|
526
|
+
break;
|
|
527
|
+
scheduleInvalidate();
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
if (controller.signal.aborted)
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
void watch();
|
|
538
|
+
createRoot(renderer).render(_jsx(QueryClientProvider, { client: queryClient, children: _jsx(App, { ctx: ctx, onQuit: quit }) }));
|
|
539
|
+
// finalizeDestroy() emits DESTROY synchronously mid-teardown; this
|
|
540
|
+
// continuation is a microtask, so it runs after the terminal is fully
|
|
541
|
+
// restored. Settling here lets main()'s top-level await finish and the
|
|
542
|
+
// process exit 0 naturally — no process.exit, no unsettled-await code 13.
|
|
543
|
+
await new Promise((resolve) => {
|
|
544
|
+
renderer.once(CliRenderEvents.DESTROY, () => resolve());
|
|
545
|
+
});
|
|
546
|
+
};
|