@sealant/mend 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/dist/agent-share.js +150 -0
  2. package/dist/clipboard.js +73 -0
  3. package/dist/dashboard-model.js +4 -9
  4. package/dist/dashboard.js +26 -17
  5. package/dist/help.js +23 -8
  6. package/dist/main.js +153 -128
  7. package/dist/shared.js +54 -11
  8. package/man/mend-accounts.1 +1 -1
  9. package/man/mend-adopt.1 +3 -3
  10. package/man/mend-attach.1 +3 -1
  11. package/man/mend-codex.1 +3 -1
  12. package/man/mend-completions.1 +1 -1
  13. package/man/mend-connect.1 +1 -1
  14. package/man/mend-continue.1 +1 -1
  15. package/man/mend-doctor.1 +1 -1
  16. package/man/mend-dotfiles-sync.1 +1 -1
  17. package/man/mend-dotfiles.1 +1 -1
  18. package/man/mend-env-cluster.1 +1 -1
  19. package/man/mend-env-load.1 +1 -1
  20. package/man/mend-env-show.1 +1 -1
  21. package/man/mend-help.1 +1 -1
  22. package/man/mend-keys-init.1 +4 -4
  23. package/man/mend-keys-mode.1 +11 -0
  24. package/man/mend-keys-share.1 +4 -2
  25. package/man/mend-keys-show.1 +3 -3
  26. package/man/mend-login.1 +1 -1
  27. package/man/mend-logout.1 +1 -1
  28. package/man/mend-man.1 +1 -1
  29. package/man/mend-pair.1 +1 -1
  30. package/man/mend-projects.1 +1 -1
  31. package/man/mend-refresh.1 +1 -1
  32. package/man/mend-rejoin.1 +1 -1
  33. package/man/mend-resume.1 +1 -1
  34. package/man/mend-run.1 +1 -1
  35. package/man/mend-service-add.1 +1 -1
  36. package/man/mend-service-connect.1 +1 -1
  37. package/man/mend-service-init.1 +1 -1
  38. package/man/mend-service-list.1 +1 -1
  39. package/man/mend-service-logs.1 +1 -1
  40. package/man/mend-service-restart.1 +1 -1
  41. package/man/mend-service-run.1 +1 -1
  42. package/man/mend-service-stop.1 +1 -1
  43. package/man/mend-sessions.1 +1 -1
  44. package/man/mend-shell.1 +1 -1
  45. package/man/mend-skills-push.1 +1 -1
  46. package/man/mend-skills.1 +1 -1
  47. package/man/mend-ssh-setup.1 +1 -1
  48. package/man/mend-ssh.1 +1 -1
  49. package/man/mend-stop.1 +1 -1
  50. package/man/mend-ui.1 +1 -1
  51. package/man/mend-version.1 +1 -1
  52. package/man/mend-worktrees.1 +1 -1
  53. package/man/mend.1 +7 -4
  54. package/package.json +1 -1
