@sealant/mend 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/clipboard.js +73 -0
- package/dist/dashboard-model.js +4 -9
- package/dist/dashboard.js +5 -16
- package/dist/help.js +2 -0
- package/dist/main.js +33 -2
- package/dist/shared.js +26 -0
- package/man/mend-accounts.1 +1 -1
- package/man/mend-adopt.1 +1 -1
- package/man/mend-attach.1 +3 -1
- package/man/mend-codex.1 +3 -1
- package/man/mend-completions.1 +1 -1
- package/man/mend-connect.1 +1 -1
- package/man/mend-continue.1 +1 -1
- package/man/mend-doctor.1 +1 -1
- package/man/mend-dotfiles-sync.1 +1 -1
- package/man/mend-dotfiles.1 +1 -1
- package/man/mend-env-cluster.1 +1 -1
- package/man/mend-env-load.1 +1 -1
- package/man/mend-env-show.1 +1 -1
- package/man/mend-help.1 +1 -1
- package/man/mend-keys-init.1 +1 -1
- package/man/mend-keys-share.1 +1 -1
- package/man/mend-keys-show.1 +1 -1
- package/man/mend-login.1 +1 -1
- package/man/mend-logout.1 +1 -1
- package/man/mend-man.1 +1 -1
- package/man/mend-pair.1 +1 -1
- package/man/mend-projects.1 +1 -1
- package/man/mend-refresh.1 +1 -1
- package/man/mend-rejoin.1 +1 -1
- package/man/mend-resume.1 +1 -1
- package/man/mend-run.1 +1 -1
- package/man/mend-service-add.1 +1 -1
- package/man/mend-service-connect.1 +1 -1
- package/man/mend-service-init.1 +1 -1
- package/man/mend-service-list.1 +1 -1
- package/man/mend-service-logs.1 +1 -1
- package/man/mend-service-restart.1 +1 -1
- package/man/mend-service-run.1 +1 -1
- package/man/mend-service-stop.1 +1 -1
- package/man/mend-sessions.1 +1 -1
- package/man/mend-shell.1 +1 -1
- package/man/mend-skills-push.1 +1 -1
- package/man/mend-skills.1 +1 -1
- package/man/mend-ssh-setup.1 +1 -1
- package/man/mend-ssh.1 +1 -1
- package/man/mend-stop.1 +1 -1
- package/man/mend-ui.1 +1 -1
- package/man/mend-version.1 +1 -1
- package/man/mend-worktrees.1 +1 -1
- package/man/mend.1 +1 -1
- package/package.json +1 -1
|
@@ -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
|
+
};
|
package/dist/dashboard-model.js
CHANGED
|
@@ -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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
@@ -3,7 +3,7 @@ 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
5
|
import { useEffect, useRef, useState } from "react";
|
|
6
|
-
import { deriveHarnesses, deriveProjects, advanceFromBase, deriveRows, deriveWorktrees, filterBranches, foldGroupStatus,
|
|
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
|
|
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;
|
|
@@ -1062,7 +1051,7 @@ const App = ({ ctx, onQuit }) => {
|
|
|
1062
1051
|
refetch();
|
|
1063
1052
|
}, onQuit: onQuit }));
|
|
1064
1053
|
}
|
|
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))) :
|
|
1054
|
+
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))) : (_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
1055
|
if (typeof value === "string")
|
|
1067
1056
|
submitRename(editing, value);
|
|
1068
1057
|
} }) })) : 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
|
@@ -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
|
},
|
package/dist/main.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createRequire } from "node:module";
|
|
|
5
5
|
import * as net from "node:net";
|
|
6
6
|
import * as os from "node:os";
|
|
7
7
|
import * as path from "node:path";
|
|
8
|
+
import { readClipboardImage } from "./clipboard.js";
|
|
8
9
|
import { doctorCommand } from "./doctor.js";
|
|
9
10
|
import { readSyncFiles, scanDotfileCandidates } from "./dotfiles.js";
|
|
10
11
|
import { formatLoadReport } from "./env.js";
|
|
@@ -12,7 +13,7 @@ import { findCommand, manFileName, renderCommand, renderGroup, renderIndex, rend
|
|
|
12
13
|
import { loginCommand } from "./login.js";
|
|
13
14
|
import { pairCommand, qrCommand } from "./pair.js";
|
|
14
15
|
import { isComposeFile, proposeFromCompose, proposeFromPackageJson, proposeFromWorkspacePackage, renderMendToml, workspaceGlobs, } from "./service-init.js";
|
|
15
|
-
import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, isDetachChunk, LIVE_STATUSES, sessionDisplayName, matchProjectByCwd, normalizeProjectName, gitCurrentBranch, parseLaunchArgs, } from "./shared.js";
|
|
16
|
+
import { agentIsLive, agentOutcome, cwdFacts, gitTopLevel, HARNESS_COMMANDS, isDetachChunk, isPasteChunk, LIVE_STATUSES, pasteBytes, trackBracketedPaste, sessionDisplayName, matchProjectByCwd, normalizeProjectName, gitCurrentBranch, parseLaunchArgs, } from "./shared.js";
|
|
16
17
|
import { DEFAULT_SKILLS_DIR, scanSkillLibrary } from "./skills.js";
|
|
17
18
|
import { sshCommand } from "./ssh-setup.js";
|
|
18
19
|
import { cliVersion, fetchServerVersion, versionLines } from "./version.js";
|
|
@@ -452,9 +453,12 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
|
|
|
452
453
|
finishAttachment?.();
|
|
453
454
|
};
|
|
454
455
|
const signals = ["SIGHUP", "SIGINT", "SIGTERM"];
|
|
456
|
+
let bracketedPaste = false;
|
|
455
457
|
const onTtyFrame = (event) => {
|
|
456
458
|
if (typeof event.data !== "string") {
|
|
457
|
-
|
|
459
|
+
const bytes = Buffer.from(event.data);
|
|
460
|
+
bracketedPaste = trackBracketedPaste(bytes, bracketedPaste);
|
|
461
|
+
process.stdout.write(bytes);
|
|
458
462
|
return;
|
|
459
463
|
}
|
|
460
464
|
try {
|
|
@@ -484,9 +488,36 @@ const attachTty = async (config, sessionId, harness, from, processId, options) =
|
|
|
484
488
|
}
|
|
485
489
|
if (ws.readyState !== WebSocket.OPEN)
|
|
486
490
|
return;
|
|
491
|
+
if (isPasteChunk(data)) {
|
|
492
|
+
// Ctrl+V — the agent's own clipboard is empty (the workspace has no
|
|
493
|
+
// display), so an image on THIS machine's clipboard goes up to Mend and
|
|
494
|
+
// its workspace path is pasted instead; codex and claude read the path.
|
|
495
|
+
// No image, or no way to ask: the keystroke goes through untouched.
|
|
496
|
+
void pasteClipboardImage(data);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
487
499
|
// Copy into a plain ArrayBuffer (WebSocket.send rejects pooled Buffer views).
|
|
488
500
|
ws.send(new Uint8Array(data).buffer);
|
|
489
501
|
};
|
|
502
|
+
const forward = (bytes) => {
|
|
503
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
504
|
+
ws.send(new Uint8Array(bytes).buffer);
|
|
505
|
+
};
|
|
506
|
+
const pasteClipboardImage = async (keystroke) => {
|
|
507
|
+
const image = await readClipboardImage();
|
|
508
|
+
if (image === null) {
|
|
509
|
+
forward(keystroke);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
try {
|
|
513
|
+
const stored = await request(config, "POST", `/sessions/${sessionId}/images`, { contentsBase64: image.bytes.toString("base64") });
|
|
514
|
+
forward(pasteBytes(stored.path, bracketedPaste));
|
|
515
|
+
}
|
|
516
|
+
catch {
|
|
517
|
+
// The TUI owns the screen; a bell is the only honest signal left.
|
|
518
|
+
process.stdout.write("\x07");
|
|
519
|
+
}
|
|
520
|
+
};
|
|
490
521
|
try {
|
|
491
522
|
sendResize();
|
|
492
523
|
process.stdout.on("resize", onWinch);
|
package/dist/shared.js
CHANGED
|
@@ -293,3 +293,29 @@ export const normalizeProjectName = (raw) => {
|
|
|
293
293
|
.replace(/-+$/, "");
|
|
294
294
|
return slug === "" ? "project" : slug.slice(0, 64);
|
|
295
295
|
};
|
|
296
|
+
/**
|
|
297
|
+
* Whether one stdin chunk is exactly Ctrl+V — the raw 0x16 byte, or the
|
|
298
|
+
* kitty CSI-u form `ESC [ 118 ; 5 u` (`v` is code 118) on press or repeat.
|
|
299
|
+
* Exact, not contained: a paste that happens to carry 0x16 is not a request.
|
|
300
|
+
*/
|
|
301
|
+
export const isPasteChunk = (data) => {
|
|
302
|
+
if (data.length === 1 && data[0] === 0x16)
|
|
303
|
+
return true;
|
|
304
|
+
const text = data.toString("latin1");
|
|
305
|
+
return text === "\u001b[118;5u" || text === "\u001b[118;5:1u" || text === "\u001b[118;5:2u";
|
|
306
|
+
};
|
|
307
|
+
/**
|
|
308
|
+
* Follow the remote app's bracketed-paste mode through the bytes it writes:
|
|
309
|
+
* the last `ESC [ ? 2004 h|l` in a chunk wins; a chunk with neither keeps the
|
|
310
|
+
* current state. A pasted image path is wrapped only when the app asked.
|
|
311
|
+
*/
|
|
312
|
+
export const trackBracketedPaste = (output, current) => {
|
|
313
|
+
const text = output.toString("latin1");
|
|
314
|
+
const on = text.lastIndexOf("\u001b[?2004h");
|
|
315
|
+
const off = text.lastIndexOf("\u001b[?2004l");
|
|
316
|
+
if (on === -1 && off === -1)
|
|
317
|
+
return current;
|
|
318
|
+
return on > off;
|
|
319
|
+
};
|
|
320
|
+
/** The bytes that paste `text` into the remote app, bracketed when it asked. */
|
|
321
|
+
export const pasteBytes = (text, bracketed) => Buffer.from(bracketed ? `\u001b[200~${text}\u001b[201~` : text, "utf8");
|
package/man/mend-accounts.1
CHANGED
package/man/mend-adopt.1
CHANGED
package/man/mend-attach.1
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.TH MEND-ATTACH 1 "" "mend 0.
|
|
1
|
+
.TH MEND-ATTACH 1 "" "mend 0.20.0" "mend manual"
|
|
2
2
|
.SH NAME
|
|
3
3
|
mend\-attach \- reattach this terminal to a running session
|
|
4
4
|
.SH SYNOPSIS
|
|
@@ -9,5 +9,7 @@ mend\-attach \- reattach this terminal to a running session
|
|
|
9
9
|
With no id, the one live session is taken; with several, a picker opens. A prefix of the id is enough.
|
|
10
10
|
.PP
|
|
11
11
|
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.
|
|
12
|
+
.PP
|
|
13
|
+
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.
|
|
12
14
|
.SH SEE ALSO
|
|
13
15
|
mend(1), mend\-stop(1), mend\-rejoin(1), mend\-shell(1)
|
package/man/mend-codex.1
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.TH MEND-CODEX 1 "" "mend 0.
|
|
1
|
+
.TH MEND-CODEX 1 "" "mend 0.20.0" "mend manual"
|
|
2
2
|
.SH NAME
|
|
3
3
|
mend\-codex \- launch codex, claude, or opencode in a recorded worktree
|
|
4
4
|
.SH SYNOPSIS
|
|
@@ -12,6 +12,8 @@ mend codex, mend claude, and mend opencode are the same command with a different
|
|
|
12
12
|
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.
|
|
13
13
|
.PP
|
|
14
14
|
Detach with Ctrl+] and the session keeps running. Reattach from any terminal with mend attach, or from the phone.
|
|
15
|
+
.PP
|
|
16
|
+
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.
|
|
15
17
|
.SH OPTIONS
|
|
16
18
|
.TP
|
|
17
19
|
.B \-\-name <worktree>
|
package/man/mend-completions.1
CHANGED
package/man/mend-connect.1
CHANGED
package/man/mend-continue.1
CHANGED
package/man/mend-doctor.1
CHANGED
package/man/mend-dotfiles-sync.1
CHANGED
package/man/mend-dotfiles.1
CHANGED
package/man/mend-env-cluster.1
CHANGED
package/man/mend-env-load.1
CHANGED
package/man/mend-env-show.1
CHANGED
package/man/mend-help.1
CHANGED
package/man/mend-keys-init.1
CHANGED
package/man/mend-keys-share.1
CHANGED
package/man/mend-keys-show.1
CHANGED
package/man/mend-login.1
CHANGED
package/man/mend-logout.1
CHANGED
package/man/mend-man.1
CHANGED
package/man/mend-pair.1
CHANGED
package/man/mend-projects.1
CHANGED
package/man/mend-refresh.1
CHANGED
package/man/mend-rejoin.1
CHANGED
package/man/mend-resume.1
CHANGED
package/man/mend-run.1
CHANGED
package/man/mend-service-add.1
CHANGED
package/man/mend-service-init.1
CHANGED
package/man/mend-service-list.1
CHANGED
package/man/mend-service-logs.1
CHANGED
package/man/mend-service-run.1
CHANGED
package/man/mend-service-stop.1
CHANGED
package/man/mend-sessions.1
CHANGED
package/man/mend-shell.1
CHANGED
package/man/mend-skills-push.1
CHANGED
package/man/mend-skills.1
CHANGED
package/man/mend-ssh-setup.1
CHANGED
package/man/mend-ssh.1
CHANGED
package/man/mend-stop.1
CHANGED
package/man/mend-ui.1
CHANGED
package/man/mend-version.1
CHANGED
package/man/mend-worktrees.1
CHANGED
package/man/mend.1
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sealant/mend",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "The mend CLI: adopt a repository into Mend's store, launch your coding agent in a recorded session worktree, detach and reattach from any terminal",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|