@sanohiro/casty 0.5.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/lib/input.js ADDED
@@ -0,0 +1,545 @@
1
+ // Input event handling
2
+ // Receives keyboard/mouse events via stdin raw mode and forwards them to Chrome via CDP
3
+
4
+ import { toKeyName } from './keys.js';
5
+ import { UrlBar } from './urlbar.js';
6
+ import { HintMode } from './hints.js';
7
+
8
+ // SGR 1006 mouse button codes
9
+ const MOUSE_BTN_LEFT = 0;
10
+ const MOUSE_BTN_MOTION = 35; // Motion without button (mode 1003)
11
+ const MOUSE_BTN_DRAG = 32;
12
+ const MOUSE_SCROLL_UP = 64;
13
+ const MOUSE_SCROLL_DOWN = 65;
14
+ const SCROLL_DELTA = 100; // px
15
+
16
+ // OSC 52 clipboard response timeout
17
+ const CLIPBOARD_TIMEOUT = 1000; // ms
18
+
19
+ // Status message display duration
20
+ const STATUS_DISPLAY_MS = 2000;
21
+
22
+ // Enable mouse events (SGR 1006 format)
23
+ // Mode 1003: any-event tracking (reports motion even without button press)
24
+ // This ensures Chrome always knows cursor position for proper hover/click handling
25
+ export function enableMouse() {
26
+ process.stdout.write('\x1b[?1000;1003;1006h');
27
+ }
28
+
29
+ // Disable mouse events
30
+ export function disableMouse() {
31
+ process.stdout.write('\x1b[?1000;1003;1006l');
32
+ }
33
+
34
+ // Convert terminal cell coordinates to pixel coordinates
35
+ // row=2 is the top of the browser area (row=1 is the URL bar)
36
+ function cellToPixel(col, row, cellWidth, cellHeight) {
37
+ return {
38
+ x: (col - 1) * cellWidth,
39
+ y: (row - 2) * cellHeight,
40
+ };
41
+ }
42
+
43
+ // Send mouse event via CDP
44
+ async function dispatchMouse(client, type, x, y, button = 'left', clickCount = 0) {
45
+ await client.send('Input.dispatchMouseEvent', { type, x, y, button, clickCount });
46
+ }
47
+
48
+ // Send scroll event via CDP
49
+ async function dispatchScroll(client, x, y, deltaX, deltaY) {
50
+ await client.send('Input.dispatchMouseEvent', { type: 'mouseWheel', x, y, deltaX, deltaY });
51
+ }
52
+
53
+ // CDP modifiers bitmask: Alt=1, Ctrl=2, Meta=4, Shift=8
54
+ function modifierBits({ alt = false, ctrl = false, meta = false, shift = false } = {}) {
55
+ return (alt ? 1 : 0) | (ctrl ? 2 : 0) | (meta ? 4 : 0) | (shift ? 8 : 0);
56
+ }
57
+
58
+ // Send key event via CDP
59
+ async function dispatchKey(client, { key, code, keyCode, text, modifiers = 0 }) {
60
+ const base = { key, code, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode, modifiers };
61
+ await client.send('Input.dispatchKeyEvent', { type: 'rawKeyDown', ...base });
62
+ if (text) {
63
+ await client.send('Input.dispatchKeyEvent', { type: 'char', text, key, code, modifiers });
64
+ }
65
+ await client.send('Input.dispatchKeyEvent', { type: 'keyUp', ...base });
66
+ }
67
+
68
+ // ── Key mapping (terminal escape sequences → CDP key info) ──
69
+
70
+ const SPECIAL_KEYS = {
71
+ '\x1b[A': { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
72
+ '\x1b[B': { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
73
+ '\x1b[C': { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
74
+ '\x1b[D': { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
75
+ '\x1b[H': { key: 'Home', code: 'Home', keyCode: 36 },
76
+ '\x1b[F': { key: 'End', code: 'End', keyCode: 35 },
77
+ '\x1b[5~': { key: 'PageUp', code: 'PageUp', keyCode: 33 },
78
+ '\x1b[6~': { key: 'PageDown', code: 'PageDown', keyCode: 34 },
79
+ '\x1b[3~': { key: 'Delete', code: 'Delete', keyCode: 46 },
80
+ '\x7f': { key: 'Backspace', code: 'Backspace', keyCode: 8, text: '\x08' },
81
+ '\x08': { key: 'Backspace', code: 'Backspace', keyCode: 8, text: '\x08' },
82
+ '\r': { key: 'Enter', code: 'Enter', keyCode: 13, text: '\r' },
83
+ '\n': { key: 'Enter', code: 'Enter', keyCode: 13, text: '\r' },
84
+ '\t': { key: 'Tab', code: 'Tab', keyCode: 9, text: '\t' },
85
+ '\x1b': { key: 'Escape', code: 'Escape', keyCode: 27 },
86
+ ' ': { key: ' ', code: 'Space', keyCode: 32, text: ' ' },
87
+ '\x1bOP': { key: 'F1', code: 'F1', keyCode: 112 },
88
+ '\x1bOQ': { key: 'F2', code: 'F2', keyCode: 113 },
89
+ '\x1bOR': { key: 'F3', code: 'F3', keyCode: 114 },
90
+ '\x1bOS': { key: 'F4', code: 'F4', keyCode: 115 },
91
+ '\x1b[15~': { key: 'F5', code: 'F5', keyCode: 116 },
92
+ '\x1b[17~': { key: 'F6', code: 'F6', keyCode: 117 },
93
+ '\x1b[18~': { key: 'F7', code: 'F7', keyCode: 118 },
94
+ '\x1b[19~': { key: 'F8', code: 'F8', keyCode: 119 },
95
+ '\x1b[20~': { key: 'F9', code: 'F9', keyCode: 120 },
96
+ '\x1b[21~': { key: 'F10', code: 'F10', keyCode: 121 },
97
+ '\x1b[23~': { key: 'F11', code: 'F11', keyCode: 122 },
98
+ '\x1b[24~': { key: 'F12', code: 'F12', keyCode: 123 },
99
+ };
100
+
101
+ // Ctrl+Key (0x01-0x1A) → exclude collisions with Backspace/Tab/Enter
102
+ const CTRL_KEYS = {};
103
+ const CTRL_EXCLUDE = new Set([2, 7, 8, 12]); // C, H(BS), I(Tab), M(Enter)
104
+ for (let i = 0; i < 26; i++) {
105
+ if (CTRL_EXCLUDE.has(i)) continue;
106
+ const char = String.fromCharCode(i + 1);
107
+ const letter = String.fromCharCode(i + 97);
108
+ const upper = letter.toUpperCase();
109
+ CTRL_KEYS[char] = {
110
+ key: letter,
111
+ code: `Key${upper}`,
112
+ keyCode: upper.charCodeAt(0),
113
+ modifiers: modifierBits({ ctrl: true }),
114
+ };
115
+ }
116
+
117
+ // Base key info for modifier+key combinations
118
+ const MOD_SUFFIX = {
119
+ 'A': { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
120
+ 'B': { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
121
+ 'C': { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
122
+ 'D': { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
123
+ 'H': { key: 'Home', code: 'Home', keyCode: 36 },
124
+ 'F': { key: 'End', code: 'End', keyCode: 35 },
125
+ };
126
+
127
+ const MOD_TILDE = {
128
+ '3': { key: 'Delete', code: 'Delete', keyCode: 46 },
129
+ '5': { key: 'PageUp', code: 'PageUp', keyCode: 33 },
130
+ '6': { key: 'PageDown', code: 'PageDown', keyCode: 34 },
131
+ '15': { key: 'F5', code: 'F5', keyCode: 116 },
132
+ '17': { key: 'F6', code: 'F6', keyCode: 117 },
133
+ '18': { key: 'F7', code: 'F7', keyCode: 118 },
134
+ '19': { key: 'F8', code: 'F8', keyCode: 119 },
135
+ '20': { key: 'F9', code: 'F9', keyCode: 120 },
136
+ '21': { key: 'F10', code: 'F10', keyCode: 121 },
137
+ '23': { key: 'F11', code: 'F11', keyCode: 122 },
138
+ '24': { key: 'F12', code: 'F12', keyCode: 123 },
139
+ };
140
+
141
+ // macOS Option+char → base char map (US keyboard layout)
142
+ const MAC_OPTION_MAP = {
143
+ 'å': 'a', '∫': 'b', 'ç': 'c', '∂': 'd',
144
+ 'ƒ': 'f', '©': 'g', '˙': 'h', '∆': 'j',
145
+ '˚': 'k', '¬': 'l', 'µ': 'm', 'ø': 'o',
146
+ 'π': 'p', 'œ': 'q', '®': 'r', 'ß': 's', '†': 't',
147
+ '√': 'v', '∑': 'w', '≈': 'x', '¥': 'y', 'Ω': 'z',
148
+ };
149
+
150
+ function modBitsFromParam(p) {
151
+ const n = p - 1;
152
+ return modifierBits({ shift: !!(n & 1), alt: !!(n & 2), ctrl: !!(n & 4) });
153
+ }
154
+
155
+ // Pre-compiled regexes for parseInput (hot path)
156
+ const RE_MOD_ARROW = /^\x1b\[1;(\d+)([A-H])$/;
157
+ const RE_MOD_TILDE = /^\x1b\[(\d+);(\d+)~$/;
158
+
159
+ // Parse escape sequence into key info (with modifiers)
160
+ function parseInput(str) {
161
+ // Exact match for special keys
162
+ const special = SPECIAL_KEYS[str];
163
+ if (special) return { ...special, modifiers: 0 };
164
+
165
+ // Ctrl+Key (0x01-0x1A)
166
+ if (str.length === 1 && CTRL_KEYS[str]) return CTRL_KEYS[str];
167
+
168
+ // Modified arrows: ESC [ 1 ; <mod> <A-H>
169
+ let m = str.match(RE_MOD_ARROW);
170
+ if (m) {
171
+ const info = MOD_SUFFIX[m[2]];
172
+ if (info) return { ...info, modifiers: modBitsFromParam(parseInt(m[1], 10)) };
173
+ }
174
+
175
+ // Modified special keys: ESC [ <num> ; <mod> ~
176
+ m = str.match(RE_MOD_TILDE);
177
+ if (m) {
178
+ const info = MOD_TILDE[m[1]];
179
+ if (info) return { ...info, modifiers: modBitsFromParam(parseInt(m[2], 10)) };
180
+ }
181
+
182
+ // Alt+char: ESC + single char (not ESC [ or ESC O sequences)
183
+ if (str.length === 2 && str[0] === '\x1b' && str[1] !== '[' && str[1] !== 'O') {
184
+ const ch = str[1];
185
+ return {
186
+ key: ch,
187
+ code: `Key${ch.toUpperCase()}`,
188
+ keyCode: ch.toUpperCase().charCodeAt(0),
189
+ modifiers: modifierBits({ alt: true }),
190
+ };
191
+ }
192
+
193
+ // macOS Option+char: arrives as Unicode char → convert to alt+char
194
+ if (str.length === 1 && MAC_OPTION_MAP[str]) {
195
+ const ch = MAC_OPTION_MAP[str];
196
+ return {
197
+ key: ch,
198
+ code: `Key${ch.toUpperCase()}`,
199
+ keyCode: ch.toUpperCase().charCodeAt(0),
200
+ modifiers: modifierBits({ alt: true }),
201
+ };
202
+ }
203
+
204
+ // Normal characters
205
+ if (!str.startsWith('\x1b') && str.length >= 1) return 'text';
206
+
207
+ return undefined;
208
+ }
209
+
210
+ // ── OSC 52 clipboard ──
211
+
212
+ // Write to clipboard via OSC 52
213
+ function clipboardWrite(text) {
214
+ const b64 = Buffer.from(text).toString('base64');
215
+ process.stdout.write(`\x1b]52;c;${b64}\x07`);
216
+ }
217
+
218
+ // Read from clipboard via OSC 52
219
+ function clipboardRead() {
220
+ const { promise, resolve } = Promise.withResolvers();
221
+ const timeout = setTimeout(() => {
222
+ process.stdin.removeListener('data', onData);
223
+ resolve(null);
224
+ }, CLIPBOARD_TIMEOUT);
225
+
226
+ let buf = '';
227
+ const onData = (data) => {
228
+ buf += data.toString();
229
+ // OSC 52 response: ESC ] 52 ; c ; <base64> ESC \ or BEL
230
+ const m = buf.match(/\x1b\]52;c;([A-Za-z0-9+/=]*?)(?:\x1b\\|\x07)/);
231
+ if (m) {
232
+ clearTimeout(timeout);
233
+ process.stdin.removeListener('data', onData);
234
+ resolve(Buffer.from(m[1], 'base64').toString());
235
+ }
236
+ };
237
+ process.stdin.on('data', onData);
238
+
239
+ // Request clipboard read
240
+ process.stdout.write('\x1b]52;c;?\x07');
241
+ return promise;
242
+ }
243
+
244
+ // Get selected text via CDP
245
+ async function getSelection(client) {
246
+ try {
247
+ const { result } = await client.send('Runtime.evaluate', {
248
+ expression: 'window.getSelection().toString()',
249
+ });
250
+ return result.value || '';
251
+ } catch { return ''; }
252
+ }
253
+
254
+ // ── casty action execution ──
255
+
256
+ async function getHistoryEntry(client, delta) {
257
+ const { currentIndex, entries } = await client.send('Page.getNavigationHistory');
258
+ const idx = currentIndex + delta;
259
+ if (idx >= 0 && idx < entries.length) return { entryId: entries[idx].id };
260
+ return { entryId: entries[currentIndex].id };
261
+ }
262
+
263
+ // ── Main ──
264
+
265
+ // Track currentUrl locally (replacement for page.url())
266
+ // bindings: { "ctrl+q": "quit", "alt+left": "back", "alt+l": "url_bar", ... }
267
+ export function startInputHandling(client, cellWidth, cellHeight, bindings, pauseRender, forceCapture) {
268
+ process.stdin.setRawMode(true);
269
+ process.stdin.resume();
270
+
271
+ // Mutable cell dimensions (updated on resize via updateCellSize)
272
+ let _cellW = cellWidth;
273
+ let _cellH = cellHeight;
274
+
275
+ const keyToAction = bindings;
276
+ const urlBar = new UrlBar();
277
+ const hintMode = new HintMode(forceCapture);
278
+
279
+ // Capture after user input — immediate + delayed for Chrome rendering lag
280
+ const INPUT_CAPTURE_DELAY = 150; // ms
281
+ let _captureTimer = null;
282
+ function captureAfterInput() {
283
+ forceCapture();
284
+ clearTimeout(_captureTimer);
285
+ _captureTimer = setTimeout(forceCapture, INPUT_CAPTURE_DELAY);
286
+ }
287
+
288
+ // Throttled mouse motion (mode 1003 generates events for every pixel)
289
+ let pendingMotion = null;
290
+ let _motionTimer = null;
291
+ const MOTION_INTERVAL = 16; // ms (~60fps)
292
+ function flushMotion() {
293
+ if (pendingMotion) {
294
+ const { x, y } = pendingMotion;
295
+ pendingMotion = null;
296
+ dispatchMouse(client, 'mouseMoved', x, y, 'none').catch(() => {});
297
+ }
298
+ }
299
+ _motionTimer = setInterval(flushMotion, MOTION_INTERVAL);
300
+
301
+ // Track current URL locally
302
+ let currentUrl = '';
303
+
304
+ // Build rawBindings for macOS Option key (macOS only)
305
+ // On Linux, ESC+char collides with Alt+char byte sequences
306
+ const rawBindings = {};
307
+ if (process.platform === 'darwin') {
308
+ const MAC_OPTION_ARROWS = { 'alt+left': '\x1bb', 'alt+right': '\x1bf' };
309
+ for (const [keyName, action] of Object.entries(bindings)) {
310
+ const m = keyName.match(/^alt\+([a-z])$/);
311
+ if (m) {
312
+ const optChar = Object.entries(MAC_OPTION_MAP).find(([, v]) => v === m[1]);
313
+ if (optChar) rawBindings[optChar[0]] = action;
314
+ }
315
+ if (MAC_OPTION_ARROWS[keyName]) {
316
+ rawBindings[MAC_OPTION_ARROWS[keyName]] = action;
317
+ }
318
+ }
319
+ }
320
+
321
+ // Get initial URL from navigation history
322
+ urlBar.loading = true;
323
+ client.send('Page.getNavigationHistory').then(({ currentIndex, entries }) => {
324
+ if (entries[currentIndex]) {
325
+ urlBar.setUrl(entries[currentIndex].url);
326
+ }
327
+ }).catch(() => {});
328
+
329
+ // Update URL bar on page navigation (CDP events)
330
+ client.on('Page.frameNavigated', ({ frame }) => {
331
+ // Frame without parentId = main frame
332
+ if (!frame.parentId) {
333
+ currentUrl = frame.url;
334
+ urlBar.setUrl(currentUrl);
335
+ urlBar.loading = true;
336
+ }
337
+ });
338
+
339
+ // Loading complete
340
+ client.on('Page.loadEventFired', () => {
341
+ urlBar.loading = false;
342
+ });
343
+
344
+ // Download handling (CDP events)
345
+ client.on('Browser.downloadWillBegin', ({ suggestedFilename }) => {
346
+ urlBar.setStatus(`Downloading: ${suggestedFilename}`);
347
+ });
348
+ client.on('Browser.downloadProgress', ({ state }) => {
349
+ if (state === 'completed') {
350
+ urlBar.setStatus('Download complete');
351
+ setTimeout(() => urlBar.clearStatus(), 3000);
352
+ } else if (state === 'canceled') {
353
+ urlBar.setStatus('Download canceled');
354
+ setTimeout(() => urlBar.clearStatus(), 3000);
355
+ }
356
+ });
357
+
358
+ // Execute action
359
+ async function execAction(action) {
360
+ if (action === 'quit') { process.emit('SIGINT'); return true; }
361
+ if (action === 'url_bar') {
362
+ pauseRender();
363
+ const url = await urlBar.startEditing();
364
+ pauseRender(false);
365
+ if (url) {
366
+ urlBar.loading = true;
367
+ await client.send('Page.navigate', { url });
368
+ }
369
+ return true;
370
+ }
371
+ if (action === 'back') {
372
+ urlBar.loading = true;
373
+ await client.send('Page.navigateToHistoryEntry', await getHistoryEntry(client, -1));
374
+ return true;
375
+ }
376
+ if (action === 'forward') {
377
+ urlBar.loading = true;
378
+ await client.send('Page.navigateToHistoryEntry', await getHistoryEntry(client, +1));
379
+ return true;
380
+ }
381
+ if (action === 'copy') {
382
+ const text = await getSelection(client);
383
+ if (text) {
384
+ clipboardWrite(text);
385
+ urlBar.setStatus(`Copied: ${text.slice(0, 40)}${text.length > 40 ? '...' : ''}`);
386
+ setTimeout(() => urlBar.clearStatus(), STATUS_DISPLAY_MS);
387
+ }
388
+ return true;
389
+ }
390
+ if (action === 'paste') {
391
+ const text = await clipboardRead();
392
+ if (text) {
393
+ await client.send('Input.insertText', { text });
394
+ captureAfterInput();
395
+ }
396
+ return true;
397
+ }
398
+ if (action === 'hints') {
399
+ hintMode.start(client);
400
+ return true;
401
+ }
402
+ return false;
403
+ }
404
+
405
+ // ESC prefix buffer: when ESC arrives alone, wait for subsequent chars.
406
+ // On Linux terminals, Alt+Key arrives as ESC + Key (2 bytes), but async
407
+ // handler delays can split them. 50ms is sufficient even over SSH without
408
+ // noticeable input lag.
409
+ // On macOS, rawBindings handle Option key directly, so this buffer
410
+ // is primarily relevant on Linux.
411
+ let escBuf = '';
412
+ let escTimer = null;
413
+ const ESC_TIMEOUT = 50; // ms
414
+
415
+ process.stdin.on('data', (data) => {
416
+ let str = data.toString();
417
+
418
+ if (escTimer) {
419
+ clearTimeout(escTimer);
420
+ escTimer = null;
421
+ str = escBuf + str;
422
+ escBuf = '';
423
+ }
424
+
425
+ // ESC arrived alone — wait for subsequent chars
426
+ if (str === '\x1b') {
427
+ escBuf = str;
428
+ escTimer = setTimeout(() => {
429
+ escTimer = null;
430
+ const buf = escBuf;
431
+ escBuf = '';
432
+ handleInput(buf).catch(e => console.error('casty: input error:', e.message));
433
+ }, ESC_TIMEOUT);
434
+ return;
435
+ }
436
+
437
+ handleInput(str).catch(e => console.error('casty: input error:', e.message));
438
+ });
439
+
440
+ async function handleInput(str) {
441
+ // Hint mode active → route all input to hint mode
442
+ if (hintMode.active) {
443
+ await hintMode.handleInput(str);
444
+ return;
445
+ }
446
+
447
+ // URL bar editing → handle copy/paste here, rest goes to URL bar
448
+ if (urlBar.editing) {
449
+ const r = parseInput(str);
450
+ const kn = r && r !== 'text' ? toKeyName(r) : null;
451
+ const act = kn ? keyToAction[kn] : rawBindings[str];
452
+ if (act === 'paste') {
453
+ const text = await clipboardRead();
454
+ if (text) urlBar.insertText(text);
455
+ return;
456
+ }
457
+ if (act === 'copy') {
458
+ if (urlBar.text) {
459
+ clipboardWrite(urlBar.text);
460
+ urlBar.setStatus('Copied');
461
+ setTimeout(() => urlBar.clearStatus(), 1500);
462
+ }
463
+ return;
464
+ }
465
+ urlBar.handleInput(str);
466
+ return;
467
+ }
468
+
469
+ // SGR 1006 mouse event: ESC [ < Cb ; Cx ; Cy M/m
470
+ // Cb: 0=left click, 32=drag, 64/65=scroll up/down
471
+ // M=press, m=release
472
+ const mouseRe = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
473
+ let match;
474
+ let hadMouse = false;
475
+
476
+ while ((match = mouseRe.exec(str)) !== null) {
477
+ hadMouse = true;
478
+ const cb = parseInt(match[1]);
479
+ const col = parseInt(match[2]);
480
+ const row = parseInt(match[3]);
481
+ const release = match[4] === 'm';
482
+ const { x, y } = cellToPixel(col, row, _cellW, _cellH);
483
+
484
+
485
+ // Click on line 1 → open address bar
486
+ if (row === 1 && cb === MOUSE_BTN_LEFT && !release) {
487
+ await execAction('url_bar');
488
+ return;
489
+ }
490
+
491
+ if (cb === MOUSE_SCROLL_UP) {
492
+ await dispatchScroll(client, x, y, 0, -SCROLL_DELTA);
493
+ } else if (cb === MOUSE_SCROLL_DOWN) {
494
+ await dispatchScroll(client, x, y, 0, SCROLL_DELTA);
495
+ } else if (cb === MOUSE_BTN_LEFT) {
496
+ if (release) {
497
+ await dispatchMouse(client, 'mouseReleased', x, y, 'left');
498
+ } else {
499
+ await dispatchMouse(client, 'mousePressed', x, y, 'left', 1);
500
+ }
501
+ } else if (cb === MOUSE_BTN_DRAG) {
502
+ await dispatchMouse(client, 'mouseMoved', x, y, 'left');
503
+ } else if (cb === MOUSE_BTN_MOTION) {
504
+ // Mode 1003: motion without button — throttled to avoid flooding CDP
505
+ pendingMotion = { x, y };
506
+ }
507
+ }
508
+
509
+ if (hadMouse) { captureAfterInput(); return; }
510
+
511
+ // Check raw input bindings directly (macOS Option+char)
512
+ if (rawBindings[str]) {
513
+ await execAction(rawBindings[str]);
514
+ return;
515
+ }
516
+
517
+ // Check bindings via parseInput
518
+ const result = parseInput(str);
519
+ if (result && result !== 'text') {
520
+ const keyName = toKeyName(result);
521
+ const action = keyToAction[keyName];
522
+ if (action && await execAction(action)) return;
523
+ }
524
+
525
+ // Ctrl+C fallback
526
+ if (str === '\x03') {
527
+ process.emit('SIGINT');
528
+ return;
529
+ }
530
+
531
+ // Not a casty action → pass through to Chrome
532
+ if (result === 'text') {
533
+ await client.send('Input.insertText', { text: str });
534
+ captureAfterInput();
535
+ } else if (result) {
536
+ await dispatchKey(client, result);
537
+ captureAfterInput();
538
+ }
539
+ }
540
+
541
+ // Allow updating cell dimensions after resize
542
+ urlBar.updateCellSize = (w, h) => { _cellW = w; _cellH = h; };
543
+
544
+ return urlBar;
545
+ }
package/lib/keys.js ADDED
@@ -0,0 +1,51 @@
1
+ // Key binding configuration
2
+ // Customizable via ~/.casty/keys.json
3
+ // Only defines actions casty intercepts; everything else passes through to Chrome.
4
+
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { loadJsonFile } from './config.js';
8
+
9
+ const configPath = join(homedir(), '.casty', 'keys.json');
10
+
11
+ // Default key bindings
12
+ // Key names: "ctrl+c", "alt+left", "f5", "ctrl+shift+r", etc.
13
+ const DEFAULTS = {
14
+ 'ctrl+q': 'quit',
15
+ 'alt+left': 'back',
16
+ 'alt+right': 'forward',
17
+ 'alt+l': 'url_bar',
18
+ 'alt+c': 'copy',
19
+ 'ctrl+v': 'paste',
20
+ 'alt+f': 'hints',
21
+ };
22
+
23
+ // Load bindings (falls back to defaults)
24
+ export function loadKeyBindings() {
25
+ return { ...DEFAULTS, ...loadJsonFile(configPath, {}) };
26
+ }
27
+
28
+ // Build a human-readable key name from parsed input
29
+ // { key: 'ArrowLeft', modifiers: 1 } → "alt+left"
30
+ // { key: 'r', modifiers: 2 } → "ctrl+r"
31
+ // { key: 'F5', modifiers: 0 } → "f5"
32
+ export function toKeyName(info) {
33
+ const parts = [];
34
+ if (info.modifiers & 2) parts.push('ctrl');
35
+ if (info.modifiers & 1) parts.push('alt');
36
+ if (info.modifiers & 8) parts.push('shift');
37
+ if (info.modifiers & 4) parts.push('meta');
38
+
39
+ // Normalize key name
40
+ const k = info.key;
41
+ const normalized =
42
+ k === 'ArrowUp' ? 'up' :
43
+ k === 'ArrowDown' ? 'down' :
44
+ k === 'ArrowLeft' ? 'left' :
45
+ k === 'ArrowRight' ? 'right' :
46
+ k === ' ' ? 'space' :
47
+ k.toLowerCase();
48
+
49
+ parts.push(normalized);
50
+ return parts.join('+');
51
+ }