Skip to content

Conversation

@Leask
Copy link
Contributor

@Leask Leask commented Dec 4, 2025

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:

Screenshot 2025-12-04 at 12 40 24 PM

This is the revised effect, which meets expectations:

Screenshot 2025-12-04 at 1 40 44 PM

Copilot AI review requested due to automatic review settings December 4, 2025 18:44
Copy link
Member

@kylecarbs kylecarbs left a 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!

Copy link

Copilot AI left a 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.

Comment on lines 97 to 99
preventDefault: mock(() => { }),
stopPropagation: mock(() => { }),
};
Copy link

Copilot AI Dec 4, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines +306 to +402
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(['你好']);
});
});
Copy link

Copilot AI Dec 4, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines +306 to +402
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(['你好']);
});
});
Copy link

Copilot AI Dec 4, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines +306 to +402
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(['你好']);
});
});
Copy link

Copilot AI Dec 4, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines 247 to 250
this.compositionUpdateListener = this.handleCompositionUpdate.bind(this);
this.container.addEventListener(
'compositionupdate',
this.compositionUpdateListener
Copy link

Copilot AI Dec 4, 2025

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.

Copilot uses AI. Check for mistakes.
Comment on lines +312 to +313
// Ignore keydown events during composition
// Note: Some browsers send keyCode 229 for all keys during composition
Copy link

Copilot AI Dec 4, 2025

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.

Suggested change
// 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

Copilot uses AI. Check for mistakes.

describe('IME Composition', () => {
test('handles composition sequence', () => {
const handler = new InputHandler(
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused variable handler.

Suggested change
const handler = new InputHandler(
new InputHandler(

Copilot uses AI. Check for mistakes.
});

test('ignores keydown during composition', () => {
const handler = new InputHandler(
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused variable handler.

Copilot uses AI. Check for mistakes.
@kylecarbs
Copy link
Member

@Leask fmt is failing but the rest looks good to me. Fix that up and I'll merge!

@Leask
Copy link
Contributor Author

Leask commented Dec 4, 2025

@kylecarbs fixed! 😄 Thanks!

@kylecarbs kylecarbs merged commit ce706c1 into coder:main Dec 4, 2025
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants