@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 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 status active sessions
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 — in the Mend
44
- web app, with the session record beside it. No issue tracker or pull request required.
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
+ };