@@ -0,0 +1,150 @@
1
+ import * as net from "node:net";
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+ /** The bridge endpoint for a server url: ws(s), this machine named, the token as a query. */
4
+ export const bridgeUrlOf = (serverUrl, token, host) => {
5
+ const url = new URL(`${serverUrl}/api/keys/bridge/ws`);
6
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
7
+ url.searchParams.set("host", host);
8
+ if (token !== null)
9
+ url.searchParams.set("token", token);
10
+ return url;
11
+ };
12
+ /** Reconnect backoff: 1s, 2s, … capped at 30s. */
13
+ export const retryDelayMs = (attempt) => Math.min(30_000, 1000 * 2 ** Math.min(Math.max(attempt - 1, 0), 5));
14
+ const SSH_AGENTC_REQUEST_IDENTITIES = 11;
15
+ const SSH_AGENTC_SIGN_REQUEST = 13;
16
+ /**
17
+ * One complete agent exchange against the local agent: fresh connection,
18
+ * write the framed request verbatim, read one framed response. A hardware
19
+ * key blocks here until the touch — hence the generous timeout.
20
+ */
21
+ const askLocalAgent = (agentSock, payload) => new Promise((resolve, reject) => {
22
+ const connection = net.connect(agentSock, () => {
23
+ connection.write(payload);
24
+ });
25
+ const timer = setTimeout(() => {
26
+ connection.destroy();
27
+ reject(new Error("the agent did not answer within 60s — touch missed?"));
28
+ }, 60_000);
29
+ let pending = Buffer.alloc(0);
30
+ connection.on("data", (chunk) => {
31
+ pending = Buffer.concat([pending, chunk]);
32
+ if (pending.length >= 4 && pending.length >= 4 + pending.readUInt32BE(0)) {
33
+ clearTimeout(timer);
34
+ connection.end();
35
+ resolve(pending.subarray(0, 4 + pending.readUInt32BE(0)));
36
+ }
37
+ });
38
+ connection.on("error", (error) => {
39
+ clearTimeout(timer);
40
+ reject(error);
41
+ });
42
+ });
43
+ /** An abortable wait; an abort simply ends it early. */
44
+ const sleep = (ms, signal) => delay(ms, undefined, { signal }).then(() => undefined, () => undefined);
45
+ /** Share until the signal aborts; every connect, drop, and signature is an event. */
46
+ export const shareAgent = async (options) => {
47
+ const { url, agentSock, signal, onEvent } = options;
48
+ // Requests are answered one at a time: the agent protocol has one in flight.
49
+ let chain = Promise.resolve();
50
+ let attempt = 0;
51
+ while (!signal.aborted) {
52
+ const ws = new WebSocket(url);
53
+ const onAbort = () => ws.close();
54
+ signal.addEventListener("abort", onAbort, { once: true });
55
+ const closed = new Promise((resolve) => {
56
+ ws.addEventListener("close", () => resolve(), { once: true });
57
+ });
58
+ const opened = await new Promise((resolve) => {
59
+ ws.addEventListener("open", () => resolve(true), { once: true });
60
+ ws.addEventListener("error", () => resolve(false), { once: true });
61
+ });
62
+ if (opened) {
63
+ attempt = 0;
64
+ onEvent({ kind: "connected" });
65
+ ws.addEventListener("message", (event) => {
66
+ const text = typeof event.data === "string"
67
+ ? event.data
68
+ : Buffer.from(event.data).toString("utf8");
69
+ let frame;
70
+ try {
71
+ frame = JSON.parse(text);
72
+ }
73
+ catch {
74
+ return;
75
+ }
76
+ if (frame.t !== "req" ||
77
+ typeof frame.id !== "number" ||
78
+ typeof frame.payload !== "string") {
79
+ return;
80
+ }
81
+ const id = frame.id;
82
+ const payload = Buffer.from(frame.payload, "base64");
83
+ const type = payload[4];
84
+ const context = typeof frame.context === "string" ? frame.context : "mend";
85
+ chain = chain.then(async () => {
86
+ const started = Date.now();
87
+ if (type === SSH_AGENTC_SIGN_REQUEST)
88
+ onEvent({ kind: "sign-requested", context });
89
+ else if (type === SSH_AGENTC_REQUEST_IDENTITIES) {
90
+ onEvent({ kind: "identities-requested", context });
91
+ }
92
+ try {
93
+ const response = await askLocalAgent(agentSock, payload);
94
+ if (ws.readyState !== WebSocket.OPEN)
95
+ return null;
96
+ ws.send(JSON.stringify({ t: "res", id, payload: response.toString("base64") }));
97
+ if (type === SSH_AGENTC_SIGN_REQUEST) {
98
+ onEvent({ kind: "signed", context, seconds: (Date.now() - started) / 1000 });
99
+ }
100
+ }
101
+ catch (error) {
102
+ const message = error instanceof Error ? error.message : String(error);
103
+ if (ws.readyState === WebSocket.OPEN) {
104
+ ws.send(JSON.stringify({ t: "err", id, message }));
105
+ }
106
+ if (type === SSH_AGENTC_SIGN_REQUEST)
107
+ onEvent({ kind: "not-signed", context, message });
108
+ }
109
+ return null;
110
+ });
111
+ });
112
+ }
113
+ await closed;
114
+ signal.removeEventListener("abort", onAbort);
115
+ if (signal.aborted)
116
+ return;
117
+ attempt += 1;
118
+ const retryMs = retryDelayMs(attempt);
119
+ onEvent({ kind: "disconnected", retryMs });
120
+ await sleep(retryMs, signal);
121
+ }
122
+ };
123
+ /** Start sharing in the background; `stop` ends it. */
124
+ export const startAgentShare = (options) => {
125
+ const controller = new AbortController();
126
+ const listeners = new Set();
127
+ let state = "connecting";
128
+ void shareAgent({
129
+ ...options,
130
+ signal: controller.signal,
131
+ onEvent: (event) => {
132
+ if (event.kind === "connected")
133
+ state = "connected";
134
+ else if (event.kind === "disconnected")
135
+ state = "connecting";
136
+ for (const listener of listeners)
137
+ listener(event);
138
+ },
139
+ });
140
+ return {
141
+ snapshot: () => state,
142
+ subscribe: (listener) => {
143
+ listeners.add(listener);
144
+ return () => {
145
+ listeners.delete(listener);
146
+ };
147
+ },
148
+ stop: () => controller.abort(),
149
+ };
150
+ };
@@ -0,0 +1,73 @@
1
+ import { execFile } from "node:child_process";
2
+ const IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
3
+ /** The first image type this reader accepts from a clipboard's offered list. */
4
+ export const pickImageType = (offered) => {
5
+ const types = offered.split(/\s+/).map((type) => type.trim().toLowerCase());
6
+ for (const type of IMAGE_TYPES)
7
+ if (types.includes(type))
8
+ return type;
9
+ return null;
10
+ };
11
+ /**
12
+ * AppleScript's `«data PNGf…»` literal: the PNG bytes as hex, from
13
+ * `the clipboard as «class PNGf»`. Null when the clipboard held no image.
14
+ */
15
+ export const parsePngfHex = (output) => {
16
+ const match = /«data PNGf([0-9A-Fa-f]+)»/.exec(output.trim());
17
+ if (match?.[1] === undefined || match[1].length === 0)
18
+ return null;
19
+ return Buffer.from(match[1], "hex");
20
+ };
21
+ const MAX_BYTES = 32 * 1024 * 1024;
22
+ /** One clipboard tool call; null when the tool is missing, fails, or times out. */
23
+ const run = (command, args) => new Promise((resolve) => {
24
+ execFile(command, [...args], { encoding: "buffer", maxBuffer: MAX_BYTES, timeout: 3000 }, (error, stdout) => {
25
+ resolve(error === null ? stdout : null);
26
+ });
27
+ });
28
+ const fromWayland = async () => {
29
+ const offered = await run("wl-paste", ["--list-types"]);
30
+ if (offered === null)
31
+ return null;
32
+ const mediaType = pickImageType(offered.toString("utf8"));
33
+ if (mediaType === null)
34
+ return null;
35
+ const bytes = await run("wl-paste", ["--no-newline", "--type", mediaType]);
36
+ return bytes === null || bytes.length === 0 ? null : { bytes, mediaType };
37
+ };
38
+ const fromX11 = async () => {
39
+ const offered = await run("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"]);
40
+ if (offered === null)
41
+ return null;
42
+ const mediaType = pickImageType(offered.toString("utf8"));
43
+ if (mediaType === null)
44
+ return null;
45
+ const bytes = await run("xclip", ["-selection", "clipboard", "-t", mediaType, "-o"]);
46
+ return bytes === null || bytes.length === 0 ? null : { bytes, mediaType };
47
+ };
48
+ const fromMac = async () => {
49
+ const output = await run("osascript", ["-e", "the clipboard as «class PNGf»"]);
50
+ if (output === null)
51
+ return null;
52
+ const bytes = parsePngfHex(output.toString("utf8"));
53
+ return bytes === null ? null : { bytes, mediaType: "image/png" };
54
+ };
55
+ /**
56
+ * Read an image off the clipboard: wl-paste on Wayland, xclip on X11,
57
+ * osascript on macOS. Null when there is no image or no way to ask — the
58
+ * caller then forwards the keystroke as it was.
59
+ */
60
+ export const readClipboardImage = async (platform = process.platform, env = process.env) => {
61
+ if (platform === "darwin")
62
+ return fromMac();
63
+ if (platform !== "linux" && platform !== "freebsd" && platform !== "openbsd")
64
+ return null;
65
+ if (env["WAYLAND_DISPLAY"] !== undefined && env["WAYLAND_DISPLAY"] !== "") {
66
+ const image = await fromWayland();
67
+ if (image !== null)
68
+ return image;
69
+ }
70
+ if (env["DISPLAY"] !== undefined && env["DISPLAY"] !== "")
71
+ return fromX11();
72
+ return null;
73
+ };
@@ -160,15 +160,10 @@ export const pseudoGroup = (session, item) => ({
160
160
  live: LIVE_STATUSES.has(session.status) ? 1 : 0,
161
161
  annotation: item(session).annotation,
162
162
  });
