From 447cba7a351254266a140b67c261c8c345457cd7 Mon Sep 17 00:00:00 2001 From: dante Date: Sun, 24 May 2026 18:33:38 +0900 Subject: [PATCH 1/8] 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/8] 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/8] 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/8] 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/8] 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/8] 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) { From 8f2af92dd5336f70a1ecbde97088c0bc2bd7e8e9 Mon Sep 17 00:00:00 2001 From: dante Date: Tue, 2 Jun 2026 00:31:29 +0900 Subject: [PATCH 7/8] Fix terminal IME composition release --- Taskfile.yml | 6 +- electron-builder.config.cjs | 46 +++++++++- frontend/app/onboarding/onboarding-common.tsx | 2 +- .../onboarding/onboarding-upgrade-patch.tsx | 22 +++-- .../onboarding/onboarding-upgrade-v0146.tsx | 45 ++++++++++ frontend/app/view/term/termwrap.test.ts | 88 +++++++++++++++++++ frontend/app/view/term/termwrap.ts | 66 ++++++++++---- package-lock.json | 4 +- package.json | 2 +- 9 files changed, 240 insertions(+), 41 deletions(-) create mode 100644 frontend/app/onboarding/onboarding-upgrade-v0146.tsx create mode 100644 frontend/app/view/term/termwrap.test.ts diff --git a/Taskfile.yml b/Taskfile.yml index bf37a83e45..14d7d084c1 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -124,12 +124,12 @@ tasks: package: desc: Package the application for the current platform. cmds: + - task: clean + - task: build:backend + - task: build:tsunamiscaffold - npm run build:prod && npm exec electron-builder -- -c electron-builder.config.cjs -p never {{.CLI_ARGS}} deps: - - clean - npm:install - - build:backend - - build:tsunamiscaffold build:frontend:dev: desc: Build the frontend in development mode. diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs index d49f2da616..b94da50ad2 100644 --- a/electron-builder.config.cjs +++ b/electron-builder.config.cjs @@ -4,6 +4,44 @@ const fs = require("fs"); const path = require("path"); const windowsShouldSign = !!process.env.SM_CODE_SIGNING_CERT_SHA1_HASH; +const archNameByValue = { + [Arch.x64]: "x64", + [Arch.arm64]: "arm64", + [Arch.universal]: "universal", +}; + +function getPackageBinDir(context) { + if (context.electronPlatformName !== "darwin") { + return path.resolve(context.appOutDir, "resources/app.asar.unpacked/dist/bin"); + } + return path.resolve(context.appOutDir, `${pkg.productName}.app/Contents/Resources/app.asar.unpacked/dist/bin`); +} + +function getExpectedPackageBinaries(context) { + const platform = context.electronPlatformName; + const arch = archNameByValue[context.arch] ?? context.arch; + const exeExt = platform === "win32" ? ".exe" : ""; + + if (platform === "darwin" && arch === "universal") { + return ["wavesrv.arm64", "wavesrv.x64", `wsh-${pkg.version}-darwin.arm64`, `wsh-${pkg.version}-darwin.x64`]; + } + + return [ + `wavesrv.${arch}${exeExt}`, + `wsh-${pkg.version}-${platform === "win32" ? "windows" : platform}.${arch}${exeExt}`, + ]; +} + +function validatePackagedBinaries(context) { + const packageBinDir = getPackageBinDir(context); + const missing = getExpectedPackageBinaries(context).filter( + (file) => !fs.existsSync(path.resolve(packageBinDir, file)) + ); + if (missing.length === 0) { + return; + } + throw new Error(`Missing packaged Wave binaries in ${packageBinDir}: ${missing.join(", ")}`); +} /** * @type {import('electron-builder').Configuration} @@ -61,6 +99,7 @@ const config = { singleArchFiles: "**/dist/bin/wavesrv.*", entitlements: "build/entitlements.mac.plist", entitlementsInherit: "build/entitlements.mac.plist", + notarize: !!process.env.APPLE_API_KEY || !!process.env.APPLE_ID, extendInfo: { NSContactsUsageDescription: "A CLI application running in Wave wants to use your contacts.", NSRemindersUsageDescription: "A CLI application running in Wave wants to use your reminders.", @@ -122,12 +161,11 @@ const config = { url: "https://dl.waveterm.dev/releases-w2", }, afterPack: (context) => { + validatePackagedBinaries(context); + // This is a workaround to restore file permissions to the wavesrv binaries on macOS after packaging the universal binary. if (context.electronPlatformName === "darwin" && context.arch === Arch.universal) { - const packageBinDir = path.resolve( - context.appOutDir, - `${pkg.productName}.app/Contents/Resources/app.asar.unpacked/dist/bin` - ); + const packageBinDir = getPackageBinDir(context); // Reapply file permissions to the wavesrv binaries in the final app package fs.readdirSync(packageBinDir, { diff --git a/frontend/app/onboarding/onboarding-common.tsx b/frontend/app/onboarding/onboarding-common.tsx index 41f05e1f43..2641604f60 100644 --- a/frontend/app/onboarding/onboarding-common.tsx +++ b/frontend/app/onboarding/onboarding-common.tsx @@ -1,7 +1,7 @@ // Copyright 2026, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 -export const CurrentOnboardingVersion = "v0.14.5"; +export const CurrentOnboardingVersion = "v0.14.6"; export function OnboardingGradientBg() { return ( diff --git a/frontend/app/onboarding/onboarding-upgrade-patch.tsx b/frontend/app/onboarding/onboarding-upgrade-patch.tsx index 87ffadb1e4..4e12b68fb4 100644 --- a/frontend/app/onboarding/onboarding-upgrade-patch.tsx +++ b/frontend/app/onboarding/onboarding-upgrade-patch.tsx @@ -27,6 +27,7 @@ import { UpgradeOnboardingModal_v0_14_1_Content } from "./onboarding-upgrade-v01 import { UpgradeOnboardingModal_v0_14_2_Content } from "./onboarding-upgrade-v0142"; import { UpgradeOnboardingModal_v0_14_4_Content } from "./onboarding-upgrade-v0144"; import { UpgradeOnboardingModal_v0_14_5_Content } from "./onboarding-upgrade-v0145"; +import { UpgradeOnboardingModal_v0_14_6_Content } from "./onboarding-upgrade-v0146"; interface VersionConfig { version: string; @@ -64,10 +65,7 @@ export function UpgradeOnboardingFooter({
{hasPrev && (
-
@@ -81,10 +79,7 @@ export function UpgradeOnboardingFooter({
{hasNext && (
-
@@ -153,6 +148,12 @@ export const UpgradeOnboardingVersions: VersionConfig[] = [ version: "v0.14.5", content: () => , prevText: "Prev (v0.14.4)", + nextText: "Next (v0.14.6)", + }, + { + version: "v0.14.6", + content: () => , + prevText: "Prev (v0.14.5)", }, ]; @@ -242,10 +243,7 @@ const UpgradeOnboardingPatch = ({ isReleaseNotes = false }: UpgradeOnboardingPat if (showStarAsk) { return ( - +
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/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 052cfe5afa..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 { @@ -114,7 +115,7 @@ export class TermWrap { // IME composition ordering compositionActive: boolean = false; compositionRecentlyEndedUntil: number = 0; - pendingCompositionSuffix: { data: string; timeout: ReturnType } | null = null; + pendingCompositionSuffix: { data: string; timeout: ReturnType | null } | null = null; disposed: boolean = false; // dev only (for debugging) @@ -491,6 +492,7 @@ export class TermWrap { const compositionEndHandler = () => { this.compositionActive = false; this.compositionRecentlyEndedUntil = Date.now() + 75; + this.schedulePendingCompositionSuffixFlush(); }; textarea.addEventListener("compositionstart", compositionStartHandler); textarea.addEventListener("compositionend", compositionEndHandler); @@ -523,7 +525,9 @@ export class TermWrap { return; } const pendingData = this.pendingCompositionSuffix.data; - clearTimeout(this.pendingCompositionSuffix.timeout); + if (this.pendingCompositionSuffix.timeout != null) { + clearTimeout(this.pendingCompositionSuffix.timeout); + } this.pendingCompositionSuffix = null; if (!this.loaded || this.disposed) { return; @@ -535,10 +539,28 @@ export class TermWrap { if (this.pendingCompositionSuffix == null) { return; } - clearTimeout(this.pendingCompositionSuffix.timeout); + 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; @@ -557,7 +579,7 @@ export class TermWrap { } isCompositionSuffixData(data: string): boolean { - if (data.length === 0) { + if (data.length === 0 || data.length > MaxCompositionSuffixLength) { return false; } for (const ch of data) { @@ -569,6 +591,16 @@ export class TermWrap { 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; @@ -577,34 +609,32 @@ export class TermWrap { if (this.pendingCompositionSuffix != null) { if (this.isLikelyCompositionText(data)) { const pendingData = this.pendingCompositionSuffix.data; - clearTimeout(this.pendingCompositionSuffix.timeout); + if (this.pendingCompositionSuffix.timeout != null) { + clearTimeout(this.pendingCompositionSuffix.timeout); + } this.pendingCompositionSuffix = null; this.sendTermData(data); this.sendTermData(pendingData); return; } - if (this.isCompositionSuffixData(data) && Date.now() <= this.compositionRecentlyEndedUntil) { - clearTimeout(this.pendingCompositionSuffix.timeout); + if (this.shouldDeferCompositionData(data)) { + if (this.pendingCompositionSuffix.timeout != null) { + clearTimeout(this.pendingCompositionSuffix.timeout); + this.pendingCompositionSuffix.timeout = null; + } this.pendingCompositionSuffix.data += data; - this.pendingCompositionSuffix.timeout = setTimeout(() => { - this.flushPendingCompositionSuffix(); - }, 30); + this.schedulePendingCompositionSuffixFlush(); return; } this.flushPendingCompositionSuffix(); } - if ( - this.isCompositionSuffixData(data) && - !this.compositionActive && - Date.now() <= this.compositionRecentlyEndedUntil - ) { + if (this.shouldDeferCompositionData(data)) { this.pendingCompositionSuffix = { data, - timeout: setTimeout(() => { - this.flushPendingCompositionSuffix(); - }, 30), + timeout: null, }; + this.schedulePendingCompositionSuffixFlush(); return; } 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" From 95516dc2e4b44b6172f7aa7d0b4f383a96e44202 Mon Sep 17 00:00:00 2001 From: dante Date: Tue, 2 Jun 2026 09:34:19 +0900 Subject: [PATCH 8/8] Fix macOS wavesrv deployment target --- Taskfile.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Taskfile.yml b/Taskfile.yml index 14d7d084c1..991b968f80 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -199,6 +199,7 @@ tasks: - task: build:server:internal vars: ARCHS: arm64,amd64 + GO_ENV_VARS: MACOSX_DEPLOYMENT_TARGET=12.0 build:server:quickdev: desc: Build the wavesrv component for quickdev (arm64 macOS only, no generate). @@ -207,6 +208,7 @@ tasks: - task: build:server:internal vars: ARCHS: arm64 + GO_ENV_VARS: MACOSX_DEPLOYMENT_TARGET=12.0 deps: - go:mod:tidy sources: