Skip to content

Commit 2322b00

Browse files
authored
feat: add paste support to InputHandler (#15)
* feat: add paste support to InputHandler ## Problem Terminal had working copy (auto-copy on selection) but paste was completely missing. Users couldn't paste text using Ctrl+V, Cmd+V, or right-click paste. ## Solution Added complete paste support to InputHandler: 1. **Paste Event Listener**: Listens for browser paste events on container 2. **handlePaste Method**: Extracts clipboard text and sends to terminal 3. **Ctrl+V/Cmd+V Support**: Bypasses normal keydown handling to allow paste 4. **Proper Cleanup**: Removes paste listener on dispose() ## Features ✅ Ctrl+V / Cmd+V keyboard shortcuts ✅ Right-click → Paste (browser context menu) ✅ Multi-line paste support ✅ Empty/null clipboard handling ✅ Focus-aware (only works when terminal focused) ## Tests Added 6 comprehensive tests: - Single-line paste - Multi-line paste - Empty clipboard handling - Null clipboard data handling - Ctrl+V shortcut behavior - Cmd+V shortcut behavior All 40 tests passing (6 new + 34 existing) ## Terminal Behavior Notes - **Copy**: Auto-copy on mouse selection (no Ctrl+C copy) - **Paste**: Ctrl+V / Cmd+V / Right-click - **Ctrl+C**: Sends SIGINT (interrupt signal) - standard terminal behavior This matches behavior of iTerm2, Terminal.app, Windows Terminal, etc. ## Files Changed - lib/input-handler.ts: Added paste event handling - lib/input-handler.test.ts: Added mock ClipboardEvent + 6 tests ## Future Enhancements - Bracketed paste mode (\x1b[200~ ... \x1b[201~) - Paste confirmation for large text blocks - Rich text → plain text conversion - Paste rate limiting * fix: prevent Cmd+C from outputting 'c' character on Mac ## Problem When pressing Cmd+C (Command+C on Mac) to copy selected text, the terminal would output a 'c' character in addition to copying. This happened because: 1. metaKey (Cmd) wasn't checked in isPrintableCharacter() 2. Cmd+C was being treated as a printable character 3. The 'c' was sent to the terminal output ## Solution Two-part fix: 1. **Check metaKey in isPrintableCharacter()** - Added check: `if (event.metaKey) return false;` - Prevents ANY Cmd+key combo from being treated as printable 2. **Explicit Cmd+C handling** - Added early return for Cmd+C (similar to Cmd+V) - Allows SelectionManager to handle copy - Prevents any character from being sent to terminal ## Behavior - **Ctrl+C**: Sends interrupt signal (0x03) - standard terminal - **Cmd+C** (Mac): Triggers copy, no output - Mac standard - **Selection**: Auto-copies on mouse selection (all platforms) - **Cmd+V** (Mac): Triggers paste This matches native Mac terminal behavior where: - Cmd+C = Copy - Cmd+V = Paste - Ctrl+C = Interrupt (SIGINT) ## Tests Added test: "Cmd+C allows copy (no data sent)" - Verifies Cmd+C doesn't send any data to terminal - All 41 tests passing ## Files Changed - lib/input-handler.ts: Added metaKey check + Cmd+C handling - lib/input-handler.test.ts: Added Cmd+C test Fixes #issue (user reported Cmd+C outputting 'c')
1 parent 2262634 commit 2322b00

2 files changed

Lines changed: 213 additions & 8 deletions

File tree

lib/input-handler.test.ts

Lines changed: 152 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,19 @@ interface MockKeyboardEvent {
2020
stopPropagation: () => void;
2121
}
2222

23+
interface MockClipboardEvent {
24+
type: string;
25+
clipboardData: {
26+
getData: (format: string) => string;
27+
setData: (format: string, data: string) => void;
28+
} | null;
29+
preventDefault: () => void;
30+
stopPropagation: () => void;
31+
}
32+
2333
interface MockHTMLElement {
24-
addEventListener: (event: string, handler: (e: MockKeyboardEvent) => void) => void;
25-
removeEventListener: (event: string, handler: (e: MockKeyboardEvent) => void) => void;
34+
addEventListener: (event: string, handler: (e: any) => void) => void;
35+
removeEventListener: (event: string, handler: (e: any) => void) => void;
2636
}
2737

2838
// Helper to create mock keyboard event
@@ -44,21 +54,42 @@ function createKeyEvent(
4454
};
4555
}
4656

57+
// Helper to create mock clipboard event
58+
function createClipboardEvent(
59+
text: string | null
60+
): MockClipboardEvent {
61+
const data = new Map<string, string>();
62+
if (text !== null) {
63+
data.set('text/plain', text);
64+
}
65+
66+
return {
67+
type: 'paste',
68+
clipboardData: text !== null ? {
69+
getData: (format: string) => data.get(format) || '',
70+
setData: (format: string, value: string) => { data.set(format, value); },
71+
} : null,
72+
preventDefault: mock(() => {}),
73+
stopPropagation: mock(() => {}),
74+
};
75+
}
76+
4777
// Helper to create mock container
4878
function createMockContainer(): MockHTMLElement & {
49-
_listeners: Map<string, ((e: MockKeyboardEvent) => void)[]>
79+
_listeners: Map<string, ((e: any) => void)[]>;
80+
dispatchEvent: (event: any) => void;
5081
} {
51-
const listeners = new Map<string, ((e: MockKeyboardEvent) => void)[]>();
82+
const listeners = new Map<string, ((e: any) => void)[]>();
5283

5384
return {
5485
_listeners: listeners,
55-
addEventListener(event: string, handler: (e: MockKeyboardEvent) => void) {
86+
addEventListener(event: string, handler: (e: any) => void) {
5687
if (!listeners.has(event)) {
5788
listeners.set(event, []);
5889
}
5990
listeners.get(event)!.push(handler);
6091
},
61-
removeEventListener(event: string, handler: (e: MockKeyboardEvent) => void) {
92+
removeEventListener(event: string, handler: (e: any) => void) {
6293
const handlers = listeners.get(event);
6394
if (handlers) {
6495
const index = handlers.indexOf(handler);
@@ -67,6 +98,12 @@ function createMockContainer(): MockHTMLElement & {
6798
}
6899
}
69100
},
101+
dispatchEvent(event: any) {
102+
const handlers = listeners.get(event.type) || [];
103+
for (const handler of handlers) {
104+
handler(event);
105+
}
106+
},
70107
};
71108
}
72109

@@ -276,6 +313,21 @@ describe('InputHandler', () => {
276313
// Ctrl+Z should produce 0x1A (26)
277314
expect(dataReceived[0].charCodeAt(0)).toBe(0x1A);
278315
});
316+
317+
test('Cmd+C allows copy (no data sent)', () => {
318+
const handler = new InputHandler(
319+
ghostty,
320+
container as any,
321+
(data) => dataReceived.push(data),
322+
() => { bellCalled = true; }
323+
);
324+
325+
simulateKey(container, createKeyEvent('KeyC', 'c', { meta: true }));
326+
327+
// Cmd+C should NOT send data - it should allow copy operation
328+
// SelectionManager handles the actual copying
329+
expect(dataReceived.length).toBe(0);
330+
});
279331
});
280332

281333
describe('Special Keys', () => {
@@ -610,4 +662,98 @@ describe('InputHandler', () => {
610662
expect(dataReceived[0].length).toBeGreaterThan(0);
611663
});
612664
});
665+
666+
describe('Clipboard Operations', () => {
667+
test('handles paste event', () => {
668+
const handler = new InputHandler(
669+
ghostty,
670+
container as any,
671+
(data) => dataReceived.push(data),
672+
() => { bellCalled = true; }
673+
);
674+
675+
const pasteText = 'Hello, World!';
676+
const pasteEvent = createClipboardEvent(pasteText);
677+
678+
container.dispatchEvent(pasteEvent);
679+
680+
expect(dataReceived.length).toBe(1);
681+
expect(dataReceived[0]).toBe(pasteText);
682+
});
683+
684+
test('handles multi-line paste', () => {
685+
const handler = new InputHandler(
686+
ghostty,
687+
container as any,
688+
(data) => dataReceived.push(data),
689+
() => { bellCalled = true; }
690+
);
691+
692+
const pasteText = 'Line 1\nLine 2\nLine 3';
693+
const pasteEvent = createClipboardEvent(pasteText);
694+
695+
container.dispatchEvent(pasteEvent);
696+
697+
expect(dataReceived.length).toBe(1);
698+
expect(dataReceived[0]).toBe(pasteText);
699+
});
700+
701+
test('ignores paste with no clipboard data', () => {
702+
const handler = new InputHandler(
703+
ghostty,
704+
container as any,
705+
(data) => dataReceived.push(data),
706+
() => { bellCalled = true; }
707+
);
708+
709+
const pasteEvent = createClipboardEvent(null);
710+
711+
container.dispatchEvent(pasteEvent);
712+
713+
expect(dataReceived.length).toBe(0);
714+
});
715+
716+
test('ignores paste with empty text', () => {
717+
const handler = new InputHandler(
718+
ghostty,
719+
container as any,
720+
(data) => dataReceived.push(data),
721+
() => { bellCalled = true; }
722+
);
723+
724+
const pasteEvent = createClipboardEvent('');
725+
726+
container.dispatchEvent(pasteEvent);
727+
728+
expect(dataReceived.length).toBe(0);
729+
});
730+
731+
test('allows Ctrl+V to trigger paste', () => {
732+
const handler = new InputHandler(
733+
ghostty,
734+
container as any,
735+
(data) => dataReceived.push(data),
736+
() => { bellCalled = true; }
737+
);
738+
739+
// Ctrl+V should NOT call onData callback (lets paste event handle it)
740+
simulateKey(container, createKeyEvent('KeyV', 'v', { ctrl: true }));
741+
742+
expect(dataReceived.length).toBe(0);
743+
});
744+
745+
test('allows Cmd+V to trigger paste', () => {
746+
const handler = new InputHandler(
747+
ghostty,
748+
container as any,
749+
(data) => dataReceived.push(data),
750+
() => { bellCalled = true; }
751+
);
752+
753+
// Cmd+V should NOT call onData callback (lets paste event handle it)
754+
simulateKey(container, createKeyEvent('KeyV', 'v', { meta: true }));
755+
756+
expect(dataReceived.length).toBe(0);
757+
});
758+
});
613759
});

