Skip to content

Conversation

tdkn
Copy link

@tdkn tdkn commented Aug 9, 2025

🧢 Changes

  • Fixed IME (Input Method Editor) composition handling in commit title editor for CJK language input
  • Added composition state tracking using InputEvent.isComposing property
  • Implemented workaround for specific key events during IME composition to prevent premature focus shifts

☕️ Reasoning

Background

When typing in Japanese, Chinese, or Korean, users need to press the Enter key to confirm character conversion during IME composition. However, GitButler's commit editor was incorrectly interpreting this Enter key press as a signal to move focus to the next input field, disrupting the text input process.

Previous Attempt

PR #9615 attempted to fix this by checking the isComposing property of keyboard events. However, this solution didn't work properly because WebKit browsers (Safari) incorrectly set e.isComposing to false when Enter is pressed during IME composition, as discovered in this comment. This led to the revert in #9752.

Current Solution

This fix tracks the IME composition state using InputEvent.isComposing from the oninput event and prevents focus changes when:

  • Enter key is pressed during active IME composition
  • Tab key is pressed during active IME composition

Additionally, it includes a workaround for certain keys (Enter, Escape, number keys) that may terminate IME composition, ensuring the composition state is properly reset.

Technical Details

  • Uses oninput event to track isComposing state from InputEvent
  • Checks composition state before processing Enter/Tab navigation keys
  • Manually resets composition flag for keys that typically terminate IME input

Impact

This fix resolves a critical usability issue that has been preventing CJK (Chinese, Japanese, Korean) users from effectively using GitButler. The inability to properly input text in their native languages when writing commit messages was likely a dealbreaker that caused many potential users from these regions to abandon GitButler. With this fix, GitButler becomes truly accessible to millions of developers across East Asia, opening up a significant user base that was previously unable to adopt the tool.

🎫 Affected issues

Fixes: #9611

Copy link

vercel bot commented Aug 9, 2025

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Project Deployment Preview Comments Updated (UTC)
gitbutler-components Ready Preview Comment Aug 12, 2025 2:11pm

Copy link

vercel bot commented Aug 9, 2025

@tdkn is attempting to deploy a commit to the GitButler Team on Vercel.

A member of the Team first needs to authorize it.

@Byron
Copy link
Collaborator

Byron commented Aug 10, 2025

Thanks so much for tackling this!

The PR looks great to me, but then again, my previous one also did ;). Let's wait for the professionals to arrive.

Lastly, I find it quite shocking that the company that touts accessibility messes this up and either broke it recently, or still didn't fix something as simple as a boolean flag. Probably there is a whole story behind that if one would be digging into it.

@nshcr
Copy link
Contributor

nshcr commented Aug 10, 2025

Great PR! I've also noticed this issue when using Chinese IME, though compared to Japanese IME (where Enter is commonly used to confirm input), Chinese IME tends to use number keys for candidate selection, so the impact is relatively smaller.
I applied this PR locally to test it with Chinese IME and found a small issue: if you select characters with number key and then press Enter, the focus doesn't move — instead, it just inserts a new line in the input box. This might be a problem.

Kapture.2025-08-10.at.19.09.45.mp4

@Byron
Copy link
Collaborator

Byron commented Aug 10, 2025

Thanks so much for chiming in @nshcr! I was wondering if this fix would be universal and kind of assumed so, but as we all know, assumptions are errors (albeit convenient).

Now I wonder how it's possible to implement this correctly? In theory, it doesn't matter if IME composition is ongoing, but it matters if something in that composition is selectable using the enter key. In Chinese, that's generally not the case.

Would that mean we have to know which language is being typed to decide if Enter is special or not?

@nshcr
Copy link
Contributor

nshcr commented Aug 10, 2025

@Byron In fact, the approach you used in your previous PR (#9615) with the agent mode was correct.
The only thing is that isComposing was placed inside the onkeydown callback, which receives a KeyboardEvent object, check the KeyboardEvent.isComposing1.
Since onkeydown is triggered after pressing Enter to confirm input, the composition state has already transitioned to compositionend, so KeyboardEvent.isComposing is always false.
Simply moving the isComposing check to the oninput callback (which receives an InputEvent object, check InputEvent.isComposing2) will resolve the issue, without having to worry about which IME the user is using.

@tdkn
Copy link
Author

tdkn commented Aug 10, 2025

@nshcr @Byron
Thank you for showing interest in this issue🙏 I apologize for the lack of testing with Chinese input.
I’m not confident that I fully understand @nshcr’s comment below, so could you provide a code suggestion?

Simply moving the isComposing check to the oninput callback (which receives an InputEvent object, check InputEvent.isComposing2) will resolve the issue, without having to worry about which IME the user is using.

Also, based on my testing, it seems that the following fix may also resolve the Chinese input issue that @nshcr encountered.

diff --git a/apps/desktop/src/components/CommitMessageEditor.svelte b/apps/desktop/src/components/CommitMessageEditor.svelte
index b35f67569..165cfe25a 100644
--- a/apps/desktop/src/components/CommitMessageEditor.svelte
+++ b/apps/desktop/src/components/CommitMessageEditor.svelte
@@ -170,12 +170,14 @@
 			// Track IME composition state manually for WebKit compatibility
 			isTitleComposing = true;
 		}}
+		onkeyup={(e: KeyboardEvent) => {
+			isTitleComposing = e.isComposing;
+		}}
 		onkeydown={async (e: KeyboardEvent) => {
 			// Prevent focus movement when Enter is pressed during IME composition
 			// for Japanese/Chinese/Korean input confirmation.
 			// WebKit sets e.isComposing to false on Enter, so we use isTitleComposing as fallback
 			if (e.key === 'Enter' && (e.isComposing || isTitleComposing)) {
-				isTitleComposing = false;
 				return;
 			}
 			// Submit commit with Ctrl/Cmd + Enter
diff --git a/apps/desktop/src/components/editor/MessageEditorInput.svelte b/apps/desktop/src/components/editor/MessageEditorInput.svelte
index e73064e57..23f6de824 100644
--- a/apps/desktop/src/components/editor/MessageEditorInput.svelte
+++ b/apps/desktop/src/components/editor/MessageEditorInput.svelte
@@ -10,6 +10,7 @@
 		oninput?: (e: Event) => void;
 		onchange?: (value: string) => void;
 		oncompositionstart?: CompositionEventHandler<HTMLTextAreaElement>;
+		onkeyup?: (e: KeyboardEvent) => void;
 		onkeydown: (e: KeyboardEvent) => void;
 		testId?: string;
 	}
@@ -22,6 +23,7 @@
 		oninput,
 		onchange,
 		oncompositionstart,
+		onkeyup,
 		onkeydown,
 		testId
 	}: Props = $props();
@@ -49,6 +51,7 @@
 		unstyled
 		onchange={(e) => onchange?.(e.currentTarget.value)}
 		{oncompositionstart}
