From 447cba7a351254266a140b67c261c8c345457cd7 Mon Sep 17 00:00:00 2001 From: dante Date: Sun, 24 May 2026 18:33:38 +0900 Subject: [PATCH 1/6] Fix terminal IME composition handling --- frontend/app/store/keymodel.ts | 3 +++ frontend/app/view/term/ijson.tsx | 2 +- frontend/app/view/term/term-model.ts | 3 +++ frontend/app/view/term/term.tsx | 6 +++++- frontend/app/view/term/termwrap.ts | 3 +++ frontend/util/keyutil.ts | 2 ++ pkg/wconfig/defaultconfig/settings.json | 1 + 7 files changed, 18 insertions(+), 2 deletions(-) diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index cca01753bb..01a33f80aa 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -417,6 +417,9 @@ function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean { if (globalKeybindingsDisabled) { return false; } + if ((waveEvent as any).isComposing) { + return false; + } const nativeEvent = (waveEvent as any).nativeEvent; if (lastHandledEvent != null && nativeEvent != null && lastHandledEvent === nativeEvent) { return false; diff --git a/frontend/app/view/term/ijson.tsx b/frontend/app/view/term/ijson.tsx index 617a6e094d..472c4c0cfd 100644 --- a/frontend/app/view/term/ijson.tsx +++ b/frontend/app/view/term/ijson.tsx @@ -104,7 +104,7 @@ body { } .fixed-font { - normal 12px / normal "Hack", monospace; + font: normal 12px / normal "Hack", "Noto Sans Mono CJK KR", "Noto Sans Mono CJK JP", "Noto Sans Mono CJK SC", "Noto Sans Mono CJK TC", "Noto Sans CJK KR", "Noto Sans CJK JP", "Noto Sans CJK SC", "Noto Sans CJK TC", "Apple SD Gothic Neo", "Hiragino Sans", "PingFang SC", "PingFang TC", "Microsoft YaHei", "Malgun Gothic", monospace; } `} diff --git a/frontend/app/view/term/term-model.ts b/frontend/app/view/term/term-model.ts index a256929e7d..3af9f23950 100644 --- a/frontend/app/view/term/term-model.ts +++ b/frontend/app/view/term/term-model.ts @@ -697,6 +697,9 @@ export class TermViewModel implements ViewModel { } handleTerminalKeydown(event: KeyboardEvent): boolean { + if (event.isComposing || event.keyCode == 229) { + return true; + } const waveEvent = keyutil.adaptFromReactOrNativeKeyEvent(event); if (waveEvent.type != "keydown") { return true; diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 67eb5737c6..8de8e9816f 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -29,6 +29,10 @@ import { TermWrap } from "./termwrap"; import "./xterm.css"; const dlog = debug("wave:term"); +const DefaultTermFontFamily = + "Hack, 'Noto Sans Mono CJK KR', 'Noto Sans Mono CJK JP', 'Noto Sans Mono CJK SC', 'Noto Sans Mono CJK TC', " + + "'Noto Sans CJK KR', 'Noto Sans CJK JP', 'Noto Sans CJK SC', 'Noto Sans CJK TC', " + + "'Apple SD Gothic Neo', 'Hiragino Sans', 'PingFang SC', 'PingFang TC', 'Microsoft YaHei', 'Malgun Gothic', monospace"; interface TerminalViewProps { blockId: string; @@ -300,7 +304,7 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => { theme: termTheme, fontSize: termFontSize, - fontFamily: termSettings?.["term:fontfamily"] ?? connFontFamily ?? "Hack", + fontFamily: termSettings?.["term:fontfamily"] ?? connFontFamily ?? DefaultTermFontFamily, drawBoldTextInBrightColors: false, fontWeight: "normal", fontWeightBold: "bold", diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index d10b600459..26a5c49e6b 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -272,6 +272,9 @@ export class TermWrap { }) ); this.terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => { + if (e.isComposing || e.keyCode == 229) { + return true; + } if (!waveOptions.keydownHandler) { return true; } diff --git a/frontend/util/keyutil.ts b/frontend/util/keyutil.ts index 867dfcb4e2..a770e8d2a1 100644 --- a/frontend/util/keyutil.ts +++ b/frontend/util/keyutil.ts @@ -240,6 +240,7 @@ function adaptFromReactOrNativeKeyEvent(event: React.KeyboardEvent | KeyboardEve rtn.key = event.key; rtn.location = event.location; (rtn as any).nativeEvent = event; + (rtn as any).isComposing = event.isComposing || (event as any).keyCode == 229; if (event.type == "keydown" || event.type == "keyup" || event.type == "keypress") { rtn.type = event.type; } else { @@ -268,6 +269,7 @@ function adaptFromElectronKeyEvent(event: any): WaveKeyboardEvent { rtn.location = event.location; rtn.code = event.code; rtn.key = event.key; + (rtn as any).isComposing = event.isComposing || event.keyCode == 229; return rtn; } diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index d8847cabf2..78c28b25a9 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -32,6 +32,7 @@ "telemetry:enabled": true, "term:bellsound": false, "term:bellindicator": true, + "term:fontfamily": "Hack, 'Noto Sans Mono CJK KR', 'Noto Sans Mono CJK JP', 'Noto Sans Mono CJK SC', 'Noto Sans Mono CJK TC', 'Noto Sans CJK KR', 'Noto Sans CJK JP', 'Noto Sans CJK SC', 'Noto Sans CJK TC', 'Apple SD Gothic Neo', 'Hiragino Sans', 'PingFang SC', 'PingFang TC', 'Microsoft YaHei', 'Malgun Gothic', monospace", "term:osc52": "always", "term:cursor": "block", "term:cursorblink": false, From d1119b4caf4a11770e942a51d2ccae413a50bb52 Mon Sep 17 00:00:00 2001 From: dante Date: Mon, 25 May 2026 13:41:00 +0900 Subject: [PATCH 2/6] Default terminal rendering to non-WebGL --- frontend/app/view/term/term.tsx | 3 ++- pkg/wconfig/defaultconfig/settings.json | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/app/view/term/term.tsx b/frontend/app/view/term/term.tsx index 8de8e9816f..085c16d91e 100644 --- a/frontend/app/view/term/term.tsx +++ b/frontend/app/view/term/term.tsx @@ -296,6 +296,7 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => const termMacOptionIsMeta = globalStore.get(termMacOptionIsMetaAtom) ?? false; const termCursorStyle = normalizeCursorStyle(globalStore.get(getOverrideConfigAtom(blockId, "term:cursor"))); const termCursorBlink = globalStore.get(getOverrideConfigAtom(blockId, "term:cursorblink")) ?? false; + const termDisableWebGl = termSettings?.["term:disablewebgl"] ?? true; const wasFocused = model.termRef.current != null && globalStore.get(model.nodeModel.isFocused); const termWrap = new TermWrap( tabModel.tabId, @@ -319,7 +320,7 @@ const TerminalView = ({ blockId, model }: ViewComponentProps) => }, { keydownHandler: model.handleTerminalKeydown.bind(model), - useWebGl: !termSettings?.["term:disablewebgl"], + useWebGl: !termDisableWebGl, sendDataHandler: model.sendDataToController.bind(model), nodeModel: model.nodeModel, } diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json index 78c28b25a9..f9ed4b5957 100644 --- a/pkg/wconfig/defaultconfig/settings.json +++ b/pkg/wconfig/defaultconfig/settings.json @@ -32,6 +32,7 @@ "telemetry:enabled": true, "term:bellsound": false, "term:bellindicator": true, + "term:disablewebgl": true, "term:fontfamily": "Hack, 'Noto Sans Mono CJK KR', 'Noto Sans Mono CJK JP', 'Noto Sans Mono CJK SC', 'Noto Sans Mono CJK TC', 'Noto Sans CJK KR', 'Noto Sans CJK JP', 'Noto Sans CJK SC', 'Noto Sans CJK TC', 'Apple SD Gothic Neo', 'Hiragino Sans', 'PingFang SC', 'PingFang TC', 'Microsoft YaHei', 'Malgun Gothic', monospace", "term:osc52": "always", "term:cursor": "block", From ada924dec0318893470ffbad72a6b2f042b4a82e Mon Sep 17 00:00:00 2001 From: dante Date: Mon, 25 May 2026 13:43:57 +0900 Subject: [PATCH 3/6] Type IME composition keyboard events --- frontend/app/store/keymodel.ts | 2 +- frontend/types/gotypes.d.ts | 1 + frontend/util/keyutil.ts | 4 ++-- pkg/vdom/vdom_types.go | 2 ++ 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts index 01a33f80aa..690f80ec20 100644 --- a/frontend/app/store/keymodel.ts +++ b/frontend/app/store/keymodel.ts @@ -417,7 +417,7 @@ function appHandleKeyDown(waveEvent: WaveKeyboardEvent): boolean { if (globalKeybindingsDisabled) { return false; } - if ((waveEvent as any).isComposing) { + if (waveEvent.isComposing) { return false; } const nativeEvent = (waveEvent as any).nativeEvent; diff --git a/frontend/types/gotypes.d.ts b/frontend/types/gotypes.d.ts index c5b870d7ed..49f99b2a16 100644 --- a/frontend/types/gotypes.d.ts +++ b/frontend/types/gotypes.d.ts @@ -2052,6 +2052,7 @@ declare global { code: string; repeat?: boolean; location?: number; + isComposing?: boolean; shift?: boolean; control?: boolean; alt?: boolean; diff --git a/frontend/util/keyutil.ts b/frontend/util/keyutil.ts index a770e8d2a1..4a5a789388 100644 --- a/frontend/util/keyutil.ts +++ b/frontend/util/keyutil.ts @@ -240,7 +240,7 @@ function adaptFromReactOrNativeKeyEvent(event: React.KeyboardEvent | KeyboardEve rtn.key = event.key; rtn.location = event.location; (rtn as any).nativeEvent = event; - (rtn as any).isComposing = event.isComposing || (event as any).keyCode == 229; + rtn.isComposing = event.isComposing || (event as any).keyCode == 229; if (event.type == "keydown" || event.type == "keyup" || event.type == "keypress") { rtn.type = event.type; } else { @@ -269,7 +269,7 @@ function adaptFromElectronKeyEvent(event: any): WaveKeyboardEvent { rtn.location = event.location; rtn.code = event.code; rtn.key = event.key; - (rtn as any).isComposing = event.isComposing || event.keyCode == 229; + rtn.isComposing = event.isComposing || event.keyCode == 229; return rtn; } diff --git a/pkg/vdom/vdom_types.go b/pkg/vdom/vdom_types.go index 9ff5a4157e..3833aa3926 100644 --- a/pkg/vdom/vdom_types.go +++ b/pkg/vdom/vdom_types.go @@ -236,6 +236,8 @@ type WaveKeyboardEvent struct { Code string `json:"code"` // KeyboardEvent.code Repeat bool `json:"repeat,omitempty"` Location int `json:"location,omitempty"` // KeyboardEvent.location + // True while an IME composition is active. These key events should not trigger app shortcuts. + IsComposing bool `json:"isComposing,omitempty"` // modifiers Shift bool `json:"shift,omitempty"` From 90749e99f43de0f4128690f149cae1605c2fe7c4 Mon Sep 17 00:00:00 2001 From: dante Date: Mon, 25 May 2026 16:29:02 +0900 Subject: [PATCH 4/6] Preserve IME composition order around spaces --- frontend/app/view/term/termwrap.ts | 105 ++++++++++++++++++++++++++++- 1 file changed, 103 insertions(+), 2 deletions(-) diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 26a5c49e6b..cd3b6807a5 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -111,6 +111,12 @@ export class TermWrap { lastPasteData: string = ""; lastPasteTime: number = 0; + // IME composition ordering + compositionActive: boolean = false; + compositionRecentlyEndedUntil: number = 0; + pendingCompositionSpace: { timeout: ReturnType } | null = null; + disposed: boolean = false; + // dev only (for debugging) recentWrites: { idx: number; data: string; ts: number }[] = []; recentWritesCounter: number = 0; @@ -275,6 +281,9 @@ export class TermWrap { if (e.isComposing || e.keyCode == 229) { return true; } + if (this.shouldBypassWaveKeydownForComposition(e)) { + return true; + } if (!waveOptions.keydownHandler) { return true; } @@ -285,6 +294,7 @@ export class TermWrap { this.heldData = []; this.handleResize_debounced = debounce(50, this.handleResize.bind(this)); this.terminal.open(this.connectElem); + this.registerCompositionEventHandlers(); const dragoverHandler = (e: DragEvent) => { e.preventDefault(); @@ -445,6 +455,7 @@ export class TermWrap { } dispose() { + this.disposed = true; this.promptMarkers.forEach((marker) => { try { marker.dispose(); @@ -453,6 +464,7 @@ export class TermWrap { } }); this.promptMarkers = []; + this.cancelPendingCompositionSpace(); this.webglContextLossDisposable?.dispose(); this.webglContextLossDisposable = null; this.terminal.dispose(); @@ -466,15 +478,104 @@ export class TermWrap { this.mainFileSubject.release(); } - handleTermData(data: string) { - if (!this.loaded) { + registerCompositionEventHandlers() { + const textarea = this.terminal.textarea; + if (textarea == null) { return; } + const compositionStartHandler = () => { + this.compositionActive = true; + this.compositionRecentlyEndedUntil = 0; + this.flushPendingCompositionSpace(); + }; + const compositionEndHandler = () => { + this.compositionActive = false; + this.compositionRecentlyEndedUntil = Date.now() + 75; + }; + textarea.addEventListener("compositionstart", compositionStartHandler); + textarea.addEventListener("compositionend", compositionEndHandler); + this.toDispose.push({ + dispose: () => { + textarea.removeEventListener("compositionstart", compositionStartHandler); + textarea.removeEventListener("compositionend", compositionEndHandler); + this.cancelPendingCompositionSpace(); + }, + }); + } + + shouldBypassWaveKeydownForComposition(event: KeyboardEvent): boolean { + if (this.compositionActive) { + return true; + } + if (Date.now() > this.compositionRecentlyEndedUntil) { + return false; + } + return event.key === " "; + } + sendTermData(data: string) { this.sendDataHandler?.(data); this.multiInputCallback?.(data); } + flushPendingCompositionSpace() { + if (this.pendingCompositionSpace == null) { + return; + } + clearTimeout(this.pendingCompositionSpace.timeout); + this.pendingCompositionSpace = null; + if (!this.loaded || this.disposed) { + return; + } + this.sendTermData(" "); + } + + cancelPendingCompositionSpace() { + if (this.pendingCompositionSpace == null) { + return; + } + clearTimeout(this.pendingCompositionSpace.timeout); + this.pendingCompositionSpace = null; + } + + isLikelyCompositionText(data: string): boolean { + if (data.length === 0) { + return false; + } + if (/[\x00-\x1F\x7F]/.test(data)) { + return false; + } + return /[^\x00-\x7F]/.test(data); + } + + handleTermData(data: string) { + if (!this.loaded) { + return; + } + + if (this.pendingCompositionSpace != null) { + if (this.isLikelyCompositionText(data)) { + clearTimeout(this.pendingCompositionSpace.timeout); + this.pendingCompositionSpace = null; + this.sendTermData(data); + this.sendTermData(" "); + return; + } + this.flushPendingCompositionSpace(); + } + + if (data === " " && !this.compositionActive && Date.now() <= this.compositionRecentlyEndedUntil) { + this.pendingCompositionSpace = { + timeout: setTimeout(() => { + this.flushPendingCompositionSpace(); + }, 30), + }; + return; + } + + this.sendTermData(data); + } + addFocusListener(focusFn: () => void) { this.terminal.textarea.addEventListener("focus", focusFn); } From 42d0b9a7616da42b9e733e707ca589af3124d554 Mon Sep 17 00:00:00 2001 From: dante Date: Mon, 25 May 2026 18:23:06 +0900 Subject: [PATCH 5/6] Handle IME composition suffix punctuation --- frontend/app/view/term/termwrap.ts | 69 ++++++++++++++++++++---------- 1 file changed, 47 insertions(+), 22 deletions(-) diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index cd3b6807a5..43d620d4c0 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -114,7 +114,7 @@ export class TermWrap { // IME composition ordering compositionActive: boolean = false; compositionRecentlyEndedUntil: number = 0; - pendingCompositionSpace: { timeout: ReturnType } | null = null; + pendingCompositionSuffix: { data: string; timeout: ReturnType } | null = null; disposed: boolean = false; // dev only (for debugging) @@ -464,7 +464,7 @@ export class TermWrap { } }); this.promptMarkers = []; - this.cancelPendingCompositionSpace(); + this.cancelPendingCompositionSuffix(); this.webglContextLossDisposable?.dispose(); this.webglContextLossDisposable = null; this.terminal.dispose(); @@ -486,7 +486,7 @@ export class TermWrap { const compositionStartHandler = () => { this.compositionActive = true; this.compositionRecentlyEndedUntil = 0; - this.flushPendingCompositionSpace(); + this.flushPendingCompositionSuffix(); }; const compositionEndHandler = () => { this.compositionActive = false; @@ -498,7 +498,7 @@ export class TermWrap { dispose: () => { textarea.removeEventListener("compositionstart", compositionStartHandler); textarea.removeEventListener("compositionend", compositionEndHandler); - this.cancelPendingCompositionSpace(); + this.cancelPendingCompositionSuffix(); }, }); } @@ -510,7 +510,7 @@ export class TermWrap { if (Date.now() > this.compositionRecentlyEndedUntil) { return false; } - return event.key === " "; + return !event.ctrlKey && !event.metaKey && !event.altKey && this.isCompositionSuffixData(event.key); } sendTermData(data: string) { @@ -518,24 +518,25 @@ export class TermWrap { this.multiInputCallback?.(data); } - flushPendingCompositionSpace() { - if (this.pendingCompositionSpace == null) { + flushPendingCompositionSuffix() { + if (this.pendingCompositionSuffix == null) { return; } - clearTimeout(this.pendingCompositionSpace.timeout); - this.pendingCompositionSpace = null; + const pendingData = this.pendingCompositionSuffix.data; + clearTimeout(this.pendingCompositionSuffix.timeout); + this.pendingCompositionSuffix = null; if (!this.loaded || this.disposed) { return; } - this.sendTermData(" "); + this.sendTermData(pendingData); } - cancelPendingCompositionSpace() { - if (this.pendingCompositionSpace == null) { + cancelPendingCompositionSuffix() { + if (this.pendingCompositionSuffix == null) { return; } - clearTimeout(this.pendingCompositionSpace.timeout); - this.pendingCompositionSpace = null; + clearTimeout(this.pendingCompositionSuffix.timeout); + this.pendingCompositionSuffix = null; } isLikelyCompositionText(data: string): boolean { @@ -548,26 +549,50 @@ export class TermWrap { return /[^\x00-\x7F]/.test(data); } + isCompositionSuffixData(data: string): boolean { + if (data.length === 0) { + return false; + } + if (/[\x00-\x1F\x7F]/.test(data)) { + return false; + } + return /^[\x20-\x7E]+$/.test(data); + } + handleTermData(data: string) { if (!this.loaded) { return; } - if (this.pendingCompositionSpace != null) { + if (this.pendingCompositionSuffix != null) { if (this.isLikelyCompositionText(data)) { - clearTimeout(this.pendingCompositionSpace.timeout); - this.pendingCompositionSpace = null; + const pendingData = this.pendingCompositionSuffix.data; + clearTimeout(this.pendingCompositionSuffix.timeout); + this.pendingCompositionSuffix = null; this.sendTermData(data); - this.sendTermData(" "); + this.sendTermData(pendingData); + return; + } + if (this.isCompositionSuffixData(data) && Date.now() <= this.compositionRecentlyEndedUntil) { + clearTimeout(this.pendingCompositionSuffix.timeout); + this.pendingCompositionSuffix.data += data; + this.pendingCompositionSuffix.timeout = setTimeout(() => { + this.flushPendingCompositionSuffix(); + }, 30); return; } - this.flushPendingCompositionSpace(); + this.flushPendingCompositionSuffix(); } - if (data === " " && !this.compositionActive && Date.now() <= this.compositionRecentlyEndedUntil) { - this.pendingCompositionSpace = { + if ( + this.isCompositionSuffixData(data) && + !this.compositionActive && + Date.now() <= this.compositionRecentlyEndedUntil + ) { + this.pendingCompositionSuffix = { + data, timeout: setTimeout(() => { - this.flushPendingCompositionSpace(); + this.flushPendingCompositionSuffix(); }, 30), }; return; From 0276036422247275d121b2f7f217f8a08ce58c7d Mon Sep 17 00:00:00 2001 From: dante Date: Mon, 25 May 2026 18:42:40 +0900 Subject: [PATCH 6/6] Avoid control regex in IME suffix checks --- frontend/app/view/term/termwrap.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index 43d620d4c0..052cfe5afa 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -543,20 +543,30 @@ export class TermWrap { if (data.length === 0) { return false; } - if (/[\x00-\x1F\x7F]/.test(data)) { - return false; + let hasNonAscii = false; + for (const ch of data) { + const codePoint = ch.codePointAt(0); + if (codePoint == null || codePoint <= 0x1f || codePoint === 0x7f) { + return false; + } + if (codePoint > 0x7f) { + hasNonAscii = true; + } } - return /[^\x00-\x7F]/.test(data); + return hasNonAscii; } isCompositionSuffixData(data: string): boolean { if (data.length === 0) { return false; } - if (/[\x00-\x1F\x7F]/.test(data)) { - return false; + for (const ch of data) { + const codePoint = ch.codePointAt(0); + if (codePoint == null || codePoint < 0x20 || codePoint > 0x7e) { + return false; + } } - return /^[\x20-\x7E]+$/.test(data); + return true; } handleTermData(data: string) {