lib/input-handler.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,7 @@ export class InputHandler {
164164
private onBellCallback: () => void;
165165
private keydownListener: ((e: KeyboardEvent) => void) | null = null;
166166
private keypressListener: ((e: KeyboardEvent) => void) | null = null;
167+
private pasteListener: ((e: ClipboardEvent) => void) | null = null;
167168
private isDisposed = false;
168169

169170
/**
@@ -207,6 +208,9 @@ export class InputHandler {
207208

208209
this.keydownListener = this.handleKeyDown.bind(this);
209210
this.container.addEventListener('keydown', this.keydownListener);
211+
212+
this.pasteListener = this.handlePaste.bind(this);
213+
this.container.addEventListener('paste', this.pasteListener);
210214
}
211215

212216
/**
@@ -244,10 +248,11 @@ export class InputHandler {
244248
* @returns true if printable character
245249
*/
246250
private isPrintableCharacter(event: KeyboardEvent): boolean {
247-
// If Ctrl or Alt is pressed (but not AltGr which is Ctrl+Alt on some keyboards)
248-
// then it's not a simple printable character
251+
// If Ctrl, Alt, or Meta (Cmd on Mac) is pressed, it's not a simple printable character
252+
// Exception: AltGr (Ctrl+Alt on some keyboards) can produce printable characters
249253
if (event.ctrlKey && !event.altKey) return false;
250254
if (event.altKey && !event.ctrlKey) return false;
255+
if (event.metaKey) return false; // Cmd key on Mac
251256

252257
// If key produces a single printable character
253258
return event.key.length === 1;
@@ -260,6 +265,22 @@ export class InputHandler {
260265
private handleKeyDown(event: KeyboardEvent): void {
261266
if (this.isDisposed) return;
262267

268+
// Allow Ctrl+V and Cmd+V to trigger paste event (don't preventDefault)
269+
if ((event.ctrlKey || event.metaKey) && event.code === 'KeyV') {
270+
// Let the browser's native paste event fire
271+
console.log('[InputHandler] ⌨️ Ctrl/Cmd+V detected, allowing paste event');
272+
return;
273+
}
274+
275+
// Allow Cmd+C for copy (on Mac, Cmd+C should copy, not send interrupt)
276+
// SelectionManager handles the actual copying
277+
// Note: Ctrl+C on all platforms sends interrupt signal (0x03)
278+
if (event.metaKey && event.code === 'KeyC') {
279+
// Let browser/SelectionManager handle copy
280+
console.log('[InputHandler] ⌨️ Cmd+C detected, allowing copy');
281+
return;
282+
}
283+
263284
// For printable characters without modifiers, send the character directly
264285
// This handles: a-z, A-Z (with shift), 0-9, punctuation, etc.
265286
if (this.isPrintableCharacter(event)) {
@@ -410,6 +431,39 @@ export class InputHandler {
410431
}
411432
}
412433

434+
/**
435+
* Handle paste event from clipboard
436+
* @param event - ClipboardEvent
437+
*/
438+
private handlePaste(event: ClipboardEvent): void {
439+
if (this.isDisposed) return;
440+
441+
// Prevent default paste behavior
442+
event.preventDefault();
443+
event.stopPropagation();
444+
445+
// Get clipboard data
446+
const clipboardData = event.clipboardData;
447+
if (!clipboardData) {
448+
console.warn('No clipboard data available');
449+
return;
450+
}
451+
452+
// Get text from clipboard
453+
const text = clipboardData.getData('text/plain');
454+
if (!text) {
455+
console.warn('No text in clipboard');
456+
return;
457+
}
458+
459+
console.log('[InputHandler] 📋 Pasting text:', text.substring(0, 50) + (text.length > 50 ? '...' : ''));
460+
461+
// Send the text to the terminal
462+
// Note: For bracketed paste mode, we would wrap this in \x1b[200~ ... \x1b[201~
463+
// but for now, send raw text
464+
this.onDataCallback(text);
465+
}
466+
413467
/**
414468
* Dispose the InputHandler and remove event listeners
415469
*/
@@ -426,6 +480,11 @@ export class InputHandler {
426480
this.keypressListener = null;
427481
}
428482

483+
if (this.pasteListener) {
484+
this.container.removeEventListener('paste', this.pasteListener);
485+
this.pasteListener = null;
486+
}
487+
429488
this.isDisposed = true;
430489
}
431490

0 commit comments

Comments
 (0)