@selesai/code 0.13.26 → 0.13.27
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/CHANGELOG.md +74 -0
- package/dist/cli/args.js +1 -0
- package/dist/cli/session-picker.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/core/agent-session-auto-handoff.test.js +2 -1
- package/dist/core/agent-session-length-continuation.test.js +2 -1
- package/dist/core/agent-session.d.ts +58 -7
- package/dist/core/agent-session.js +334 -167
- package/dist/core/bug-report-upload.d.ts +11 -0
- package/dist/core/bug-report-upload.js +21 -0
- package/dist/core/bug-report.d.ts +175 -0
- package/dist/core/bug-report.js +291 -0
- package/dist/core/cache-stats.js +12 -1
- package/dist/core/cache-warmer.d.ts +101 -0
- package/dist/core/cache-warmer.js +341 -0
- package/dist/core/compaction/branch-summarization.js +2 -2
- package/dist/core/compaction/compaction.d.ts +6 -5
- package/dist/core/compaction/compaction.js +49 -42
- package/dist/core/crash-log.d.ts +21 -0
- package/dist/core/crash-log.js +70 -0
- package/dist/core/experimental.d.ts +0 -4
- package/dist/core/experimental.js +0 -4
- package/dist/core/extensions/index.d.ts +1 -1
- package/dist/core/extensions/jiti-loader.d.ts +1 -0
- package/dist/core/extensions/jiti-loader.js +3 -0
- package/dist/core/extensions/jiti-static-loader.d.ts +1 -0
- package/dist/core/extensions/jiti-static-loader.js +3 -0
- package/dist/core/extensions/loader.js +39 -49
- package/dist/core/extensions/runner.d.ts +10 -7
- package/dist/core/extensions/runner.js +85 -67
- package/dist/core/extensions/types.d.ts +66 -52
- package/dist/core/extensions/virtual-modules.d.ts +2 -0
- package/dist/core/extensions/virtual-modules.js +39 -0
- package/dist/core/extensions/wrapper.js +1 -20
- package/dist/core/index.d.ts +2 -1
- package/dist/core/keybindings.d.ts +1 -1
- package/dist/core/keybindings.js +1 -1
- package/dist/core/messages.js +1 -0
- package/dist/core/model-config.d.ts +101 -20
- package/dist/core/model-config.js +25 -18
- package/dist/core/model-resolver.js +2 -1
- package/dist/core/model-runtime.js +5 -3
- package/dist/core/provider-composer.d.ts +7 -2
- package/dist/core/provider-composer.js +10 -5
- package/dist/core/radius.d.ts +3 -0
- package/dist/core/radius.js +6 -0
- package/dist/core/sdk.js +60 -38
- package/dist/core/session-export.d.ts +6 -2
- package/dist/core/session-export.js +14 -13
- package/dist/core/session-format.test.d.ts +1 -0
- package/dist/core/session-format.test.js +96 -0
- package/dist/core/session-manager.d.ts +29 -6
- package/dist/core/session-manager.js +159 -96
- package/dist/core/settings-manager.d.ts +22 -5
- package/dist/core/settings-manager.js +56 -23
- package/dist/core/slash-commands.js +1 -0
- package/dist/core/system-prompt.d.ts +49 -4
- package/dist/core/system-prompt.js +155 -102
- package/dist/core/system-prompt.test.js +61 -23
- package/dist/core/tools/bash.d.ts +2 -1
- package/dist/core/tools/bash.js +10 -4
- package/dist/core/tools/edit.js +1 -2
- package/dist/core/tools/read.js +1 -2
- package/dist/core/tools/renderers/bash.js +9 -1
- package/dist/core/tools/write.js +1 -2
- package/dist/core/usage-totals.d.ts +1 -1
- package/dist/core/usage-totals.js +5 -1
- package/dist/extensions/pi-web-agent/src/backends/factory.ts +14 -6
- package/dist/extensions/pi-web-agent/src/extension.ts +3 -1
- package/dist/extensions/pi-web-agent/src/orchestration/answer-synthesizer.ts +7 -3
- package/dist/extensions/pi-web-agent/src/orchestration/research-orchestrator.ts +3 -0
- package/dist/extensions/pi-web-agent/src/orchestration/research-types.ts +2 -0
- package/dist/extensions/pi-web-agent/src/presentation/explore-presentation.ts +4 -2
- package/dist/extensions/pi-web-agent/src/search/tokenin.ts +4 -1
- package/dist/extensions/pi-web-agent/src/tools/web-explore.ts +2 -1
- package/dist/extensions/pi-web-agent/src/types.ts +4 -0
- package/dist/index.d.ts +3 -2
- package/dist/main.js +13 -9
- package/dist/migrations.js +2 -2
- package/dist/modes/interactive/bug-report.d.ts +15 -0
- package/dist/modes/interactive/bug-report.js +205 -0
- package/dist/modes/interactive/chat-viewport.js +1 -1
- package/dist/modes/interactive/components/branch-summary-message.js +12 -5
- package/dist/modes/interactive/components/compaction-summary-message.js +12 -5
- package/dist/modes/interactive/components/custom-editor.d.ts +3 -3
- package/dist/modes/interactive/components/extension-editor.d.ts +4 -1
- package/dist/modes/interactive/components/extension-editor.js +7 -2
- package/dist/modes/interactive/components/extension-input.d.ts +2 -0
- package/dist/modes/interactive/components/extension-input.js +6 -0
- package/dist/modes/interactive/components/extension-selector.d.ts +1 -0
- package/dist/modes/interactive/components/extension-selector.js +4 -0
- package/dist/modes/interactive/components/footer.js +4 -1
- package/dist/modes/interactive/components/session-selector.d.ts +5 -5
- package/dist/modes/interactive/components/session-selector.js +74 -48
- package/dist/modes/interactive/components/settings-selector.d.ts +3 -1
- package/dist/modes/interactive/components/settings-selector.js +11 -0
- package/dist/modes/interactive/components/skill-invocation-message.js +11 -4
- package/dist/modes/interactive/components/status-indicator.d.ts +2 -2
- package/dist/modes/interactive/components/status-indicator.js +7 -7
- package/dist/modes/interactive/components/tool-execution.js +18 -9
- package/dist/modes/interactive/components/tree-selector.js +2 -0
- package/dist/modes/interactive/interactive-mode.d.ts +15 -2
- package/dist/modes/interactive/interactive-mode.js +257 -81
- package/dist/modes/interactive/session-share.d.ts +2 -0
- package/dist/modes/interactive/session-share.js +8 -4
- package/dist/modes/interactive/tui-renderer.js +2 -2
- package/dist/modes/rpc/rpc-mode.js +2 -2
- package/dist/utils/clipboard-command.d.ts +6 -0
- package/dist/utils/clipboard-command.js +44 -0
- package/dist/utils/clipboard-image.js +55 -89
- package/dist/utils/clipboard.d.ts +1 -1
- package/dist/utils/clipboard.js +118 -108
- package/dist/utils/wsl.d.ts +2 -0
- package/dist/utils/wsl.js +14 -0
- package/dist/utils/zip.d.ts +6 -0
- package/dist/utils/zip.js +59 -0
- package/package.json +9 -9
- package/dist/utils/clipboard-native.d.ts +0 -10
- package/dist/utils/clipboard-native.js +0 -19
|
@@ -435,11 +435,11 @@ export async function runRpcMode(runtimeHost) {
|
|
|
435
435
|
return undefined;
|
|
436
436
|
}
|
|
437
437
|
case "steer": {
|
|
438
|
-
await session.steer(command.message, command.images);
|
|
438
|
+
await session.steer(command.message, command.images, { source: "rpc" });
|
|
439
439
|
return success(id, "steer");
|
|
440
440
|
}
|
|
441
441
|
case "follow_up": {
|
|
442
|
-
await session.followUp(command.message, command.images);
|
|
442
|
+
await session.followUp(command.message, command.images, { source: "rpc" });
|
|
443
443
|
return success(id, "follow_up");
|
|
444
444
|
}
|
|
445
445
|
case "abort": {
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Undefined means the command failed; an empty buffer is a successful result. */
|
|
2
|
+
export declare function runClipboardCommand(command: string, args: readonly string[], options?: {
|
|
3
|
+
input?: string;
|
|
4
|
+
timeoutMs?: number;
|
|
5
|
+
maxBufferBytes?: number;
|
|
6
|
+
}): Promise<Buffer | undefined>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
/** Undefined means the command failed; an empty buffer is a successful result. */
|
|
3
|
+
export function runClipboardCommand(command, args, options) {
|
|
4
|
+
return new Promise((resolve) => {
|
|
5
|
+
// Clipboard writers can daemonize. Do not give them output pipes to retain.
|
|
6
|
+
const child = spawn(command, args, {
|
|
7
|
+
stdio: ["pipe", options?.input === undefined ? "pipe" : "ignore", "ignore"],
|
|
8
|
+
windowsHide: true,
|
|
9
|
+
});
|
|
10
|
+
const chunks = [];
|
|
11
|
+
let length = 0;
|
|
12
|
+
let settled = false;
|
|
13
|
+
const finish = (result) => {
|
|
14
|
+
if (settled)
|
|
15
|
+
return;
|
|
16
|
+
settled = true;
|
|
17
|
+
clearTimeout(timer);
|
|
18
|
+
resolve(result);
|
|
19
|
+
};
|
|
20
|
+
const abort = () => {
|
|
21
|
+
child.kill("SIGKILL");
|
|
22
|
+
child.stdout?.destroy();
|
|
23
|
+
child.stdin?.destroy();
|
|
24
|
+
finish(undefined);
|
|
25
|
+
};
|
|
26
|
+
const timer = setTimeout(abort, options?.timeoutMs ?? 3000);
|
|
27
|
+
child.on("error", () => finish(undefined));
|
|
28
|
+
child.on("close", (code) => {
|
|
29
|
+
if (!settled)
|
|
30
|
+
finish(code === 0 ? Buffer.concat(chunks, length) : undefined);
|
|
31
|
+
});
|
|
32
|
+
child.stdout?.on("data", (chunk) => {
|
|
33
|
+
if (settled)
|
|
34
|
+
return;
|
|
35
|
+
length += chunk.length;
|
|
36
|
+
if (length > (options?.maxBufferBytes ?? 50 * 1024 * 1024))
|
|
37
|
+
abort();
|
|
38
|
+
else
|
|
39
|
+
chunks.push(chunk);
|
|
40
|
+
});
|
|
41
|
+
child.stdin?.on("error", () => { }); // A writer may exit before consuming all input.
|
|
42
|
+
child.stdin?.end(options?.input);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getNativeClipboard } from "@earendil-works/pi-tui";
|
|
2
2
|
import { randomUUID } from "crypto";
|
|
3
3
|
import { readFileSync, unlinkSync } from "fs";
|
|
4
4
|
import { tmpdir } from "os";
|
|
5
5
|
import { join } from "path";
|
|
6
|
-
import {
|
|
6
|
+
import { runClipboardCommand } from "./clipboard-command.js";
|
|
7
|
+
import { detectSupportedImageMimeType } from "./mime.js";
|
|
7
8
|
import { loadPhoton } from "./photon.js";
|
|
9
|
+
import { isWSL } from "./wsl.js";
|
|
8
10
|
const SUPPORTED_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
|
9
11
|
const DEFAULT_LIST_TIMEOUT_MS = 1000;
|
|
10
|
-
const DEFAULT_READ_TIMEOUT_MS = 3000;
|
|
11
12
|
const DEFAULT_POWERSHELL_TIMEOUT_MS = 5000;
|
|
12
|
-
const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
13
13
|
export function isWaylandSession(env = process.env) {
|
|
14
14
|
return Boolean(env.WAYLAND_DISPLAY) || env.XDG_SESSION_TYPE === "wayland";
|
|
15
15
|
}
|
|
@@ -70,31 +70,13 @@ async function convertToPng(bytes) {
|
|
|
70
70
|
return null;
|
|
71
71
|
}
|
|
72
72
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
});
|
|
81
|
-
if (result.error) {
|
|
82
|
-
return { ok: false, stdout: Buffer.alloc(0) };
|
|
83
|
-
}
|
|
84
|
-
if (result.status !== 0) {
|
|
85
|
-
return { ok: false, stdout: Buffer.alloc(0) };
|
|
86
|
-
}
|
|
87
|
-
const stdout = Buffer.isBuffer(result.stdout)
|
|
88
|
-
? result.stdout
|
|
89
|
-
: Buffer.from(result.stdout ?? "", typeof result.stdout === "string" ? "utf-8" : undefined);
|
|
90
|
-
return { ok: true, stdout };
|
|
91
|
-
}
|
|
92
|
-
function readClipboardImageViaWlPaste() {
|
|
93
|
-
const list = runCommand("wl-paste", ["--list-types"], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS });
|
|
94
|
-
if (!list.ok) {
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
97
|
-
const types = list.stdout
|
|
73
|
+
// Undefined means the backend failed; null means it has no image. An empty
|
|
74
|
+
// Wayland clipboard must not fall through to stale X11 clipboard contents.
|
|
75
|
+
async function readClipboardImageViaWlPaste() {
|
|
76
|
+
const list = await runClipboardCommand("wl-paste", ["--list-types"], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS });
|
|
77
|
+
if (list === undefined)
|
|
78
|
+
return undefined;
|
|
79
|
+
const types = list
|
|
98
80
|
.toString("utf-8")
|
|
99
81
|
.split(/\r?\n/)
|
|
100
82
|
.map((t) => t.trim())
|
|
@@ -103,40 +85,28 @@ function readClipboardImageViaWlPaste() {
|
|
|
103
85
|
if (!selectedType) {
|
|
104
86
|
return null;
|
|
105
87
|
}
|
|
106
|
-
const data =
|
|
107
|
-
if (
|
|
88
|
+
const data = await runClipboardCommand("wl-paste", ["--type", selectedType, "--no-newline"]);
|
|
89
|
+
if (data === undefined)
|
|
90
|
+
return undefined;
|
|
91
|
+
if (data.length === 0)
|
|
108
92
|
return null;
|
|
109
|
-
}
|
|
110
|
-
return { bytes: data.stdout, mimeType: baseMimeType(selectedType) };
|
|
111
|
-
}
|
|
112
|
-
function isWSL(env = process.env) {
|
|
113
|
-
if (env.WSL_DISTRO_NAME || env.WSLENV) {
|
|
114
|
-
return true;
|
|
115
|
-
}
|
|
116
|
-
try {
|
|
117
|
-
const release = readFileSync("/proc/version", "utf-8");
|
|
118
|
-
return /microsoft|wsl/i.test(release);
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
93
|
+
return { bytes: data, mimeType: baseMimeType(selectedType) };
|
|
123
94
|
}
|
|
124
95
|
/**
|
|
125
96
|
* On WSL, the Linux clipboard (Wayland/X11) does not receive image data from
|
|
126
97
|
* Windows screenshots (Win+Shift+S). PowerShell can access the Windows clipboard
|
|
127
98
|
* directly, so we use it as a fallback.
|
|
128
99
|
*/
|
|
129
|
-
function readClipboardImageViaPowerShell(
|
|
130
|
-
const tmpFile = join(tmpdir(), `pi-clip-${randomUUID()}.png`);
|
|
100
|
+
async function readClipboardImageViaPowerShell() {
|
|
101
|
+
const tmpFile = join(tmpdir(), `pi-wsl-clip-${randomUUID()}.png`);
|
|
131
102
|
try {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
winPath = winPathResult.stdout.toString("utf-8").trim();
|
|
103
|
+
const winPathResult = await runClipboardCommand("wslpath", ["-w", tmpFile], {
|
|
104
|
+
timeoutMs: DEFAULT_LIST_TIMEOUT_MS,
|
|
105
|
+
});
|
|
106
|
+
if (winPathResult === undefined) {
|
|
107
|
+
return null;
|
|
139
108
|
}
|
|
109
|
+
const winPath = winPathResult.toString("utf-8").trim();
|
|
140
110
|
if (!winPath) {
|
|
141
111
|
return null;
|
|
142
112
|
}
|
|
@@ -148,13 +118,13 @@ function readClipboardImageViaPowerShell(options) {
|
|
|
148
118
|
"$img = [System.Windows.Forms.Clipboard]::GetImage()",
|
|
149
119
|
"if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }",
|
|
150
120
|
].join("; ");
|
|
151
|
-
const result =
|
|
121
|
+
const result = await runClipboardCommand("powershell.exe", ["-NoProfile", "-Command", psScript], {
|
|
152
122
|
timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS,
|
|
153
123
|
});
|
|
154
|
-
if (
|
|
124
|
+
if (result === undefined) {
|
|
155
125
|
return null;
|
|
156
126
|
}
|
|
157
|
-
const output = result.
|
|
127
|
+
const output = result.toString("utf-8").trim();
|
|
158
128
|
if (output !== "ok") {
|
|
159
129
|
return null;
|
|
160
130
|
}
|
|
@@ -176,38 +146,37 @@ function readClipboardImageViaPowerShell(options) {
|
|
|
176
146
|
}
|
|
177
147
|
}
|
|
178
148
|
}
|
|
179
|
-
function readClipboardImageViaXclip() {
|
|
180
|
-
const targets =
|
|
149
|
+
async function readClipboardImageViaXclip() {
|
|
150
|
+
const targets = await runClipboardCommand("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"], {
|
|
181
151
|
timeoutMs: DEFAULT_LIST_TIMEOUT_MS,
|
|
182
152
|
});
|
|
183
153
|
let candidateTypes = [];
|
|
184
|
-
if (targets
|
|
185
|
-
candidateTypes = targets
|
|
154
|
+
if (targets !== undefined) {
|
|
155
|
+
candidateTypes = targets
|
|
186
156
|
.toString("utf-8")
|
|
187
157
|
.split(/\r?\n/)
|
|
188
158
|
.map((t) => t.trim())
|
|
189
159
|
.filter(Boolean);
|
|
190
160
|
}
|
|
191
|
-
const preferred =
|
|
192
|
-
|
|
161
|
+
const preferred = selectPreferredImageMimeType(candidateTypes);
|
|
162
|
+
if (targets !== undefined && !preferred)
|
|
163
|
+
return null;
|
|
164
|
+
const tryTypes = new Set(preferred ? [preferred, ...SUPPORTED_IMAGE_MIME_TYPES] : SUPPORTED_IMAGE_MIME_TYPES);
|
|
193
165
|
for (const mimeType of tryTypes) {
|
|
194
|
-
const data =
|
|
195
|
-
if (data
|
|
196
|
-
return { bytes: data
|
|
166
|
+
const data = await runClipboardCommand("xclip", ["-selection", "clipboard", "-t", mimeType, "-o"]);
|
|
167
|
+
if (data !== undefined && data.length > 0) {
|
|
168
|
+
return { bytes: data, mimeType: baseMimeType(mimeType) };
|
|
197
169
|
}
|
|
198
170
|
}
|
|
199
|
-
return
|
|
171
|
+
return undefined;
|
|
200
172
|
}
|
|
201
173
|
async function readClipboardImageViaNativeClipboard() {
|
|
202
|
-
|
|
174
|
+
const bytes = await getNativeClipboard()?.getImage();
|
|
175
|
+
if (bytes === undefined)
|
|
176
|
+
return undefined;
|
|
177
|
+
if (!bytes?.length)
|
|
203
178
|
return null;
|
|
204
|
-
}
|
|
205
|
-
const imageData = await clipboard.getImageBinary();
|
|
206
|
-
if (!imageData || imageData.length === 0) {
|
|
207
|
-
return null;
|
|
208
|
-
}
|
|
209
|
-
const bytes = imageData instanceof Uint8Array ? imageData : Uint8Array.from(imageData);
|
|
210
|
-
return { bytes, mimeType: "image/png" };
|
|
179
|
+
return { bytes, mimeType: detectSupportedImageMimeType(bytes) ?? "application/octet-stream" };
|
|
211
180
|
}
|
|
212
181
|
export async function readClipboardImage(options) {
|
|
213
182
|
const env = options?.env ?? process.env;
|
|
@@ -215,22 +184,19 @@ export async function readClipboardImage(options) {
|
|
|
215
184
|
if (env.TERMUX_VERSION) {
|
|
216
185
|
return null;
|
|
217
186
|
}
|
|
218
|
-
let image
|
|
187
|
+
let image;
|
|
219
188
|
if (platform === "linux") {
|
|
220
189
|
const wsl = isWSL(env);
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
image = readClipboardImageViaWlPaste() ?? readClipboardImageViaXclip();
|
|
224
|
-
}
|
|
225
|
-
if (!image && wsl) {
|
|
226
|
-
image = readClipboardImageViaPowerShell({ wsl: true });
|
|
190
|
+
if (isWaylandSession(env) || wsl) {
|
|
191
|
+
image = await readClipboardImageViaWlPaste();
|
|
227
192
|
}
|
|
228
|
-
if (
|
|
229
|
-
image =
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
193
|
+
if (image === undefined)
|
|
194
|
+
image = await readClipboardImageViaXclip();
|
|
195
|
+
// Preserve Linux's empty/unavailable distinction if Windows has no image.
|
|
196
|
+
if (!image && wsl)
|
|
197
|
+
image = (await readClipboardImageViaPowerShell()) ?? image;
|
|
198
|
+
if (image === undefined)
|
|
199
|
+
image = await readClipboardImageViaNativeClipboard();
|
|
234
200
|
}
|
|
235
201
|
else {
|
|
236
202
|
image = await readClipboardImageViaNativeClipboard();
|
|
@@ -238,7 +204,7 @@ export async function readClipboardImage(options) {
|
|
|
238
204
|
if (!image) {
|
|
239
205
|
return null;
|
|
240
206
|
}
|
|
241
|
-
// Convert unsupported formats (e.g.,
|
|
207
|
+
// Convert unsupported formats (e.g., Windows DIB data wrapped as BMP) to PNG
|
|
242
208
|
if (!isSupportedImageMimeType(image.mimeType)) {
|
|
243
209
|
const pngBytes = await convertToPng(image.bytes);
|
|
244
210
|
if (!pngBytes) {
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
/** Read plain text from the system clipboard
|
|
1
|
+
/** Read plain text from the system clipboard. */
|
|
2
2
|
export declare function readClipboardText(): Promise<string | null>;
|
|
3
3
|
export declare function copyToClipboard(text: string): Promise<void>;
|
package/dist/utils/clipboard.js
CHANGED
|
@@ -1,17 +1,12 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
}
|
|
9
|
-
catch {
|
|
10
|
-
execSync("xsel --clipboard --input", options);
|
|
11
|
-
}
|
|
12
|
-
}
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { platform, tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { getNativeClipboard } from "@earendil-works/pi-tui";
|
|
6
|
+
import { runClipboardCommand } from "./clipboard-command.js";
|
|
7
|
+
import { isWSL } from "./wsl.js";
|
|
13
8
|
const MAX_OSC52_ENCODED_LENGTH = 100_000;
|
|
14
|
-
function isRemoteSession(env
|
|
9
|
+
function isRemoteSession(env) {
|
|
15
10
|
return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.MOSH_CONNECTION);
|
|
16
11
|
}
|
|
17
12
|
function emitOsc52(text) {
|
|
@@ -22,119 +17,134 @@ function emitOsc52(text) {
|
|
|
22
17
|
process.stdout.write(`\x1b]52;c;${encoded}\x07`);
|
|
23
18
|
return true;
|
|
24
19
|
}
|
|
25
|
-
/**
|
|
20
|
+
/**
|
|
21
|
+
* WSL without WSLg has no Linux display, so the Windows clipboard is written through
|
|
22
|
+
* interop. PowerShell reads the text from a file because `clip.exe` and PowerShell stdin
|
|
23
|
+
* decode piped bytes with the console code page, which mangles non-ASCII UTF-8.
|
|
24
|
+
*/
|
|
25
|
+
async function copyViaWindowsClipboard(text) {
|
|
26
|
+
const tmpFile = join(tmpdir(), `pi-wsl-clip-${randomUUID()}.txt`);
|
|
27
|
+
try {
|
|
28
|
+
writeFileSync(tmpFile, text, { encoding: "utf8", mode: 0o600 });
|
|
29
|
+
const winPath = (await runClipboardCommand("wslpath", ["-w", tmpFile], { timeoutMs: 1000 }))
|
|
30
|
+
?.toString("utf8")
|
|
31
|
+
.trim();
|
|
32
|
+
if (!winPath)
|
|
33
|
+
return false;
|
|
34
|
+
const script = `Set-Clipboard -Value ([System.IO.File]::ReadAllText('${winPath.replaceAll("'", "''")}', [System.Text.Encoding]::UTF8))`;
|
|
35
|
+
const result = await runClipboardCommand("powershell.exe", ["-NoProfile", "-Command", script], {
|
|
36
|
+
timeoutMs: 5000,
|
|
37
|
+
});
|
|
38
|
+
return result !== undefined;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
try {
|
|
45
|
+
unlinkSync(tmpFile);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// The file may not have been created.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Read plain text from the system clipboard. */
|
|
26
53
|
export async function readClipboardText() {
|
|
27
|
-
if (
|
|
28
|
-
|
|
54
|
+
if (platform() === "linux") {
|
|
55
|
+
const commands = [];
|
|
56
|
+
if (process.env.TERMUX_VERSION)
|
|
57
|
+
commands.push(["termux-clipboard-get", []]);
|
|
58
|
+
if (process.env.WAYLAND_DISPLAY)
|
|
59
|
+
commands.push(["wl-paste", ["--no-newline", "--type", "text"]]);
|
|
60
|
+
if (process.env.DISPLAY) {
|
|
61
|
+
commands.push(["xclip", ["-selection", "clipboard", "-out"]], ["xsel", ["--clipboard", "--output"]]);
|
|
62
|
+
}
|
|
63
|
+
for (const [command, args] of commands) {
|
|
64
|
+
const bytes = await runClipboardCommand(command, args, { timeoutMs: 5000 });
|
|
65
|
+
if (bytes !== undefined)
|
|
66
|
+
return bytes.toString("utf8") || null;
|
|
67
|
+
}
|
|
29
68
|
}
|
|
30
69
|
try {
|
|
31
|
-
|
|
32
|
-
return text || null;
|
|
70
|
+
return (await getNativeClipboard()?.getText()) || null;
|
|
33
71
|
}
|
|
34
72
|
catch {
|
|
35
73
|
return null;
|
|
36
74
|
}
|
|
37
75
|
}
|
|
38
76
|
export async function copyToClipboard(text) {
|
|
39
|
-
let copied = false;
|
|
40
77
|
const p = platform();
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
// OSC 52
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
78
|
+
const env = process.env;
|
|
79
|
+
let copied = false;
|
|
80
|
+
// Direct writes precede OSC 52 so the terminal cannot race the native writer.
|
|
81
|
+
// Linux tools retain clipboard selection ownership after this call returns.
|
|
82
|
+
if (p !== "linux") {
|
|
83
|
+
try {
|
|
84
|
+
const clipboard = getNativeClipboard();
|
|
85
|
+
if (clipboard?.setText) {
|
|
86
|
+
await clipboard.setText(text);
|
|
87
|
+
copied = true;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// Try platform commands next.
|
|
55
92
|
}
|
|
56
93
|
}
|
|
57
|
-
catch {
|
|
58
|
-
// Fall through to platform-specific clipboard tools.
|
|
59
|
-
}
|
|
60
|
-
const remote = isRemoteSession();
|
|
61
|
-
if (copied && !remote) {
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
const options = { input: text, timeout: 5000, stdio: ["pipe", "ignore", "ignore"] };
|
|
65
94
|
if (!copied) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
95
|
+
const commands = [];
|
|
96
|
+
if (p === "darwin")
|
|
97
|
+
commands.push(["pbcopy", []]);
|
|
98
|
+
else if (p === "win32")
|
|
99
|
+
commands.push(["clip", []]);
|
|
100
|
+
else {
|
|
101
|
+
if (env.TERMUX_VERSION)
|
|
102
|
+
commands.push(["termux-clipboard-set", []]);
|
|
103
|
+
if (env.WAYLAND_DISPLAY)
|
|
104
|
+
commands.push(["wl-copy", []]);
|
|
105
|
+
if (env.DISPLAY) {
|
|
106
|
+
commands.push(["xclip", ["-selection", "clipboard"]], ["xsel", ["--clipboard", "--input"]]);
|
|
70
107
|
}
|
|
71
|
-
|
|
72
|
-
|
|
108
|
+
}
|
|
109
|
+
for (const [command, args] of commands) {
|
|
110
|
+
if ((await runClipboardCommand(command, args, { input: text, timeoutMs: 5000 })) !== undefined) {
|
|
73
111
|
copied = true;
|
|
112
|
+
break;
|
|
74
113
|
}
|
|
75
|
-
else {
|
|
76
|
-
// Linux. Try Termux, Wayland, or X11 clipboard tools.
|
|
77
|
-
if (process.env.TERMUX_VERSION) {
|
|
78
|
-
try {
|
|
79
|
-
execSync("termux-clipboard-set", options);
|
|
80
|
-
copied = true;
|
|
81
|
-
}
|
|
82
|
-
catch {
|
|
83
|
-
// Fall back to Wayland or X11 tools.
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
if (!copied) {
|
|
87
|
-
const hasWaylandDisplay = Boolean(process.env.WAYLAND_DISPLAY);
|
|
88
|
-
const hasX11Display = Boolean(process.env.DISPLAY);
|
|
89
|
-
const isWayland = isWaylandSession();
|
|
90
|
-
if (isWayland && hasWaylandDisplay) {
|
|
91
|
-
try {
|
|
92
|
-
// Verify wl-copy exists (spawn errors are async and won't be caught)
|
|
93
|
-
execSync("which wl-copy", { stdio: "ignore" });
|
|
94
|
-
// wl-copy with execSync hangs due to fork behavior; use spawn instead.
|
|
95
|
-
// Await the exit code and only claim success on a clean exit, so a
|
|
96
|
-
// failed wl-copy falls through to the xclip/OSC 52 fallbacks.
|
|
97
|
-
const wlCopyExit = await new Promise((resolve) => {
|
|
98
|
-
const proc = spawn("wl-copy", [], { stdio: ["pipe", "ignore", "ignore"] });
|
|
99
|
-
proc.on("error", () => resolve(1));
|
|
100
|
-
proc.on("close", (code) => resolve(code ?? 1));
|
|
101
|
-
proc.stdin.on("error", () => {
|
|
102
|
-
// Ignore EPIPE errors if wl-copy exits early
|
|
103
|
-
});
|
|
104
|
-
proc.stdin.write(text);
|
|
105
|
-
proc.stdin.end();
|
|
106
|
-
});
|
|
107
|
-
if (wlCopyExit === 0) {
|
|
108
|
-
copied = true;
|
|
109
|
-
}
|
|
110
|
-
else if (hasX11Display) {
|
|
111
|
-
copyToX11Clipboard(options);
|
|
112
|
-
copied = true;
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
116
|
-
if (hasX11Display) {
|
|
117
|
-
copyToX11Clipboard(options);
|
|
118
|
-
copied = true;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
else if (hasX11Display) {
|
|
123
|
-
copyToX11Clipboard(options);
|
|
124
|
-
copied = true;
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
catch {
|
|
130
|
-
// Fall through to OSC 52 fallback.
|
|
131
114
|
}
|
|
132
115
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
116
|
+
let osc52Emitted = false;
|
|
117
|
+
if (!copied && p === "linux" && isWSL(env)) {
|
|
118
|
+
// Windows Terminal supports OSC 52; prefer it over the slower PowerShell round trip.
|
|
119
|
+
if (env.WT_SESSION)
|
|
120
|
+
osc52Emitted = emitOsc52(text);
|
|
121
|
+
copied = osc52Emitted || (await copyViaWindowsClipboard(text));
|
|
136
122
|
}
|
|
137
|
-
|
|
138
|
-
|
|
123
|
+
// OSC 52 cannot be verified, so a desktop session with a display reports the failure
|
|
124
|
+
// instead (#9618). Without a display the terminal is the only clipboard route (containers,
|
|
125
|
+
// WSL without WSLg), and remote sessions always emit it to reach the client clipboard.
|
|
126
|
+
const headless = p === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY && !env.TERMUX_VERSION;
|
|
127
|
+
let oversized = false;
|
|
128
|
+
if (!osc52Emitted && (isRemoteSession(env) || (!copied && headless))) {
|
|
129
|
+
if (emitOsc52(text))
|
|
130
|
+
copied = true;
|
|
131
|
+
else
|
|
132
|
+
oversized = true;
|
|
133
|
+
}
|
|
134
|
+
if (copied)
|
|
135
|
+
return;
|
|
136
|
+
if (oversized)
|
|
137
|
+
throw new Error("Clipboard unavailable: text exceeds the OSC 52 size limit");
|
|
138
|
+
if (p === "linux") {
|
|
139
|
+
if (env.TERMUX_VERSION) {
|
|
140
|
+
throw new Error("Clipboard unavailable: install the Termux:API app and `termux-api` package");
|
|
141
|
+
}
|
|
142
|
+
if (env.WAYLAND_DISPLAY) {
|
|
143
|
+
throw new Error("Clipboard unavailable: install `wl-clipboard` (`wl-copy`) or check Wayland access");
|
|
144
|
+
}
|
|
145
|
+
if (env.DISPLAY) {
|
|
146
|
+
throw new Error("Clipboard unavailable: install `xclip` or `xsel`, or check X11 access");
|
|
147
|
+
}
|
|
139
148
|
}
|
|
149
|
+
throw new Error("Clipboard unavailable");
|
|
140
150
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
/** Windows Subsystem for Linux, where Windows executables are reachable through interop. */
|
|
3
|
+
export function isWSL(env = process.env) {
|
|
4
|
+
if (env.WSL_DISTRO_NAME || env.WSLENV) {
|
|
5
|
+
return true;
|
|
6
|
+
}
|
|
7
|
+
try {
|
|
8
|
+
const release = readFileSync("/proc/version", "utf-8");
|
|
9
|
+
return /microsoft|wsl/i.test(release);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { writeFile } from "node:fs/promises";
|
|
2
|
+
import { crc32, deflateRawSync } from "node:zlib";
|
|
3
|
+
function dosDateTime(date) {
|
|
4
|
+
return {
|
|
5
|
+
time: (date.getHours() << 11) | (date.getMinutes() << 5) | (date.getSeconds() >> 1),
|
|
6
|
+
day: ((Math.max(1980, date.getFullYear()) - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
/** Create the small, classic ZIP archives used by bug reports. */
|
|
10
|
+
function createZipArchive(entries) {
|
|
11
|
+
const files = [];
|
|
12
|
+
const directory = [];
|
|
13
|
+
const { time, day } = dosDateTime(new Date());
|
|
14
|
+
let offset = 0;
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
const name = Buffer.from(entry.name);
|
|
17
|
+
const data = typeof entry.data === "string" ? Buffer.from(entry.data) : Buffer.from(entry.data);
|
|
18
|
+
const compressed = deflateRawSync(data);
|
|
19
|
+
const checksum = crc32(data);
|
|
20
|
+
const local = Buffer.alloc(30);
|
|
21
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
22
|
+
local.writeUInt16LE(20, 4);
|
|
23
|
+
local.writeUInt16LE(0x0800, 6);
|
|
24
|
+
local.writeUInt16LE(8, 8);
|
|
25
|
+
local.writeUInt16LE(time, 10);
|
|
26
|
+
local.writeUInt16LE(day, 12);
|
|
27
|
+
local.writeUInt32LE(checksum, 14);
|
|
28
|
+
local.writeUInt32LE(compressed.length, 18);
|
|
29
|
+
local.writeUInt32LE(data.length, 22);
|
|
30
|
+
local.writeUInt16LE(name.length, 26);
|
|
31
|
+
const central = Buffer.alloc(46);
|
|
32
|
+
central.writeUInt32LE(0x02014b50, 0);
|
|
33
|
+
central.writeUInt16LE(20, 4);
|
|
34
|
+
central.writeUInt16LE(20, 6);
|
|
35
|
+
central.writeUInt16LE(0x0800, 8);
|
|
36
|
+
central.writeUInt16LE(8, 10);
|
|
37
|
+
central.writeUInt16LE(time, 12);
|
|
38
|
+
central.writeUInt16LE(day, 14);
|
|
39
|
+
central.writeUInt32LE(checksum, 16);
|
|
40
|
+
central.writeUInt32LE(compressed.length, 20);
|
|
41
|
+
central.writeUInt32LE(data.length, 24);
|
|
42
|
+
central.writeUInt16LE(name.length, 28);
|
|
43
|
+
central.writeUInt32LE(offset, 42);
|
|
44
|
+
files.push(local, name, compressed);
|
|
45
|
+
directory.push(central, name);
|
|
46
|
+
offset += local.length + name.length + compressed.length;
|
|
47
|
+
}
|
|
48
|
+
const centralDirectory = Buffer.concat(directory);
|
|
49
|
+
const end = Buffer.alloc(22);
|
|
50
|
+
end.writeUInt32LE(0x06054b50, 0);
|
|
51
|
+
end.writeUInt16LE(entries.length, 8);
|
|
52
|
+
end.writeUInt16LE(entries.length, 10);
|
|
53
|
+
end.writeUInt32LE(centralDirectory.length, 12);
|
|
54
|
+
end.writeUInt32LE(offset, 16);
|
|
55
|
+
return Buffer.concat([...files, centralDirectory, end]);
|
|
56
|
+
}
|
|
57
|
+
export function writeZipArchive(filePath, entries) {
|
|
58
|
+
return writeFile(filePath, createZipArchive(entries));
|
|
59
|
+
}
|