163
- export const deriveRows = (groups) => groups.flatMap((group) => {
164
- const only = group.sessions.length === 1 ? group.sessions[0] : undefined;
165
- if (only !== undefined)
166
- return [{ kind: "session", group, item: only }];
167
- return [
168
- { kind: "worktree", group },
169
- ...group.sessions.map((item) => ({ kind: "session", group, item })),
170
- ];
171
- });
163
+ export const deriveRows = (groups) => groups.flatMap((group) => [
164
+ { kind: "worktree", group },
165
+ ...group.sessions.map((item) => ({ kind: "session", group, item })),
166
+ ]);
172
167
  export const rowKeyOf = (row) => row.kind === "worktree" ? `wt:${row.group.key}` : `s:${row.item.session.id}`;
173
168
  /** The picker's rows: resume offers the same harness first, then the crossings. */
174
169
  export const deriveHarnesses = (resuming) => {
package/dist/dashboard.js CHANGED
@@ -2,8 +2,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/reac
2
2
  import { CliRenderEvents, createCliRenderer } from "@opentui/core";
3
3
  import { createRoot, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react";
4
4
  import { QueryClient, QueryClientProvider, useMutation, useQuery, useQueryClient, } from "@tanstack/react-query";
5
- import { useEffect, useRef, useState } from "react";
6
- import { deriveHarnesses, deriveProjects, advanceFromBase, deriveRows, deriveWorktrees, filterBranches, foldGroupStatus, labelIsIdentity, rowKeyOf, worktreeDisplayName, fetchWorkbench, liveProtocolOf, liveShellOf, mapWorkbenchSessions, markSessionStopped, removeWorktreeGroup, prependSession, removeSession, replaceSession, WORKBENCH_KEY, } from "./dashboard-model.js";
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
7
  import { reviewTargetForSession } from "./review-workflow.js";
8
8
  import { ReviewScreen } from "./review.js";
9
9
  import { cwdFacts, HARNESS_COMMANDS, isPendingId, LIVE_STATUSES, matchProjectByCwd, normalizeProjectName, pendingId, } from "./shared.js";
@@ -58,26 +58,15 @@ const ProcessLines = ({ processes, services, indent, }) => (_jsxs(_Fragment, { c
58
58
  : process.kind === "agent-external"
59
59
  ? " · external"
60
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
- /**
62
- * A lone-session worktree, combined into one row (today's density): the
63
- * worktree name leads, everything live inside hangs underneath as facts.
64
- */
65
- const CombinedRow = ({ group, item, selected, }) => {
66
- const { session, annotation, services, processes } = item;
67
- const color = STATUS_COLOR[session.status] ?? MUTED;
68
- const age = timeAgo(session.createdAt).replace(" ago", "");
69
- const trailingLabel = labelIsIdentity(session) ? "" : (session.label ?? "");
70
- 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: INK, children: group.name.slice(0, 30).padEnd(31) }), _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) }), annotation !== undefined && annotation.openComments > 0 ? (_jsx("span", { fg: MUTED, children: `${annotation.openComments} open ` })) : null, _jsx("span", { fg: FAINT, children: trailingLabel.slice(0, 40) })] }) }), _jsx(ProcessLines, { processes: processes, services: services, indent: " └ " })] }));
71
- };
72
- /** A shared worktree's header: the place, its folded status, its member facts. */
61
+ /** A worktree's header: the place, its folded status, its member facts. */
73
62
  const WorktreeHeaderRow = ({ group, selected, }) => {
74
63
  const folded = foldGroupStatus(group);
75
64
  const color = group.live > 0 ? (STATUS_COLOR[folded] ?? MUTED) : FAINT;
76
65
  const age = timeAgo(group.sessions.at(-1)?.session.createdAt ?? group.createdAt).replace(" ago", "");
77
66
  const open = group.annotation?.openComments ?? 0;
78
- 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} sessions` }), open > 0 ? _jsx("span", { fg: MUTED, children: ` · ${open} open` }) : null] }) }));
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] }) }));
79
68
  };
80
- /** One conversation inside a shared worktree — the label is its identity. */
69
+ /** One conversation inside a worktree — the label is its identity. */
81
70
  const SessionChildRow = ({ item, selected, }) => {
82
71
  const { session, services, processes } = item;
83
72
  const color = STATUS_COLOR[session.status] ?? MUTED;
@@ -135,6 +124,8 @@ const keepSpanVisible = (scroll, top, height) => {
135
124
  };
136
125
  const keepRowVisible = (scroll, index) => keepSpanVisible(scroll, index, 1);
137
126
  const PROJECTS_PANE_WIDTH = 30;
127
+ /** The header's share fact when no share runs. */
128
+ const noShare = () => "off";
138
129
  const App = ({ ctx, onQuit }) => {
139
130
  const renderer = useRenderer();
140
131
  const queryClient = useQueryClient();
@@ -154,6 +145,7 @@ const App = ({ ctx, onQuit }) => {
154
145
  const [busy, setBusy] = useState(null);
155
146
  const [busyStarted, setBusyStarted] = useState(0);
156
147
  const [status, setStatus] = useState(null);
148
+ const shareState = useSyncExternalStore((onChange) => ctx.agentShare?.subscribe(() => onChange()) ?? (() => { }), ctx.agentShare === null ? noShare : ctx.agentShare.snapshot);
157
149
  const [editing, setEditing] = useState(null);
158
150
  /** Session id a stop is armed against; the second press fires it. */
159
151
  const [stopArmed, setStopArmed] = useState(null);
@@ -208,6 +200,23 @@ const App = ({ ctx, onQuit }) => {
208
200
  const selectSession = (id) => setSessionKey(`s:${id}`);
209
201
  const say = (text) => setStatus({ text, at: Date.now() });
210
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]);
211
220
  // A repo underfoot that the store has never met: raise the adopt offer the
212
221
  // first time the workbench answers and no project matches the cwd.
213
222
  useEffect(() => {
@@ -1062,7 +1071,7 @@ const App = ({ ctx, onQuit }) => {
1062
1071
  refetch();
1063
1072
  }, onQuit: onQuit }));
1064
1073
  }
1065
- 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"] })] }), _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))) : row.group.sessions.length === 1 ? (_jsx(CombinedRow, { group: row.group, item: row.item, 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) => {
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) => {
1066
1075
  if (typeof value === "string")
1067
1076
  submitRename(editing, value);
1068
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
package/dist/help.js CHANGED
@@ -63,11 +63,11 @@ export const COMMANDS = [
63
63
  synopsis: ["[source] [--name <name>] [--auth ambient|mend-key|bridge]"],
64
64
  description: [
65
65
  "Clones the repository into Mend's store. Every session then gets its own worktree of it. The default source is the current directory; any git URL works, GitHub, GitLab, self-hosted, ssh://, or a local path.",
66
- "--auth says how the store fetches from the remote. ambient uses the server's own credentials. mend-key uses this machine's deploy key (see mend keys). bridge relays this machine's ssh-agent while mend keys share runs, so hardware keys stay here.",
66
+ "--auth says how the store fetches from the remote. Default: your mode from mend keys mode. mend-key signs with your Mend key on the server (see mend keys). bridge relays this machine's ssh-agent while a mend command runs here, so hardware keys stay on your desk. ambient uses the server's own credentials.",
67
67
  ],
68
68
  options: [
69
69
  { flag: "--name <name>", text: "the project's name in Mend. Default: the repository's" },
70
- { flag: "--auth <mode>", text: "ambient, mend-key, or bridge. Default: ambient" },
70
+ { flag: "--auth <mode>", text: "mend-key, bridge, or ambient. Default: mend keys mode" },
71
71
  ],
72
72
  examples: [
73
73
  { command: "mend adopt", text: "the repository you are standing in" },
@@ -87,6 +87,7 @@ export const COMMANDS = [
87
87
  "mend codex, mend claude, and mend opencode are the same command with a different harness. The session runs in a workspace on the platform, in its own git worktree, and everything it does is recorded. This terminal attaches to it.",
88
88
  "The worktree's name is asked first. --name skips the ask; an existing name joins that worktree as a new session. --worktree joins only and fails if the name is unknown. A quoted prompt becomes the first message.",
89
89
  "Detach with Ctrl+] and the session keeps running. Reattach from any terminal with mend attach, or from the phone.",
90
+ "Ctrl+V with an image on this machine's clipboard sends the image to the session and pastes its path; codex and claude read it. Needs wl-paste on Wayland, xclip on X11, nothing extra on macOS.",
90
91
  ],
91
92
  options: [
92
93
  { flag: "--name <worktree>", text: "name the worktree; an existing name joins it" },
@@ -156,6 +157,7 @@ export const COMMANDS = [
156
157
  description: [
157
158
  sessionArg,
158
159
  "A session that was picked up on the phone is taken back into this terminal: the phone's agent ends and the same conversation continues here.",
160
+ "Ctrl+V with an image on this machine's clipboard sends the image to the session and pastes its path; codex and claude read it. Needs wl-paste on Wayland, xclip on X11, nothing extra on macOS.",
159
161
  ],
160
162
  see: ["stop", "rejoin", "shell"],
161
163
  },
@@ -469,21 +471,33 @@ export const COMMANDS = [
469
471
  {
470
472
  name: "keys init",
471
473
  section: "project setup",
472
- summary: "generate this machine's Mend deploy key (ed25519)",
474
+ summary: "create your Mend key (ed25519) on the server",
473
475
  synopsis: [],
474
476
  description: [
475
- "Creates a key under ~/.config/mend/keys for projects adopted with --auth mend-key. Add the public key on your git host as a deploy key.",
477
+ "One key per user, held on the server, never copied anywhere. Add the public key to your git account's SSH keys and every repository you can reach works, from detached sessions and the phone too. For one repository only, add it as that repository's deploy key instead.",
476
478
  ],
477
- see: ["keys show", "adopt"],
479
+ see: ["keys show", "keys mode", "adopt"],
478
480
  },
479
481
  {
480
482
  name: "keys show",
481
483
  section: "project setup",
482
- summary: "print the public key",
484
+ summary: "print your Mend public key",
483
485
  synopsis: [],
484
- description: ["The key to add as a deploy key on your git host."],
486
+ description: [
487
+ "The key to add to your git account's SSH keys, or as a deploy key on one repository.",
488
+ ],
485
489
  see: ["keys init"],
486
490
  },
491
+ {
492
+ name: "keys mode",
493
+ section: "project setup",
494
+ summary: "how your remotes are reached: mend-key or bridge",
495
+ synopsis: ["[mend-key|bridge]"],
496
+ description: [
497
+ "mend-key (the default) signs with your Mend key on the server and works whenever the server is up. bridge signs with this machine's ssh-agent, so a hardware key never leaves your desk, but only while a mend command is running here. New projects adopt with this mode; a project's setup page can override it. Without a value, prints the current mode.",
498
+ ],
499
+ see: ["keys init", "keys share"],
500
+ },
487
501
  {
488
502
  name: "keys share",
489
503
  section: "project setup",
@@ -491,8 +505,9 @@ export const COMMANDS = [
491
505
  synopsis: [],
492
506
  description: [
493
507
  "Bridge mode for projects adopted with --auth bridge: the server's git operations sign with the keys in this machine's ssh-agent, so hardware keys never leave here. Runs until Ctrl-C.",
508
+ "When your mode is bridge (mend keys mode), every attaching mend command and the dashboard do this on their own for as long as they run, so this command is for a machine that is not running one. The server takes one signer at a time; a newer share replaces the older one.",
494
509
  ],
495
- see: ["adopt"],
510
+ see: ["keys mode", "adopt"],
496
511
  },
497
512
  // ── this machine ───────────────────────────────────────────────────────
498
513
  {