@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/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
+ }