@dex-ai/vue-tui 0.1.10
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/package.json +51 -0
- package/src/app.ts +385 -0
- package/src/components/TuiCheckbox.ts +35 -0
- package/src/components/TuiField.ts +123 -0
- package/src/components/TuiSelect.ts +86 -0
- package/src/components/index.ts +7 -0
- package/src/composables/index.ts +19 -0
- package/src/composables/useFocusList.ts +101 -0
- package/src/composables/useForm.ts +335 -0
- package/src/composables/useSelectList.ts +199 -0
- package/src/env.d.ts +6 -0
- package/src/index.ts +131 -0
- package/src/input.ts +603 -0
- package/src/nodes.ts +153 -0
- package/src/panel.ts +51 -0
- package/src/plugin.ts +148 -0
- package/src/register.ts +4 -0
- package/src/render.ts +632 -0
- package/src/renderer.ts +120 -0
- package/src/style.ts +107 -0
- package/src/template.ts +326 -0
- package/src/text-buffer.ts +609 -0
- package/src/theme.ts +44 -0
- package/src/types/form.ts +90 -0
- package/src/widget-renderer.ts +237 -0
- package/src/widget.ts +326 -0
package/src/input.ts
ADDED
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Raw terminal input — escape sequence parsing + stdin management
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
export interface KeyEvent {
|
|
6
|
+
/** Named key: "up", "down", "left", "right", "enter", "backspace", "delete",
|
|
7
|
+
* "home", "end", "tab", "escape", or "char" for printable characters. */
|
|
8
|
+
name: string;
|
|
9
|
+
/** The printable character (only set when name === "char"). */
|
|
10
|
+
char?: string | undefined;
|
|
11
|
+
/** Shift modifier was held. */
|
|
12
|
+
shift?: boolean | undefined;
|
|
13
|
+
/** Alt/Meta modifier was held. */
|
|
14
|
+
alt?: boolean | undefined;
|
|
15
|
+
/** Ctrl modifier was held. */
|
|
16
|
+
ctrl?: boolean | undefined;
|
|
17
|
+
/** The raw escape sequence / byte string. */
|
|
18
|
+
raw: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type KeyHandler = (key: KeyEvent) => void;
|
|
22
|
+
|
|
23
|
+
export interface RawInputHandle {
|
|
24
|
+
/** Teardown: restore stdin raw mode, remove data listener. */
|
|
25
|
+
destroy(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse a raw stdin data chunk into a KeyEvent.
|
|
30
|
+
*/
|
|
31
|
+
export function parseKey(str: string): KeyEvent {
|
|
32
|
+
// Ctrl key combos (0x01 - 0x1a)
|
|
33
|
+
if (str.length === 1 && str.charCodeAt(0) >= 1 && str.charCodeAt(0) <= 26) {
|
|
34
|
+
const code = str.charCodeAt(0);
|
|
35
|
+
switch (code) {
|
|
36
|
+
case 0x01:
|
|
37
|
+
return { name: "home", ctrl: true, raw: str };
|
|
38
|
+
case 0x03:
|
|
39
|
+
return { name: "c", ctrl: true, raw: str };
|
|
40
|
+
case 0x04:
|
|
41
|
+
return { name: "delete", ctrl: true, raw: str };
|
|
42
|
+
case 0x05:
|
|
43
|
+
return { name: "end", ctrl: true, raw: str };
|
|
44
|
+
case 0x09:
|
|
45
|
+
return { name: "tab", raw: str };
|
|
46
|
+
case 0x0d:
|
|
47
|
+
return { name: "enter", raw: str };
|
|
48
|
+
default: {
|
|
49
|
+
const ch = String.fromCharCode(code + 96); // 0x01='a', 0x02='b', etc.
|
|
50
|
+
return { name: ch, ctrl: true, raw: str };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Enter (newline)
|
|
56
|
+
if (str === "\r" || str === "\n") {
|
|
57
|
+
return { name: "enter", raw: str };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Backspace
|
|
61
|
+
if (str === "\x7f" || str === "\b") {
|
|
62
|
+
return { name: "backspace", raw: str };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Alt+Enter
|
|
66
|
+
if (str === "\x1b\r" || str === "\x1b\n") {
|
|
67
|
+
return { name: "enter", alt: true, raw: str };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Escape alone
|
|
71
|
+
if (str === "\x1b") {
|
|
72
|
+
return { name: "escape", raw: str };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// CSI sequences: \x1b[...
|
|
76
|
+
if (str.startsWith("\x1b[")) {
|
|
77
|
+
// Kitty keyboard protocol: \x1b[<keycode>;<modifier>u or \x1b[<keycode>u
|
|
78
|
+
const kittyMatch = str.match(/^\x1b\[(\d+)(?:;(\d+))?u$/);
|
|
79
|
+
if (kittyMatch) {
|
|
80
|
+
const keycode = parseInt(kittyMatch[1]!, 10);
|
|
81
|
+
const modifier = kittyMatch[2] ? parseInt(kittyMatch[2], 10) : 1;
|
|
82
|
+
const shift = (modifier - 1) & 1 ? true : undefined;
|
|
83
|
+
const alt = (modifier - 1) & 2 ? true : undefined;
|
|
84
|
+
const ctrl = (modifier - 1) & 4 ? true : undefined;
|
|
85
|
+
|
|
86
|
+
// Map keycodes to named keys
|
|
87
|
+
switch (keycode) {
|
|
88
|
+
case 13:
|
|
89
|
+
return { name: "enter", shift, alt, ctrl, raw: str };
|
|
90
|
+
case 27:
|
|
91
|
+
return { name: "escape", shift, alt, ctrl, raw: str };
|
|
92
|
+
case 9:
|
|
93
|
+
return { name: "tab", shift, alt, ctrl, raw: str };
|
|
94
|
+
case 127:
|
|
95
|
+
return { name: "backspace", shift, alt, ctrl, raw: str };
|
|
96
|
+
default:
|
|
97
|
+
// Printable ASCII range — treat as character
|
|
98
|
+
if (keycode >= 32 && keycode <= 126) {
|
|
99
|
+
const ch = String.fromCharCode(keycode);
|
|
100
|
+
const name = ctrl ? ch : "char";
|
|
101
|
+
return {
|
|
102
|
+
name,
|
|
103
|
+
char: ctrl ? undefined : ch,
|
|
104
|
+
shift,
|
|
105
|
+
alt,
|
|
106
|
+
ctrl,
|
|
107
|
+
raw: str,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return { name: "unknown", shift, alt, ctrl, raw: str };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
switch (str) {
|
|
115
|
+
case "\x1b[A":
|
|
116
|
+
return { name: "up", raw: str };
|
|
117
|
+
case "\x1b[B":
|
|
118
|
+
return { name: "down", raw: str };
|
|
119
|
+
case "\x1b[C":
|
|
120
|
+
return { name: "right", raw: str };
|
|
121
|
+
case "\x1b[D":
|
|
122
|
+
return { name: "left", raw: str };
|
|
123
|
+
case "\x1b[H":
|
|
124
|
+
return { name: "home", raw: str };
|
|
125
|
+
case "\x1b[F":
|
|
126
|
+
return { name: "end", raw: str };
|
|
127
|
+
case "\x1b[Z":
|
|
128
|
+
return { name: "tab", shift: true, raw: str };
|
|
129
|
+
case "\x1b[3~":
|
|
130
|
+
return { name: "delete", raw: str };
|
|
131
|
+
case "\x1b[2~":
|
|
132
|
+
return { name: "insert", raw: str };
|
|
133
|
+
case "\x1b[5~":
|
|
134
|
+
return { name: "pageup", raw: str };
|
|
135
|
+
case "\x1b[6~":
|
|
136
|
+
return { name: "pagedown", raw: str };
|
|
137
|
+
|
|
138
|
+
// Ctrl+Arrow (non-kitty protocol) — \x1b[1;5* sequences
|
|
139
|
+
case "\x1b[1;5A":
|
|
140
|
+
return { name: "up", ctrl: true, raw: str };
|
|
141
|
+
case "\x1b[1;5B":
|
|
142
|
+
return { name: "down", ctrl: true, raw: str };
|
|
143
|
+
case "\x1b[1;5C":
|
|
144
|
+
return { name: "right", ctrl: true, raw: str };
|
|
145
|
+
case "\x1b[1;5D":
|
|
146
|
+
return { name: "left", ctrl: true, raw: str };
|
|
147
|
+
|
|
148
|
+
default:
|
|
149
|
+
// Unknown CSI sequence
|
|
150
|
+
return { name: "unknown", raw: str };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Alt + printable char: \x1b followed by a char
|
|
155
|
+
if (str.length === 2 && str[0] === "\x1b") {
|
|
156
|
+
return { name: "char", char: str[1], alt: true, raw: str };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Other escape sequences we don't handle
|
|
160
|
+
if (str.startsWith("\x1b")) {
|
|
161
|
+
return { name: "unknown", raw: str };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Printable character(s)
|
|
165
|
+
return { name: "char", char: str, raw: str };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Bracketed paste mode constants
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
const PASTE_START = "\x1b[200~";
|
|
173
|
+
const PASTE_END = "\x1b[201~";
|
|
174
|
+
|
|
175
|
+
/** Minimum length for a multi-line paste to trigger paste event (vs direct insert). */
|
|
176
|
+
const PASTE_THRESHOLD = 40;
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Detect whether raw stdin data is a bracketed paste and extract the content,
|
|
180
|
+
* or detect a multi-line paste heuristic (contains \n and >= threshold chars).
|
|
181
|
+
* Returns the paste content if detected, or null if it's a normal key.
|
|
182
|
+
*/
|
|
183
|
+
export function detectPaste(str: string): string | null {
|
|
184
|
+
// Bracketed paste mode: \x1b[200~...content...\x1b[201~
|
|
185
|
+
if (str.startsWith(PASTE_START)) {
|
|
186
|
+
const endIdx = str.indexOf(PASTE_END);
|
|
187
|
+
if (endIdx !== -1) {
|
|
188
|
+
return str.slice(PASTE_START.length, endIdx);
|
|
189
|
+
}
|
|
190
|
+
// No end marker — treat everything after start as paste content
|
|
191
|
+
return str.slice(PASTE_START.length);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Heuristic fallback: multi-line text >= threshold
|
|
195
|
+
if (str.includes("\n") && str.length >= PASTE_THRESHOLD) {
|
|
196
|
+
return str;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// System clipboard reading
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
import { spawnSync } from "node:child_process";
|
|
207
|
+
|
|
208
|
+
/** Clipboard content — either text or binary image data. */
|
|
209
|
+
export type ClipboardContent =
|
|
210
|
+
| { type: "text"; text: string }
|
|
211
|
+
| { type: "image"; data: Uint8Array; mediaType: string };
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Query available MIME types on the clipboard.
|
|
215
|
+
* Returns an array of type strings, or empty array on failure.
|
|
216
|
+
*/
|
|
217
|
+
function getClipboardTypes(): string[] {
|
|
218
|
+
const platform = process.platform;
|
|
219
|
+
try {
|
|
220
|
+
if (platform === "linux" && process.env.WAYLAND_DISPLAY) {
|
|
221
|
+
const result = spawnSync("wl-paste", ["--list-types"], {
|
|
222
|
+
encoding: "utf-8",
|
|
223
|
+
timeout: 2000,
|
|
224
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
225
|
+
});
|
|
226
|
+
if (result.status === 0 && result.stdout) {
|
|
227
|
+
return result.stdout.trim().split("\n").filter(Boolean);
|
|
228
|
+
}
|
|
229
|
+
} else if (platform === "linux") {
|
|
230
|
+
const result = spawnSync(
|
|
231
|
+
"xclip",
|
|
232
|
+
["-selection", "clipboard", "-t", "TARGETS", "-o"],
|
|
233
|
+
{
|
|
234
|
+
encoding: "utf-8",
|
|
235
|
+
timeout: 2000,
|
|
236
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
if (result.status === 0 && result.stdout) {
|
|
240
|
+
return result.stdout.trim().split("\n").filter(Boolean);
|
|
241
|
+
}
|
|
242
|
+
} else if (platform === "darwin") {
|
|
243
|
+
// macOS: use osascript to check clipboard info
|
|
244
|
+
const result = spawnSync("osascript", ["-e", "clipboard info"], {
|
|
245
|
+
encoding: "utf-8",
|
|
246
|
+
timeout: 2000,
|
|
247
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
248
|
+
});
|
|
249
|
+
if (result.status === 0 && result.stdout) {
|
|
250
|
+
// Returns lines like "«class PNGf», 12345"
|
|
251
|
+
const types: string[] = [];
|
|
252
|
+
for (const line of result.stdout.split(",")) {
|
|
253
|
+
if (line.includes("PNGf") || line.includes("png"))
|
|
254
|
+
types.push("image/png");
|
|
255
|
+
if (line.includes("TIFF") || line.includes("tiff"))
|
|
256
|
+
types.push("image/tiff");
|
|
257
|
+
if (line.includes("JPEG") || line.includes("jpeg"))
|
|
258
|
+
types.push("image/jpeg");
|
|
259
|
+
if (
|
|
260
|
+
line.includes("utf-8") ||
|
|
261
|
+
line.includes("ut16") ||
|
|
262
|
+
line.includes("TEXT")
|
|
263
|
+
)
|
|
264
|
+
types.push("text/plain");
|
|
265
|
+
}
|
|
266
|
+
return types;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
} catch {
|
|
270
|
+
// ignore
|
|
271
|
+
}
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Known image MIME types we support. */
|
|
276
|
+
const IMAGE_TYPES = [
|
|
277
|
+
"image/png",
|
|
278
|
+
"image/jpeg",
|
|
279
|
+
"image/gif",
|
|
280
|
+
"image/webp",
|
|
281
|
+
"image/tiff",
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Read image data from the clipboard as raw bytes.
|
|
286
|
+
* Automatically downscales large images (>4MB or >1920px) for API compatibility.
|
|
287
|
+
* Returns the data + mediaType, or null if not available.
|
|
288
|
+
*/
|
|
289
|
+
function readClipboardImage(): { data: Uint8Array; mediaType: string } | null {
|
|
290
|
+
const platform = process.platform;
|
|
291
|
+
// Pick the best image type available
|
|
292
|
+
const types = getClipboardTypes();
|
|
293
|
+
const imageType = types.find((t) => IMAGE_TYPES.includes(t));
|
|
294
|
+
if (!imageType) return null;
|
|
295
|
+
|
|
296
|
+
let raw: { data: Uint8Array; mediaType: string } | null = null;
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
if (platform === "linux" && process.env.WAYLAND_DISPLAY) {
|
|
300
|
+
const result = spawnSync(
|
|
301
|
+
"wl-paste",
|
|
302
|
+
["--no-newline", "--type", imageType],
|
|
303
|
+
{
|
|
304
|
+
timeout: 5000,
|
|
305
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
306
|
+
maxBuffer: 50 * 1024 * 1024, // 50MB
|
|
307
|
+
},
|
|
308
|
+
);
|
|
309
|
+
if (result.status === 0 && result.stdout && result.stdout.length > 0) {
|
|
310
|
+
raw = { data: new Uint8Array(result.stdout), mediaType: imageType };
|
|
311
|
+
}
|
|
312
|
+
} else if (platform === "linux") {
|
|
313
|
+
const result = spawnSync(
|
|
314
|
+
"xclip",
|
|
315
|
+
["-selection", "clipboard", "-t", imageType, "-o"],
|
|
316
|
+
{
|
|
317
|
+
timeout: 5000,
|
|
318
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
319
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
320
|
+
},
|
|
321
|
+
);
|
|
322
|
+
if (result.status === 0 && result.stdout && result.stdout.length > 0) {
|
|
323
|
+
raw = { data: new Uint8Array(result.stdout), mediaType: imageType };
|
|
324
|
+
}
|
|
325
|
+
} else if (platform === "darwin") {
|
|
326
|
+
// macOS: use osascript to write clipboard image to temp file, then read it
|
|
327
|
+
const tmpPath = `/tmp/dex-clipboard-${Date.now()}.png`;
|
|
328
|
+
const script = `
|
|
329
|
+
set theFile to POSIX file "${tmpPath}"
|
|
330
|
+
try
|
|
331
|
+
set imgData to the clipboard as «class PNGf»
|
|
332
|
+
set fp to open for access theFile with write permission
|
|
333
|
+
write imgData to fp
|
|
334
|
+
close access fp
|
|
335
|
+
on error
|
|
336
|
+
return "error"
|
|
337
|
+
end try
|
|
338
|
+
return "ok"
|
|
339
|
+
`;
|
|
340
|
+
const result = spawnSync("osascript", ["-e", script], {
|
|
341
|
+
encoding: "utf-8",
|
|
342
|
+
timeout: 5000,
|
|
343
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
344
|
+
});
|
|
345
|
+
if (result.status === 0 && result.stdout?.trim() === "ok") {
|
|
346
|
+
const { readFileSync, unlinkSync } = require("node:fs");
|
|
347
|
+
try {
|
|
348
|
+
const data = readFileSync(tmpPath);
|
|
349
|
+
unlinkSync(tmpPath);
|
|
350
|
+
raw = { data: new Uint8Array(data), mediaType: "image/png" };
|
|
351
|
+
} catch {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
} catch {
|
|
357
|
+
// ignore
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (!raw) return null;
|
|
361
|
+
|
|
362
|
+
// Downscale large images to keep within provider content-length limits
|
|
363
|
+
return downscaleImage(raw.data, raw.mediaType);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Max image dimension (width or height) — images larger than 1920px get downscaled. */
|
|
367
|
+
const MAX_IMAGE_DIMENSION = 1920;
|
|
368
|
+
/** Max image byte size before we attempt to compress/downscale (4MB). */
|
|
369
|
+
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Attempt to downscale/compress image data to fit within provider limits.
|
|
373
|
+
* Uses ImageMagick's `convert` (or macOS `sips`) to resize images that are
|
|
374
|
+
* larger than 1920px on their longest side, and re-compress to reduce byte size.
|
|
375
|
+
* Returns the original data if no resizing tools are available.
|
|
376
|
+
*/
|
|
377
|
+
function downscaleImage(
|
|
378
|
+
data: Uint8Array,
|
|
379
|
+
mediaType: string,
|
|
380
|
+
): { data: Uint8Array; mediaType: string } {
|
|
381
|
+
// If already small enough, skip
|
|
382
|
+
if (data.length <= MAX_IMAGE_BYTES) return { data, mediaType };
|
|
383
|
+
|
|
384
|
+
const platform = process.platform;
|
|
385
|
+
try {
|
|
386
|
+
if (platform === "darwin") {
|
|
387
|
+
// macOS: use sips to resize. Write to temp, resize, read back
|
|
388
|
+
const { writeFileSync, readFileSync, unlinkSync } = require("node:fs");
|
|
389
|
+
const tmpIn = `/tmp/dex-resize-in-${Date.now()}.png`;
|
|
390
|
+
const tmpOut = `/tmp/dex-resize-out-${Date.now()}.jpg`;
|
|
391
|
+
writeFileSync(tmpIn, data);
|
|
392
|
+
// sips can resize by longest side and output JPEG for compression
|
|
393
|
+
spawnSync(
|
|
394
|
+
"sips",
|
|
395
|
+
[
|
|
396
|
+
"--resampleHeightWidthMax",
|
|
397
|
+
String(MAX_IMAGE_DIMENSION),
|
|
398
|
+
"--setProperty",
|
|
399
|
+
"format",
|
|
400
|
+
"jpeg",
|
|
401
|
+
"--setProperty",
|
|
402
|
+
"formatOptions",
|
|
403
|
+
"80",
|
|
404
|
+
tmpIn,
|
|
405
|
+
"--out",
|
|
406
|
+
tmpOut,
|
|
407
|
+
],
|
|
408
|
+
{ timeout: 10000, stdio: "pipe" },
|
|
409
|
+
);
|
|
410
|
+
try {
|
|
411
|
+
const resized = readFileSync(tmpOut);
|
|
412
|
+
unlinkSync(tmpIn);
|
|
413
|
+
unlinkSync(tmpOut);
|
|
414
|
+
return { data: new Uint8Array(resized), mediaType: "image/jpeg" };
|
|
415
|
+
} catch {
|
|
416
|
+
try {
|
|
417
|
+
unlinkSync(tmpIn);
|
|
418
|
+
} catch {}
|
|
419
|
+
try {
|
|
420
|
+
unlinkSync(tmpOut);
|
|
421
|
+
} catch {}
|
|
422
|
+
}
|
|
423
|
+
} else {
|
|
424
|
+
// Linux/other: use ImageMagick convert via stdin/stdout
|
|
425
|
+
const result = spawnSync(
|
|
426
|
+
"convert",
|
|
427
|
+
[
|
|
428
|
+
"-", // read from stdin
|
|
429
|
+
"-resize",
|
|
430
|
+
`${MAX_IMAGE_DIMENSION}x${MAX_IMAGE_DIMENSION}>`, // only shrink
|
|
431
|
+
"-quality",
|
|
432
|
+
"80",
|
|
433
|
+
"jpeg:-", // output JPEG to stdout
|
|
434
|
+
],
|
|
435
|
+
{
|
|
436
|
+
input: Buffer.from(data),
|
|
437
|
+
timeout: 10000,
|
|
438
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
439
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
440
|
+
},
|
|
441
|
+
);
|
|
442
|
+
if (result.status === 0 && result.stdout && result.stdout.length > 0) {
|
|
443
|
+
return { data: new Uint8Array(result.stdout), mediaType: "image/jpeg" };
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
} catch {
|
|
447
|
+
// Resizing unavailable — return original
|
|
448
|
+
}
|
|
449
|
+
return { data, mediaType };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Read text from the system clipboard using platform-native commands.
|
|
454
|
+
* Returns the text content or null if reading fails.
|
|
455
|
+
*/
|
|
456
|
+
function readClipboardText(): string | null {
|
|
457
|
+
const platform = process.platform;
|
|
458
|
+
let cmd: string;
|
|
459
|
+
let args: string[];
|
|
460
|
+
|
|
461
|
+
if (platform === "darwin") {
|
|
462
|
+
cmd = "pbpaste";
|
|
463
|
+
args = [];
|
|
464
|
+
} else if (platform === "linux") {
|
|
465
|
+
if (process.env.WAYLAND_DISPLAY) {
|
|
466
|
+
cmd = "wl-paste";
|
|
467
|
+
args = ["--no-newline"];
|
|
468
|
+
} else {
|
|
469
|
+
cmd = "xclip";
|
|
470
|
+
args = ["-selection", "clipboard", "-o"];
|
|
471
|
+
}
|
|
472
|
+
} else if (platform === "win32") {
|
|
473
|
+
cmd = "powershell.exe";
|
|
474
|
+
args = ["-NoProfile", "-Command", "Get-Clipboard"];
|
|
475
|
+
} else {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
try {
|
|
480
|
+
const result = spawnSync(cmd, args, {
|
|
481
|
+
encoding: "utf-8",
|
|
482
|
+
timeout: 2000,
|
|
483
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
484
|
+
});
|
|
485
|
+
if (result.status === 0 && result.stdout) {
|
|
486
|
+
return result.stdout;
|
|
487
|
+
}
|
|
488
|
+
if (platform === "linux" && !process.env.WAYLAND_DISPLAY) {
|
|
489
|
+
const fallback = spawnSync("xsel", ["--clipboard", "--output"], {
|
|
490
|
+
encoding: "utf-8",
|
|
491
|
+
timeout: 2000,
|
|
492
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
493
|
+
});
|
|
494
|
+
if (fallback.status === 0 && fallback.stdout) {
|
|
495
|
+
return fallback.stdout;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return null;
|
|
499
|
+
} catch {
|
|
500
|
+
return null;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Read from the system clipboard. Detects whether content is an image or text.
|
|
506
|
+
* Prefers image if available (since text is usually also offered as a target
|
|
507
|
+
* even for image data — e.g. file path). Returns null if clipboard is empty.
|
|
508
|
+
*/
|
|
509
|
+
export function readClipboard(): ClipboardContent | null {
|
|
510
|
+
const types = getClipboardTypes();
|
|
511
|
+
const hasImage = types.some((t) => IMAGE_TYPES.includes(t));
|
|
512
|
+
const hasText = types.some(
|
|
513
|
+
(t) =>
|
|
514
|
+
t.startsWith("text/") ||
|
|
515
|
+
t === "UTF8_STRING" ||
|
|
516
|
+
t === "STRING" ||
|
|
517
|
+
t === "TEXT",
|
|
518
|
+
);
|
|
519
|
+
|
|
520
|
+
// Prefer image if only image is present or if image types exist without plain text indicators
|
|
521
|
+
// But if both text and image exist, check if text is just a file path (short) → prefer image
|
|
522
|
+
if (hasImage) {
|
|
523
|
+
const img = readClipboardImage();
|
|
524
|
+
if (img) return { type: "image", ...img };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (hasText) {
|
|
528
|
+
const text = readClipboardText();
|
|
529
|
+
if (text) return { type: "text", text };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Fallback: try text read without type detection
|
|
533
|
+
const text = readClipboardText();
|
|
534
|
+
if (text) return { type: "text", text };
|
|
535
|
+
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Set up raw stdin input and call `handler` for each parsed key event.
|
|
541
|
+
*
|
|
542
|
+
* @param handler Called for each key press
|
|
543
|
+
* @param stdin Optional stdin stream (defaults to process.stdin)
|
|
544
|
+
*/
|
|
545
|
+
export function createRawInput(
|
|
546
|
+
handler: KeyHandler,
|
|
547
|
+
stdin?: NodeJS.ReadStream,
|
|
548
|
+
): RawInputHandle {
|
|
549
|
+
const stream = stdin ?? process.stdin;
|
|
550
|
+
|
|
551
|
+
if (stream.isTTY) {
|
|
552
|
+
stream.setRawMode(true);
|
|
553
|
+
}
|
|
554
|
+
stream.resume();
|
|
555
|
+
|
|
556
|
+
const onData = (data: Buffer): void => {
|
|
557
|
+
const str = data.toString();
|
|
558
|
+
|
|
559
|
+
// Check for bracketed paste / heuristic paste before normal key parsing
|
|
560
|
+
const pasteContent = detectPaste(str);
|
|
561
|
+
if (pasteContent !== null) {
|
|
562
|
+
handler({ name: "paste", char: pasteContent, raw: str });
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const key = parseKey(str);
|
|
567
|
+
|
|
568
|
+
// Ctrl+V — read from system clipboard and emit as paste event
|
|
569
|
+
if (key.name === "v" && key.ctrl) {
|
|
570
|
+
const clipboard = readClipboard();
|
|
571
|
+
if (clipboard) {
|
|
572
|
+
if (clipboard.type === "text") {
|
|
573
|
+
handler({ name: "paste", char: clipboard.text, raw: str });
|
|
574
|
+
} else {
|
|
575
|
+
// Image paste — encode in char as JSON for transport through KeyEvent
|
|
576
|
+
handler({
|
|
577
|
+
name: "paste-image",
|
|
578
|
+
char: JSON.stringify({
|
|
579
|
+
data: Buffer.from(clipboard.data).toString("base64"),
|
|
580
|
+
mediaType: clipboard.mediaType,
|
|
581
|
+
}),
|
|
582
|
+
raw: str,
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
handler(key);
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
stream.on("data", onData);
|
|
593
|
+
|
|
594
|
+
return {
|
|
595
|
+
destroy(): void {
|
|
596
|
+
stream.removeListener("data", onData);
|
|
597
|
+
if (stream.isTTY) {
|
|
598
|
+
stream.setRawMode(false);
|
|
599
|
+
}
|
|
600
|
+
stream.pause();
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
}
|