+		{onkeyup}
 		{onkeydown}
 	/>
 	{#if isCharCount}
CleanShot.2025-08-11.at.08.58.58.mp4

To help better understand the differences in isComposing behavior across browser engines, I’ve created the following Svelte Playground.
Please feel free to make use of it: https://svelte.dev/playground/c6c9bd82e6fa43e786bb494e5abb8f76?version=5.38.0

if (e.key === 'Enter' || (e.key === 'Tab' && !e.shiftKey)) {
e.preventDefault();
composer?.focus();
}
// Cancel commit with Escape
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's remove these two one-liner comments and keep ones that communicate something that isn't clear from the code itself?

// Prevent focus movement when Enter is pressed during IME composition
// for Japanese/Chinese/Korean input confirmation.
// WebKit sets e.isComposing to false on Enter, so we use isTitleComposing as fallback
if (e.key === 'Enter' && (e.isComposing || isTitleComposing)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we need to check e.isComposing when we have isTitleComposing from the oncompositionstart event?

@@ -53,7 +53,8 @@
onchange,
onfocus,
onblur,
onkeydown
onkeydown,
...restProps
Copy link
Contributor

@mtsgrd mtsgrd Aug 11, 2025

Choose a reason for hiding this comment

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

We currently don't use restProps really anywhere in our code base, could we replace this with oncompositionstart for the sake of consistency?

@nshcr
Copy link
Contributor

nshcr commented Aug 11, 2025

@tdkn Sorry for my unexplained reply, here is my proposed solution (not fully tested, but I've tried it briefly with both Chinese and Japanese IMEs, and it seems to work well).

Subject: [PATCH] diff
---
Index: apps/desktop/src/components/CommitMessageEditor.svelte
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/apps/desktop/src/components/CommitMessageEditor.svelte b/apps/desktop/src/components/CommitMessageEditor.svelte
--- a/apps/desktop/src/components/CommitMessageEditor.svelte	(revision 8b4f2ae3dee056b5712c74c12cefcae14747aac8)
+++ b/apps/desktop/src/components/CommitMessageEditor.svelte	(date 1754953196615)
@@ -68,6 +68,7 @@
 	let composer = $state<ReturnType<typeof MessageEditor>>();
 	let titleInput = $state<HTMLTextAreaElement>();
+	let isComposing = $state(false);
 
 	const suggestionsHandler = new CommitSuggestions(aiService, uiState);
 	const diffInputArgs = $derived<DiffInputContextArgs>(
@@ -165,14 +166,29 @@
 		onchange={(value) => {
 			onChange?.({ title: value });
 		}}
+		oninput={(e: InputEvent) => {
+			isComposing = e.isComposing;
+		}}
 		onkeydown={async (e: KeyboardEvent) => {
+			if ((e.key === 'Enter' || e.key === 'Escape') && isComposing) {
+				e.preventDefault();
+				isComposing = false;
+				return;
+			}
+			if (['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes(e.key)) {
+				e.preventDefault();
+				if (isComposing === true) {
+					isComposing = false;
+				}
+				return;
+			}
 			if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
 				e.preventDefault();
 				if (title.trim()) {
 					emitAction();
 				}
 			}
-			if (e.key === 'Enter' || (e.key === 'Tab' && !e.shiftKey)) {
+			if ((e.key === 'Enter' || (e.key === 'Tab' && !e.shiftKey)) && !isComposing) {
 				e.preventDefault();
 				composer?.focus();
 			}

The key points are:

  1. In oninput, use a separate isComposing variable to track composition state, similar to what you did in onkeyup.
  2. Since oninput doesn’t capture Enter key events, this callback alone can't immediately update isComposing to false.

To address this, I added two extra handlers in onkeydown:

  1. When Enter or Escape is pressed and isComposing is true, set it to false — typically pressing these keys means exiting composition (Escape handling is added to prevent cancelling commit while still composing).
  2. Because Chinese IME can input via number keys (0–9), I added handling for these as well: if a number key is pressed while isComposing is true, set it to false.
    • For Japanese IME, I'm less familiar, but in my testing, entering numbers produces full-width digits and does not exit composition. Setting isComposing to false here does not interfere with later Enter handling (this might need more testing).

This solution is quite similar to your PR, but with fewer code changes. The main differences are:

  1. It never relies on KeyboardEvent.isComposing — as mentioned earlier, in onkeydown, isComposing will always be false when handling Enter.
  2. It doesn't add event callbacks (onkeyup, oncompositionstart) that weren't in the original code.
  3. It accounts for both input modes (number keys and Enter), as well as Escape handling.

Of course, it's entirely up to you how you'd like to improve your PR. If you decide to incorporate some of my ideas, feel free to add me as a co-author in your commit — I'd be happy to see that :).


EDIT:

I've updated it with a more concise implementation, maybe helpful.

+ if (['Enter', 'Escape', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9'].includes(e.key) && isComposing) {
+     e.preventDefault();
+     isComposing = false;
+     return;
+ }

@tdkn
Copy link
Author

tdkn commented Aug 12, 2025

Thank you all for reviewing the code.
I’ll clean it up and push the changes shortly, so please wait a moment 🙏.
May I add @nshcr and @Byron as co-authors?
If so, is the information below correct?

Co-authored-by: Byron <[email protected]>
Co-authored-by: nshcr <[email protected]>

@nshcr
Copy link
Contributor

nshcr commented Aug 12, 2025

@tdkn I tested the playground you provided in different browsers and now fully understand what you mean.
Only Safari reports isComposing as false when entering the keydown event for Enter.
It seems that in Safari (WebKit), compositionend occurs before keydown, whereas in Chromium and Firefox, keydown comes before compositionend.

It's definitely an interesting issue. I dug around a bit and found a discussion about it.
https://bugs.webkit.org/show_bug.cgi?id=165004

This is also causing problems when trying to rely on the isComposing property of KeyboardEvent and InputEvent. The Enter key that accepts the IME input will be sent with isComposing === false, because Safari is sending events out of order, and keydown/input are being sent after compositionend. This makes comparing keyCode === 229 the only reliable way to detect whether IME is being used or not, even though modern APIs are available, because they are giving the wrong results.

See https://www.w3.org/TR/uievents/#events-composition-key-events and https://www.w3.org/TR/uievents/#events-composition-input-events for the spec. It is very specific that the keydown event that exits composition must be sent with isComposing === true, but Safari sends them with isComposing === false.

An ancient bug still haunting modern developers — and once again, Apple is the one behind it🤔.

My proposed solution doesn't rely on KeyboardEvent and deliberately skips one Enter keydown event to mark the end of composition, so it should be able to work around this issue.

BTW, @Byron maybe this is the story you wanted.

@Byron
Copy link
Collaborator

Byron commented Aug 12, 2025

Thanks so much for digging into this @nshcr! And yes, I am also shocked by this Safari/WebKit bug, and very surprised by Apple here. Maybe this is the prime reason that most switch to Chrome - after all, it's much more compliant and thus usable.

This issue makes handling this consistently difficult, particularly among the various components that seem to special-case the enter key. By the looks of it, we have to continue manually tracking it, while handling isComposing correctly will mean some overhead for each component that needs it.

Comment on lines +169 to +173
oninput={(e: Event) => {
if (e instanceof InputEvent) {
isComposing = e.isComposing;
}
}}
Copy link
Author

@tdkn tdkn Aug 12, 2025

Choose a reason for hiding this comment

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

I had to use instanceof to check whether it was an InputEvent
FYI: @nshcr

CleanShot 2025-08-12 at 13 49 57@2x

Copy link
Contributor

Choose a reason for hiding this comment

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

@tdkn My bad, forget to add this change.

diff --git a/apps/desktop/src/components/editor/MessageEditorInput.svelte b/apps/desktop/src/components/editor/MessageEditorInput.svelte
--- a/apps/desktop/src/components/editor/MessageEditorInput.svelte	(revision 8b4f2ae3dee056b5712c74c12cefcae14747aac8)
+++ b/apps/desktop/src/components/editor/MessageEditorInput.svelte	(date 1754975641661)
@@ -6,7 +6,7 @@
 		value: string;
 		showCount?: boolean;
 		placeholder?: string;
-		oninput?: (e: Event) => void;
+		oninput?: (e: InputEvent) => void;
 		onchange?: (value: string) => void;
 		onkeydown: (e: KeyboardEvent) => void;
 		testId?: string;

BTW, ReviewCreation.svelte has the same logic as CommitMessageEditor.svelte, maybe it needs to be changed too?

<MessageEditorInput
testId={TestId.ReviewTitleInput}
bind:ref={titleInput}
value={$prTitle}
onchange={(value) => {
prTitle.set(value);
}}
onkeydown={(e: KeyboardEvent) => {
if (e.key === 'Enter' || (e.key === 'Tab' && !e.shiftKey)) {
e.preventDefault();
messageEditor?.focus();
}
if (e.key === 'Escape') {
e.preventDefault();
onClose();
}
}}
placeholder="PR title"
showCount={false}
oninput={(e: Event) => {
const target = e.target as HTMLInputElement;
prTitle.set(target.value);
}}
/>

Copy link
Author

@tdkn tdkn Aug 12, 2025

Choose a reason for hiding this comment

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

Changing the argument type of MessageEditorInput.oninput also triggers the following errors. You will need to update the type definitions in the Textarea.svelte file.

CleanShot 2025-08-12 at 15 03 02@2x

When you trace from <MessageEditorInput /> through <Textarea /> to the native <textarea /> element, you’ll find that oninput is typed as Event & { currentTarget: EventTarget & HTMLTextAreaElement }. It isn’t an InputEvent. Therefore, you must cast the Event to an InputEvent. As noted in the official Svelte repo (sveltejs/svelte#9957), Svelte’s type definitions have some flaws 🤔

The Textarea component is used throughout the application, so modifying it feels risky. It would be great to have a way to minimize its impact.

Copy link
Author

@tdkn tdkn Aug 12, 2025

Choose a reason for hiding this comment

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

I missed ReviewCreation.svelte.
I’ll check it!

@tdkn
Copy link
Author

tdkn commented Aug 12, 2025

I've updated the code based on everyone's suggestions.
I would appreciate it if you could review it again 🙏

if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (title.trim()) {
emitAction();
}
}
if (e.key === 'Enter' || (e.key === 'Tab' && !e.shiftKey)) {
if ((e.key === 'Enter' || (e.key === 'Tab' && !e.shiftKey)) && !isComposing) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I was thinking about it later, && !isComposing check here seems unnecessary, in any state where this line is reached, isComposing should always be false.
The only exception is Tab, but when composing, the focus is on the IME, so the keydown event won't be triggered.

Copy link
Contributor

Choose a reason for hiding this comment

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

Never mind, this check should stay, since pressing Tab during composition can move the focus.

@Byron Byron marked this pull request as draft August 12, 2025 07:52
@Byron
Copy link
Collaborator

Byron commented Aug 12, 2025

It's a bit hard for me to understand when this PR is ready for review given all the work going in, so I put it back into draft state.
Please do feel free to put it back when it's ready for review so we can try it.

Add composition state tracking to CommitMessageEditor and 
ReviewCreation components to prevent keyboard shortcuts from 
interfering with IME input. Track isComposing state and disable 
Enter/Tab/Escape navigation while users are typing with input 
methods like Asian keyboards.

Co-authored-by: Byron <[email protected]>
Co-authored-by: nshcr <[email protected]>
Copy link
Author

Choose a reason for hiding this comment

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

@nshcr
I applied the same updates to ReviewCreation.svelte as I did to CommitMessageEditor.svelte.
After confirming input with the number keys, pressing Enter moves the focus.

CleanShot.2025-08-12.at.23.10.37.mp4

@tdkn
Copy link
Author

tdkn commented Aug 12, 2025

I believe the fixes for the original issue are complete. I’ll switch this PR back to Ready for review. If similar issues are found elsewhere, it would be great to open new issues and address them in dedicated follow-up PRs.

In my experience, trying to fix too many things in a single PR increases the risk of regressions.

@tdkn tdkn marked this pull request as ready for review August 12, 2025 14:30
Copy link
Collaborator

@Byron Byron left a comment

Choose a reason for hiding this comment

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

That's awesome work, and it's great to have the fix finally.

I was wondering if it already makes sense to re-use as much of the logic to make it harder to get wrong in all the other input-components that will need it.
Maybe there is some solution already available in a maintained 'package' somewhere.
Or is it common for everyone on earth to fix this by hand over and over?

Besides that, it definitely looks good to me, but I will leave to to others to merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Enter key for Japanese Kana-to-Kanji conversion moves the cursor to the next input box in commit/PR forms.
4 participants