From f73c0a6b1dfab084076e2a9c677679dc06af67e4 Mon Sep 17 00:00:00 2001
From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
Date: Tue, 27 Oct 2026 17:16:35 +0800
Subject: [PATCH] fix(keymap): Don't run the Enter that commits an input-method
candidate
WebKit sends the Enter (or Space) that commits a candidate after compositionend,
with isComposing already false and keyCode 229. Handlers that checked isComposing
alone ran it: dialogs confirmed and shortcuts fired while typing Chinese or
Japanese. isImeKey() checks both, and every keydown handler that skipped
composition uses it.
---
src/features/database/Dialogs.tsx | 3 ++-
src/features/database/SourceDialog.tsx | 3 ++-
src/features/debugger/ui/Overlays.tsx | 3 ++-
src/features/macros/index.ts | 3 ++-
src/features/search/everywhere/SearchEverywhere.tsx | 3 ++-
src/features/settings/index.ts | 3 ++-
src/features/terminal/keys.ts | 3 ++-
src/platform/ime.ts | 8 ++++++++
src/platform/keymap.ts | 3 ++-
src/ui/Dialog.tsx | 3 ++-
10 files changed, 26 insertions(+), 9 deletions(-)
create mode 100644 src/platform/ime.ts
diff --git a/src/features/database/Dialogs.tsx b/src/features/database/Dialogs.tsx
index 3a1cd7c8..13fc362d 100644
--- a/src/features/database/Dialogs.tsx
+++ b/src/features/database/Dialogs.tsx
@@ -6,6 +6,7 @@ import { sshLabel } from "./model";
import { SourceDialog } from "./SourceDialog";
import { closeDialog, useDatabase } from "./store";
import styles from "./Database.module.css";
+import { isImeKey } from "@/platform/ime";
const ObjectDialogs = lazyComponent(() => import("./ObjectDialogs").then((m) => m.ObjectDialogs));
const ExportDialog = lazyComponent(() => import("./ExportDialog").then((m) => m.ExportDialog));
@@ -138,7 +139,7 @@ function ParamsDialog({
{
- if (e.key !== "Enter" || e.nativeEvent.isComposing) return;
+ if (e.key !== "Enter" || isImeKey(e.nativeEvent)) return;
e.preventDefault();
done(values);
}}
diff --git a/src/features/database/SourceDialog.tsx b/src/features/database/SourceDialog.tsx
index 2929f361..9bca7f2b 100644
--- a/src/features/database/SourceDialog.tsx
+++ b/src/features/database/SourceDialog.tsx
@@ -23,6 +23,7 @@ import { colorVar, connectRemedy, fileSourceName, newSourceId, parseSourceUrl, s
import { closeDialog, loadDatabases, loadSchemas, saveSource, useDatabase } from "./store";
import { VENDORS, sourceUrl, vendorById, vendorOf } from "./vendors";
import styles from "./Database.module.css";
+import { isImeKey } from "@/platform/ime";
type Tab = "general" | "options" | "ssh" | "schemas" | "advanced";
@@ -284,7 +285,7 @@ function NamePicker({
aria-label={`Add ${title} pattern`}
onChange={(e) => setPattern(e.target.value)}
onKeyDown={(e) => {
- if (e.key === "Enter" && !e.nativeEvent.isComposing) {
+ if (e.key === "Enter" && !isImeKey(e.nativeEvent)) {
e.preventDefault();
add();
}
diff --git a/src/features/debugger/ui/Overlays.tsx b/src/features/debugger/ui/Overlays.tsx
index 570f1a97..374df949 100644
--- a/src/features/debugger/ui/Overlays.tsx
+++ b/src/features/debugger/ui/Overlays.tsx
@@ -33,6 +33,7 @@ import { closeBreakpointPopover, useOverlays } from "./popoverStore";
import { VariablesView, type VarRoot } from "./VariablesView";
import { pushHistory, readHistory } from "./expressionHistory";
import styles from "./Overlays.module.css";
+import { isImeKey } from "@/platform/ime";
export function DebuggerOverlays() {
return (
@@ -710,7 +711,7 @@ function EvaluateDialogInner({ initial }: { initial: string }) {
});
};
const onKeyDown = (e: KeyboardEvent) => {
- if (e.nativeEvent.isComposing) return;
+ if (isImeKey(e.nativeEvent)) return;
const el = e.currentTarget;
if (e.key === "Enter" && e.shiftKey && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
diff --git a/src/features/macros/index.ts b/src/features/macros/index.ts
index 8ec9323d..3c483796 100644
--- a/src/features/macros/index.ts
+++ b/src/features/macros/index.ts
@@ -21,6 +21,7 @@ import {
useMacros,
type MacroStep,
} from "./model";
+import { isImeKey } from "@/platform/ime";
/** Records typing and editor keys of every text editor while a macro records. */
const recorder = [
@@ -31,7 +32,7 @@ const recorder = [
EditorView.domEventHandlers({
keydown(e) {
// Keys the IDE keymap ran as actions were recorded as those actions.
- if (e.defaultPrevented || e.isComposing || !useMacros.getState().recording) return false;
+ if (e.defaultPrevented || isImeKey(e) || !useMacros.getState().recording) return false;
const key = editorKeyName(e);
if (key) recordStep({ kind: "key", key });
return false;
diff --git a/src/features/search/everywhere/SearchEverywhere.tsx b/src/features/search/everywhere/SearchEverywhere.tsx
index ab32e53d..d55ff6ec 100644
--- a/src/features/search/everywhere/SearchEverywhere.tsx
+++ b/src/features/search/everywhere/SearchEverywhere.tsx
@@ -57,6 +57,7 @@ import {
widenSearchEverywhere,
} from "./store";
import styles from "./SearchEverywhere.module.css";
+import { isImeKey } from "@/platform/ime";
type SeScope = "allPlaces" | ProjectFileSet;
@@ -315,7 +316,7 @@ function SearchEverywhereDialog() {
};
const onKeyDown = (e: KeyboardEvent) => {
- if (e.nativeEvent.isComposing) return;
+ if (isImeKey(e.nativeEvent)) return;
if (e.key === "Tab") {
e.preventDefault();
e.stopPropagation();
diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts
index f7f7cdc0..1643d0c4 100644
--- a/src/features/settings/index.ts
+++ b/src/features/settings/index.ts
@@ -33,6 +33,7 @@ import { trackRuntimeSettings } from "./runtime";
import { installActionProxies, installSettingsHost } from "./window/bridge";
import { AssignShortcutOverlay, showAssignShortcut } from "./keymap/AssignShortcut";
import { settingsSearchEntries } from "./searchIndex";
+import { isImeKey } from "@/platform/ime";
export { settingsWindowParent } from "./window/bridge";
export const SettingsWindow = lazyComponent(() => import("./window/SettingsWindow").then((m) => m.SettingsWindow));
@@ -165,7 +166,7 @@ function listenLaunch(): () => void {
/** Esc and ⌘W cancel, like closing the window; ⌘, stays here. */
function installWindowKeys(): () => void {
const onKey = (e: KeyboardEvent) => {
- if (e.defaultPrevented || e.isComposing) return;
+ if (e.defaultPrevented || isImeKey(e)) return;
const mod = e.metaKey || e.ctrlKey;
if ((e.key === "Escape" && !isPopupOpen()) || (mod && !e.altKey && !e.shiftKey && e.key.toLowerCase() === "w")) {
e.preventDefault();
diff --git a/src/features/terminal/keys.ts b/src/features/terminal/keys.ts
index bd77624f..68bc13ec 100644
--- a/src/features/terminal/keys.ts
+++ b/src/features/terminal/keys.ts
@@ -18,6 +18,7 @@ import { executeAction } from "@/platform/actions";
import { effectiveBindings, strokeOf } from "@/platform/keymap";
import { workflowSettings } from "@/platform/workflowSettings";
import { terminalRuntime } from "./loader";
+import { isImeKey } from "@/platform/ime";
export type TerminalKeyResult =
| { kind: "send"; data: string }
@@ -128,7 +129,7 @@ export function isAllowedIdeShortcut(stroke: string, bindings: ReadonlyArray void {
const onKeyDown = (e: KeyboardEvent) => {
const rt = terminalRuntime();
- if (e.isComposing || !rt) return;
+ if (isImeKey(e) || !rt) return;
// The terminal find bar: Escape closes it (instead of "focus editor"), ⌘F reselects the query.
if ((e.target as Element | null)?.closest?.("[data-terminal-find]")) {
const plain = !e.ctrlKey && !e.altKey && !e.shiftKey;
diff --git a/src/platform/ime.ts b/src/platform/ime.ts
new file mode 100644
index 00000000..389e1324
--- /dev/null
+++ b/src/platform/ime.ts
@@ -0,0 +1,8 @@
+/**
+ * True while an input method is composing, including the key that ends it: WebKit sends the
+ * Enter / Space that commits a candidate after `compositionend`, with `isComposing` already
+ * false and keyCode 229, so a handler checking `isComposing` alone runs that Enter.
+ */
+export function isImeKey(e: KeyboardEvent): boolean {
+ return e.isComposing || e.keyCode === 229;
+}
diff --git a/src/platform/keymap.ts b/src/platform/keymap.ts
index 596c7033..2a900886 100644
--- a/src/platform/keymap.ts
+++ b/src/platform/keymap.ts
@@ -18,6 +18,7 @@ import { intellijWindows } from "./keymaps/intellij-windows";
import { vscodeMacos } from "./keymaps/vscode-macos";
import { vscodeWindows } from "./keymaps/vscode-windows";
import { usePlatform } from "./platformInfo";
+import { isImeKey } from "./ime";
export type Keymap = Record;
@@ -439,7 +440,7 @@ export function installKeyboardDispatcher(target: Window = window, now: () => nu
if (runHeldGesture(e)) return reset();
resetTap();
otherKeyAt = e.key === "Escape" || e.key === "Tab" ? -Infinity : now();
- if (e.isComposing) return;
+ if (isImeKey(e)) return;
const stroke = strokeOf(e);
if (!stroke) return;
const chord = pending.length > 0;
diff --git a/src/ui/Dialog.tsx b/src/ui/Dialog.tsx
index 9e08fbf2..d840d77a 100644
--- a/src/ui/Dialog.tsx
+++ b/src/ui/Dialog.tsx
@@ -8,6 +8,7 @@ import { FOCUSABLE, onWindowDeactivate, openLayer, trapTab } from "./popupState"
import { useTx } from "@/platform/i18n";
import { selectableText } from "./selectableText";
import styles from "./Dialog.module.css";
+import { isImeKey } from "@/platform/ime";
/**
* `default`: filled accent button, triggered by Enter. `cancel`: Esc / close
@@ -256,7 +257,7 @@ function DialogInner({
onCloseRef.current();
} else if (e.key === "Tab") {
trapTab(e, ref.current);
- } else if (e.key === "Enter" && !e.defaultPrevented && !e.isComposing && !e.shiftKey && !e.altKey) {
+ } else if (e.key === "Enter" && !e.defaultPrevented && !isImeKey(e) && !e.shiftKey && !e.altKey) {
const t = e.target instanceof Element ? e.target : null;
const mod = e.metaKey || e.ctrlKey;
if (t && t !== document.body && !ref.current.contains(t)) return;