-
Notifications
You must be signed in to change notification settings - Fork 37
Added support for IME input for languages such as Chinese and Japanese. #90
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
kylecarbs
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Very cool. This looks good to me!
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR implements Input Method Editor (IME) support for Chinese, Japanese, and other languages that require composition-based input. The implementation adds event listeners for composition events and prevents duplicate input during the composition process.
Key Changes:
- Added composition event handling (compositionstart, compositionupdate, compositionend) to track IME input state
- Modified keydown handler to ignore keyboard events during composition to prevent duplicate input
- Implemented text node cleanup after composition to prevent visual duplicates in the terminal
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 10 comments.
| File | Description |
|---|---|
| lib/input-handler.ts | Added composition event listeners and handlers, modified keydown to ignore events during composition, implemented text node cleanup logic |
| lib/input-handler.test.ts | Added comprehensive IME composition tests covering composition sequences, keydown ignoring, and text node cleanup; updated mock infrastructure to support composition events |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
lib/input-handler.test.ts
Outdated
| preventDefault: mock(() => { }), | ||
| stopPropagation: mock(() => { }), | ||
| }; |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] Inconsistent formatting: same empty arrow function formatting issue { } vs {}. This should match the project's established style.
| describe('IME Composition', () => { | ||
| test('handles composition sequence', () => { | ||
| const handler = new InputHandler( | ||
| ghostty, | ||
| container as any, | ||
| (data) => dataReceived.push(data), | ||
| () => { | ||
| bellCalled = true; | ||
| } | ||
| ); | ||
|
|
||
| // Start composition | ||
| const startEvent = createCompositionEvent('compositionstart', ''); | ||
| container.dispatchEvent(startEvent); | ||
|
|
||
| // Update composition (typing) | ||
| const updateEvent1 = createCompositionEvent('compositionupdate', 'n'); | ||
| container.dispatchEvent(updateEvent1); | ||
|
|
||
| // Keydown events during composition should be ignored | ||
| const keyEvent1 = createKeyEvent('KeyN', 'n'); | ||
| Object.defineProperty(keyEvent1, 'isComposing', { value: true }); | ||
| simulateKey(container, keyEvent1); | ||
|
|
||
| // Update composition (more typing) | ||
| const updateEvent2 = createCompositionEvent('compositionupdate', 'ni'); | ||
| container.dispatchEvent(updateEvent2); | ||
|
|
||
| // End composition (commit) | ||
| const endEvent = createCompositionEvent('compositionend', '你好'); | ||
| container.dispatchEvent(endEvent); | ||
|
|
||
| // Should only receive the final committed text | ||
| expect(dataReceived).toEqual(['你好']); | ||
| }); | ||
|
|
||
| test('ignores keydown during composition', () => { | ||
| const handler = new InputHandler( | ||
| ghostty, | ||
| container as any, | ||
| (data) => dataReceived.push(data), | ||
| () => { | ||
| bellCalled = true; | ||
| } | ||
| ); | ||
|
|
||
| // Start composition | ||
| container.dispatchEvent(createCompositionEvent('compositionstart', '')); | ||
|
|
||
| // Simulate keydown with isComposing=true | ||
| const keyEvent = createKeyEvent('KeyA', 'a'); | ||
| Object.defineProperty(keyEvent, 'isComposing', { value: true }); | ||
| simulateKey(container, keyEvent); | ||
|
|
||
| // Simulate keydown with keyCode 229 | ||
| const keyEvent229 = createKeyEvent('KeyB', 'b'); | ||
| Object.defineProperty(keyEvent229, 'keyCode', { value: 229 }); | ||
| simulateKey(container, keyEvent229); | ||
|
|
||
| // Should not receive any data | ||
| expect(dataReceived.length).toBe(0); | ||
|
|
||
| // End composition | ||
| container.dispatchEvent(createCompositionEvent('compositionend', 'a')); | ||
| expect(dataReceived).toEqual(['a']); | ||
| }); | ||
|
|
||
| test('cleans up text nodes in container after composition', () => { | ||
| const handler = new InputHandler( | ||
| ghostty, | ||
| container as any, | ||
| (data) => dataReceived.push(data), | ||
| () => { | ||
| bellCalled = true; | ||
| } | ||
| ); | ||
|
|
||
| // Simulate browser inserting text node during composition | ||
| const textNode = { nodeType: 3, textContent: '你好' } as Node; | ||
| container.appendChild(textNode); | ||
|
|
||
| // Also add a non-text node (e.g. canvas) to ensure it's not removed | ||
| const elementNode = { nodeType: 1, nodeName: 'CANVAS' } as Node; | ||
| container.appendChild(elementNode); | ||
|
|
||
| expect(container.childNodes.length).toBe(2); | ||
|
|
||
| // End composition | ||
| const endEvent = createCompositionEvent('compositionend', '你好'); | ||
| container.dispatchEvent(endEvent); | ||
|
|
||
| // Should have removed the text node but kept the element node | ||
| expect(container.childNodes.length).toBe(1); | ||
| expect(container.childNodes[0]).toBe(elementNode); | ||
| expect(dataReceived).toEqual(['你好']); | ||
| }); | ||
| }); |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The existing dispose removes listeners and marks inactive test should be updated to verify that the new composition event listeners (compositionstart, compositionupdate, compositionend) are also properly removed during disposal. This would ensure complete cleanup of all event listeners.
| describe('IME Composition', () => { | ||
| test('handles composition sequence', () => { | ||
| const handler = new InputHandler( | ||
| ghostty, | ||
| container as any, | ||
| (data) => dataReceived.push(data), | ||
| () => { | ||
| bellCalled = true; | ||
| } | ||
| ); | ||
|
|
||
| // Start composition | ||
| const startEvent = createCompositionEvent('compositionstart', ''); | ||
| container.dispatchEvent(startEvent); | ||
|
|
||
| // Update composition (typing) | ||
| const updateEvent1 = createCompositionEvent('compositionupdate', 'n'); | ||
| container.dispatchEvent(updateEvent1); | ||
|
|
||
| // Keydown events during composition should be ignored | ||
| const keyEvent1 = createKeyEvent('KeyN', 'n'); | ||
| Object.defineProperty(keyEvent1, 'isComposing', { value: true }); | ||
| simulateKey(container, keyEvent1); | ||
|
|
||
| // Update composition (more typing) | ||
| const updateEvent2 = createCompositionEvent('compositionupdate', 'ni'); | ||
| container.dispatchEvent(updateEvent2); | ||
|
|
||
| // End composition (commit) | ||
| const endEvent = createCompositionEvent('compositionend', '你好'); | ||
| container.dispatchEvent(endEvent); | ||
|
|
||
| // Should only receive the final committed text | ||
| expect(dataReceived).toEqual(['你好']); | ||
| }); | ||
|
|
||
| test('ignores keydown during composition', () => { | ||
| const handler = new InputHandler( | ||
| ghostty, | ||
| container as any, | ||
| (data) => dataReceived.push(data), | ||
| () => { | ||
| bellCalled = true; | ||
| } | ||
| ); | ||
|
|
||
| // Start composition | ||
| container.dispatchEvent(createCompositionEvent('compositionstart', '')); | ||
|
|
||
| // Simulate keydown with isComposing=true | ||
| const keyEvent = createKeyEvent('KeyA', 'a'); | ||
| Object.defineProperty(keyEvent, 'isComposing', { value: true }); | ||
| simulateKey(container, keyEvent); | ||
|
|
||
| // Simulate keydown with keyCode 229 | ||
| const keyEvent229 = createKeyEvent('KeyB', 'b'); | ||
| Object.defineProperty(keyEvent229, 'keyCode', { value: 229 }); | ||
| simulateKey(container, keyEvent229); | ||
|
|
||
| // Should not receive any data | ||
| expect(dataReceived.length).toBe(0); | ||
|
|
||
| // End composition | ||
| container.dispatchEvent(createCompositionEvent('compositionend', 'a')); | ||
| expect(dataReceived).toEqual(['a']); | ||
| }); | ||
|
|
||
| test('cleans up text nodes in container after composition', () => { | ||
| const handler = new InputHandler( | ||
| ghostty, | ||
| container as any, | ||
| (data) => dataReceived.push(data), | ||
| () => { | ||
| bellCalled = true; | ||
| } | ||
| ); | ||
|
|
||
| // Simulate browser inserting text node during composition | ||
| const textNode = { nodeType: 3, textContent: '你好' } as Node; | ||
| container.appendChild(textNode); | ||
|
|
||
| // Also add a non-text node (e.g. canvas) to ensure it's not removed | ||
| const elementNode = { nodeType: 1, nodeName: 'CANVAS' } as Node; | ||
| container.appendChild(elementNode); | ||
|
|
||
| expect(container.childNodes.length).toBe(2); | ||
|
|
||
| // End composition | ||
| const endEvent = createCompositionEvent('compositionend', '你好'); | ||
| container.dispatchEvent(endEvent); | ||
|
|
||
| // Should have removed the text node but kept the element node | ||
| expect(container.childNodes.length).toBe(1); | ||
| expect(container.childNodes[0]).toBe(elementNode); | ||
| expect(dataReceived).toEqual(['你好']); | ||
| }); | ||
| }); |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Edge case not tested: What happens if composition is interrupted (e.g., user clicks away or presses Escape)? The isComposing flag would remain true if compositionend is never fired. Consider adding a test for this scenario or adding a mechanism to reset the composition state when the container loses focus or when dispose is called.
| describe('IME Composition', () => { | ||
| test('handles composition sequence', () => { | ||
| const handler = new InputHandler( | ||
| ghostty, | ||
| container as any, | ||
| (data) => dataReceived.push(data), | ||
| () => { | ||
| bellCalled = true; | ||
| } | ||
| ); | ||
|
|
||
| // Start composition | ||
| const startEvent = createCompositionEvent('compositionstart', ''); | ||
| container.dispatchEvent(startEvent); | ||
|
|
||
| // Update composition (typing) | ||
| const updateEvent1 = createCompositionEvent('compositionupdate', 'n'); | ||
| container.dispatchEvent(updateEvent1); | ||
|
|
||
| // Keydown events during composition should be ignored | ||
| const keyEvent1 = createKeyEvent('KeyN', 'n'); | ||
| Object.defineProperty(keyEvent1, 'isComposing', { value: true }); | ||
| simulateKey(container, keyEvent1); | ||
|
|
||
| // Update composition (more typing) | ||
| const updateEvent2 = createCompositionEvent('compositionupdate', 'ni'); | ||
| container.dispatchEvent(updateEvent2); | ||
|
|
||
| // End composition (commit) | ||
| const endEvent = createCompositionEvent('compositionend', '你好'); | ||
| container.dispatchEvent(endEvent); | ||
|
|
||
| // Should only receive the final committed text | ||
| expect(dataReceived).toEqual(['你好']); | ||
| }); | ||
|
|
||
| test('ignores keydown during composition', () => { | ||
| const handler = new InputHandler( | ||
| ghostty, | ||
| container as any, | ||
| (data) => dataReceived.push(data), | ||
| () => { | ||
| bellCalled = true; | ||
| } | ||
| ); | ||
|
|
||
| // Start composition | ||
| container.dispatchEvent(createCompositionEvent('compositionstart', '')); | ||
|
|
||
| // Simulate keydown with isComposing=true | ||
| const keyEvent = createKeyEvent('KeyA', 'a'); | ||
| Object.defineProperty(keyEvent, 'isComposing', { value: true }); | ||
| simulateKey(container, keyEvent); | ||
|
|
||
| // Simulate keydown with keyCode 229 | ||
| const keyEvent229 = createKeyEvent('KeyB', 'b'); | ||
| Object.defineProperty(keyEvent229, 'keyCode', { value: 229 }); | ||
| simulateKey(container, keyEvent229); | ||
|
|
||
| // Should not receive any data | ||
| expect(dataReceived.length).toBe(0); | ||
|
|
||
| // End composition | ||
| container.dispatchEvent(createCompositionEvent('compositionend', 'a')); | ||
| expect(dataReceived).toEqual(['a']); | ||
| }); | ||
|
|
||
| test('cleans up text nodes in container after composition', () => { | ||
| const handler = new InputHandler( | ||
| ghostty, | ||
| container as any, | ||
| (data) => dataReceived.push(data), | ||
| () => { | ||
| bellCalled = true; | ||
| } | ||
| ); | ||
|
|
||
| // Simulate browser inserting text node during composition | ||
| const textNode = { nodeType: 3, textContent: '你好' } as Node; | ||
| container.appendChild(textNode); | ||
|
|
||
| // Also add a non-text node (e.g. canvas) to ensure it's not removed | ||
| const elementNode = { nodeType: 1, nodeName: 'CANVAS' } as Node; | ||
| container.appendChild(elementNode); | ||
|
|
||
| expect(container.childNodes.length).toBe(2); | ||
|
|
||
| // End composition | ||
| const endEvent = createCompositionEvent('compositionend', '你好'); | ||
| container.dispatchEvent(endEvent); | ||
|
|
||
| // Should have removed the text node but kept the element node | ||
| expect(container.childNodes.length).toBe(1); | ||
| expect(container.childNodes[0]).toBe(elementNode); | ||
| expect(dataReceived).toEqual(['你好']); | ||
| }); | ||
| }); |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing test coverage: The test suite doesn't cover the case where compositionend fires with empty or null data (e.g., when composition is cancelled). The implementation checks if (data && data.length > 0) before calling the callback, but this path isn't tested. Add a test to verify that cancelling composition doesn't send any data to the terminal.
lib/input-handler.ts
Outdated
| this.compositionUpdateListener = this.handleCompositionUpdate.bind(this); | ||
| this.container.addEventListener( | ||
| 'compositionupdate', | ||
| this.compositionUpdateListener |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The compositionupdate event handler is registered but doesn't perform any operations beyond the disposal check. Consider whether this event listener is necessary, or document why it's included for future extensibility. If it's kept for potential future use, the comment on lines 537-539 should be more explicit about this being a placeholder for future enhancements.
| // Ignore keydown events during composition | ||
| // Note: Some browsers send keyCode 229 for all keys during composition |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The condition checks three separate ways to detect composition: this.isComposing (internal state), event.isComposing (standard property), and event.keyCode === 229 (legacy approach). While this provides good cross-browser compatibility, the comment should clarify that keyCode 229 is a legacy fallback for older browsers, as keyCode is deprecated. Consider if all three checks are still necessary for the target browsers.
| // Ignore keydown events during composition | |
| // Note: Some browsers send keyCode 229 for all keys during composition | |
| // Ignore keydown events during composition. | |
| // We check three ways for cross-browser compatibility: | |
| // - this.isComposing: internal state set by composition events | |
| // - event.isComposing: standard property supported by modern browsers | |
| // - event.keyCode === 229: legacy fallback for older browsers (e.g., IE/Edge), as keyCode is deprecated |
|
|
||
| describe('IME Composition', () => { | ||
| test('handles composition sequence', () => { | ||
| const handler = new InputHandler( |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unused variable handler.
| const handler = new InputHandler( | |
| new InputHandler( |
| }); | ||
|
|
||
| test('ignores keydown during composition', () => { | ||
| const handler = new InputHandler( |
Copilot
AI
Dec 4, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unused variable handler.
|
@Leask fmt is failing but the rest looks good to me. Fix that up and I'll merge! |
|
@kylecarbs fixed! 😄 Thanks! |
Initial support for Chinese, Japanese, and other input methods has been implemented. This is a preliminary version that may require further optimization, but it stably achieves the target functionality without negative impacts.
Compared to before the modification, input was not possible:
This is the revised effect, which meets expectations: