diff --git a/frontend/app/onboarding/onboarding-upgrade-v0146.tsx b/frontend/app/onboarding/onboarding-upgrade-v0146.tsx
new file mode 100644
index 0000000000..d365def604
--- /dev/null
+++ b/frontend/app/onboarding/onboarding-upgrade-v0146.tsx
@@ -0,0 +1,45 @@
+// Copyright 2026, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+const UpgradeOnboardingModal_v0_14_6_Content = () => {
+ return (
+
+
+
+ Wave v0.14.6 is a patch release focused on terminal input reliability and packaging safeguards.
+
+
+
+
+
+
+
+
+
IME Input Fixes
+
+ Korean IME composition now preserves the correct ordering when pressing Enter before a final
+ consonant is committed. This also avoids intermittent duplicated input when switching between
+ English and Korean input modes.
+
+
+
+
+
+
+
+
+
+
Packaging Validation
+
+ Release packaging now fails early if required Wave backend binaries are missing from the
+ packaged app, preventing broken installers from being published.
+
+
+
+
+ );
+};
+
+UpgradeOnboardingModal_v0_14_6_Content.displayName = "UpgradeOnboardingModal_v0_14_6_Content";
+
+export { UpgradeOnboardingModal_v0_14_6_Content };
diff --git a/frontend/app/store/keymodel.ts b/frontend/app/store/keymodel.ts
index cca01753bb..690f80ec20 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.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..085c16d91e 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;
@@ -292,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,
@@ -300,7 +305,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",
@@ -315,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/frontend/app/view/term/termwrap.test.ts b/frontend/app/view/term/termwrap.test.ts
new file mode 100644
index 0000000000..214f0e5b30
--- /dev/null
+++ b/frontend/app/view/term/termwrap.test.ts
@@ -0,0 +1,88 @@
+// Copyright 2026, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+describe("TermWrap IME data ordering", () => {
+ let originalDocument: Document;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ originalDocument = globalThis.document;
+ vi.stubGlobal("document", {
+ createElement: () => ({
+ getContext: () => null,
+ }),
+ });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ if (originalDocument != null) {
+ vi.stubGlobal("document", originalDocument);
+ }
+ vi.resetModules();
+ });
+
+ async function makeTermWrapHarness() {
+ const { TermWrap } = await import("./termwrap");
+ const sent: string[] = [];
+ const termWrap = Object.create(TermWrap.prototype) as InstanceType;
+
+ termWrap.loaded = true;
+ termWrap.disposed = false;
+ termWrap.compositionActive = false;
+ termWrap.compositionRecentlyEndedUntil = 0;
+ termWrap.pendingCompositionSuffix = null;
+ termWrap.sendDataHandler = (data: string) => sent.push(data);
+ termWrap.multiInputCallback = null;
+
+ return { sent, termWrap };
+ }
+
+ it("sends Enter after committed IME text when Enter arrives during composition", async () => {
+ const { sent, termWrap } = await makeTermWrapHarness();
+
+ termWrap.compositionActive = true;
+ termWrap.handleTermData("\r");
+ expect(sent).toEqual([]);
+
+ termWrap.compositionActive = false;
+ termWrap.compositionRecentlyEndedUntil = Date.now() + 75;
+ termWrap.handleTermData("가");
+
+ expect(sent).toEqual(["가", "\r"]);
+ });
+
+ it("flushes a deferred Enter if composition ends without committed text", async () => {
+ const { sent, termWrap } = await makeTermWrapHarness();
+
+ termWrap.compositionActive = true;
+ termWrap.handleTermData("\r");
+ termWrap.compositionActive = false;
+ termWrap.schedulePendingCompositionSuffixFlush();
+
+ vi.advanceTimersByTime(30);
+
+ expect(sent).toEqual(["\r"]);
+ });
+
+ it("does not defer ordinary ASCII data while composition is active", async () => {
+ const { sent, termWrap } = await makeTermWrapHarness();
+
+ termWrap.compositionActive = true;
+ termWrap.handleTermData("hello");
+
+ expect(sent).toEqual(["hello"]);
+ });
+
+ it("does not treat a full ASCII line as a composition suffix", async () => {
+ const { sent, termWrap } = await makeTermWrapHarness();
+
+ termWrap.compositionRecentlyEndedUntil = Date.now() + 75;
+ termWrap.handleTermData("previous sentence");
+
+ expect(sent).toEqual(["previous sentence"]);
+ });
+});
diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts
index d10b600459..062c718e9b 100644
--- a/frontend/app/view/term/termwrap.ts
+++ b/frontend/app/view/term/termwrap.ts
@@ -52,6 +52,7 @@ const TermCacheFileName = "cache:term:full";
const MinDataProcessedForCache = 100 * 1024;
export const SupportsImageInput = true;
const MaxRepaintTransactionMs = 2000;
+const MaxCompositionSuffixLength = 4;
// detect webgl support
function detectWebGLSupport(): boolean {
@@ -111,6 +112,12 @@ export class TermWrap {
lastPasteData: string = "";
lastPasteTime: number = 0;
+ // IME composition ordering
+ compositionActive: boolean = false;
+ compositionRecentlyEndedUntil: number = 0;
+ pendingCompositionSuffix: { data: string; timeout: ReturnType | null } | null = null;
+ disposed: boolean = false;
+
// dev only (for debugging)
recentWrites: { idx: number; data: string; ts: number }[] = [];
recentWritesCounter: number = 0;
@@ -272,6 +279,12 @@ export class TermWrap {
})
);
this.terminal.attachCustomKeyEventHandler((e: KeyboardEvent) => {
+ if (e.isComposing || e.keyCode == 229) {
+ return true;
+ }
+ if (this.shouldBypassWaveKeydownForComposition(e)) {
+ return true;
+ }
if (!waveOptions.keydownHandler) {
return true;
}
@@ -282,6 +295,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();
@@ -442,6 +456,7 @@ export class TermWrap {
}
dispose() {
+ this.disposed = true;
this.promptMarkers.forEach((marker) => {
try {
marker.dispose();
@@ -450,6 +465,7 @@ export class TermWrap {
}
});
this.promptMarkers = [];
+ this.cancelPendingCompositionSuffix();
this.webglContextLossDisposable?.dispose();
this.webglContextLossDisposable = null;
this.terminal.dispose();
@@ -463,15 +479,168 @@ 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.flushPendingCompositionSuffix();
+ };
+ const compositionEndHandler = () => {
+ this.compositionActive = false;
+ this.compositionRecentlyEndedUntil = Date.now() + 75;
+ this.schedulePendingCompositionSuffixFlush();
+ };
+ textarea.addEventListener("compositionstart", compositionStartHandler);
+ textarea.addEventListener("compositionend", compositionEndHandler);
+ this.toDispose.push({
+ dispose: () => {
+ textarea.removeEventListener("compositionstart", compositionStartHandler);
+ textarea.removeEventListener("compositionend", compositionEndHandler);
+ this.cancelPendingCompositionSuffix();
+ },
+ });
+ }
+
+ shouldBypassWaveKeydownForComposition(event: KeyboardEvent): boolean {
+ if (this.compositionActive) {
+ return true;
+ }
+ if (Date.now() > this.compositionRecentlyEndedUntil) {
+ return false;
+ }
+ return !event.ctrlKey && !event.metaKey && !event.altKey && this.isCompositionSuffixData(event.key);
+ }
+ sendTermData(data: string) {
this.sendDataHandler?.(data);
this.multiInputCallback?.(data);
}
+ flushPendingCompositionSuffix() {
+ if (this.pendingCompositionSuffix == null) {
+ return;
+ }
+ const pendingData = this.pendingCompositionSuffix.data;
+ if (this.pendingCompositionSuffix.timeout != null) {
+ clearTimeout(this.pendingCompositionSuffix.timeout);
+ }
+ this.pendingCompositionSuffix = null;
+ if (!this.loaded || this.disposed) {
+ return;
+ }
+ this.sendTermData(pendingData);
+ }
+
+ cancelPendingCompositionSuffix() {
+ if (this.pendingCompositionSuffix == null) {
+ return;
+ }
+ if (this.pendingCompositionSuffix.timeout != null) {
+ clearTimeout(this.pendingCompositionSuffix.timeout);
+ }
+ this.pendingCompositionSuffix = null;
+ }
+
+ schedulePendingCompositionSuffixFlush() {
+ if (this.pendingCompositionSuffix == null) {
+ return;
+ }
+ if (this.pendingCompositionSuffix.timeout != null) {
+ clearTimeout(this.pendingCompositionSuffix.timeout);
+ this.pendingCompositionSuffix.timeout = null;
+ }
+ if (this.compositionActive) {
+ return;
+ }
+ this.pendingCompositionSuffix.timeout = setTimeout(() => {
+ this.flushPendingCompositionSuffix();
+ }, 30);
+ }
+
+ isLikelyCompositionText(data: string): boolean {
+ if (data.length === 0) {
+ 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 hasNonAscii;
+ }
+
+ isCompositionSuffixData(data: string): boolean {
+ if (data.length === 0 || data.length > MaxCompositionSuffixLength) {
+ return false;
+ }
+ for (const ch of data) {
+ const codePoint = ch.codePointAt(0);
+ if (codePoint == null || codePoint < 0x20 || codePoint > 0x7e) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ shouldDeferCompositionData(data: string): boolean {
+ if (this.compositionActive) {
+ return data === "\r";
+ }
+ if (Date.now() > this.compositionRecentlyEndedUntil) {
+ return false;
+ }
+ return data === "\r" || this.isCompositionSuffixData(data);
+ }
+
+ handleTermData(data: string) {
+ if (!this.loaded) {
+ return;
+ }
+
+ if (this.pendingCompositionSuffix != null) {
+ if (this.isLikelyCompositionText(data)) {
+ const pendingData = this.pendingCompositionSuffix.data;
+ if (this.pendingCompositionSuffix.timeout != null) {
+ clearTimeout(this.pendingCompositionSuffix.timeout);
+ }
+ this.pendingCompositionSuffix = null;
+ this.sendTermData(data);
+ this.sendTermData(pendingData);
+ return;
+ }
+ if (this.shouldDeferCompositionData(data)) {
+ if (this.pendingCompositionSuffix.timeout != null) {
+ clearTimeout(this.pendingCompositionSuffix.timeout);
+ this.pendingCompositionSuffix.timeout = null;
+ }
+ this.pendingCompositionSuffix.data += data;
+ this.schedulePendingCompositionSuffixFlush();
+ return;
+ }
+ this.flushPendingCompositionSuffix();
+ }
+
+ if (this.shouldDeferCompositionData(data)) {
+ this.pendingCompositionSuffix = {
+ data,
+ timeout: null,
+ };
+ this.schedulePendingCompositionSuffixFlush();
+ return;
+ }
+
+ this.sendTermData(data);
+ }
+
addFocusListener(focusFn: () => void) {
this.terminal.textarea.addEventListener("focus", focusFn);
}
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 867dfcb4e2..4a5a789388 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.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.isComposing = event.isComposing || event.keyCode == 229;
return rtn;
}
diff --git a/package-lock.json b/package-lock.json
index 1798a0fa38..43bf74f03a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "waveterm",
- "version": "0.14.5",
+ "version": "0.14.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "waveterm",
- "version": "0.14.5",
+ "version": "0.14.6",
"hasInstallScript": true,
"license": "Apache-2.0",
"workspaces": [
diff --git a/package.json b/package.json
index 4c1d56798f..ab8daee87a 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"productName": "Wave",
"description": "Open-Source AI-Native Terminal Built for Seamless Workflows",
"license": "Apache-2.0",
- "version": "0.14.5",
+ "version": "0.14.6",
"homepage": "https://waveterm.dev",
"build": {
"appId": "dev.commandline.waveterm"
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"`
diff --git a/pkg/wconfig/defaultconfig/settings.json b/pkg/wconfig/defaultconfig/settings.json
index d8847cabf2..f9ed4b5957 100644
--- a/pkg/wconfig/defaultconfig/settings.json
+++ b/pkg/wconfig/defaultconfig/settings.json
@@ -32,6 +32,8 @@
"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",
"term:cursorblink": false,