mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
fix(xterm): keep a live composition across a lone Cmd press on macOS
CompositionHelper.keydown exempted keyCodes 16/17/18 from tearing a composition down, which covers Shift/Ctrl/Alt but not macOS Meta — 91/93 in Chromium, 224 in Firefox. A lone Cmd press mid-composition therefore reached _finalizeComposition(false), which dropped the preedit overlay's `active` class and committed the live syllable early. macOS keeps the marked text alive across that press, so no later compositionstart re-arms the overlay and the rest of the word composes invisibly. Measured on hardware (m4air, macOS 26.5.2, Apple M4, 2-Set Korean) with the Cmd posted as a CGEventType.flagsChanged, which is what a physical modifier emits. AppleScript `key code 55` posts nothing a browser can see — a bare `key code 56` for Shift is equally silent — which is why no capture in the corpus ever reached this branch. Three arms, same build otherwise: overlay live throughout with the exemption, dark and prematurely committed without it, live again with it restored. Evidence under .tmp/ime-handoff/swarm-scratch/wave31-cmd-preedit/evidence/. The fix cannot widen past a lone modifier: only a standalone press reports these keyCodes, and a Cmd chord during composition is reported by Chromium as 229, which was already exempt. Cmd+A still ends the composition, via the IME's own compositionend. Ghostty draws the same line, returning early from flagsChanged under hasMarkedText() for every modifier including Super. Orca's terminal pane was never affected — shouldSuppressTerminalModifierKeyboardEvent drops a standalone Meta keydown before xterm sees it, and deleting only 'Meta' from that set is what flipped the hardware arm to broken. The popout preview terminal and mobile's webview install no such guard and did reach the teardown. terminal-ime-xterm-composition-commit-overlap.test.ts asked its fixer to update the two Cmd arms to the values it named as correct; both now emit a single ['한'].
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -70,7 +70,7 @@ index 5a1e6c38c9799f7f57d5df6d4a122a7beb0cf04f..2d78e414177804261acbbe2f53d71b5c
|
||||
|
||||
describe('Input', () => {
|
||||
diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts
|
||||
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..67188a5c3310ac694bde8112f5387eb712227358 100644
|
||||
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..ea77d070322479e75201970d63fc41336d64dc93 100644
|
||||
--- a/src/browser/input/CompositionHelper.ts
|
||||
+++ b/src/browser/input/CompositionHelper.ts
|
||||
@@ -4,8 +4,7 @@
|
||||
@@ -149,7 +149,20 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..67188a5c3310ac694bde8112f5387eb7
|
||||
/**
|
||||
* Handles the compositionupdate event, updating the composition view.
|
||||
* @param ev The event.
|
||||
@@ -129,9 +142,6 @@ export class CompositionHelper {
|
||||
@@ -119,8 +132,10 @@ export class CompositionHelper {
|
||||
// Continue composing if the keyCode is the "composition character"
|
||||
return false;
|
||||
}
|
||||
- if (ev.keyCode === 16 || ev.keyCode === 17 || ev.keyCode === 18) {
|
||||
- // Continue composing if the keyCode is a modifier key
|
||||
+ if (ev.keyCode === 16 || ev.keyCode === 17 || ev.keyCode === 18 || ev.keyCode === 91 || ev.keyCode === 93 || ev.keyCode === 224) {
|
||||
+ // Continue composing if the keyCode is a modifier key. 91/93 are Chromium's left and
|
||||
+ // right Meta, 224 is Firefox's. Only a lone modifier press reports these, and on macOS
|
||||
+ // finalizing there hides the preedit for the rest of a word the IME is still composing.
|
||||
return false;
|
||||
}
|
||||
// Finish composition immediately. This is mainly here for the case where enter is
|
||||
@@ -129,9 +144,6 @@ export class CompositionHelper {
|
||||
}
|
||||
|
||||
if (ev.keyCode === 229) {
|
||||
@@ -159,7 +172,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..67188a5c3310ac694bde8112f5387eb7
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -153,7 +163,11 @@ export class CompositionHelper {
|
||||
@@ -153,7 +165,11 @@ export class CompositionHelper {
|
||||
if (!waitForPropagation) {
|
||||
// Cancel any delayed composition send requests and send the input immediately.
|
||||
this._isSendingComposition = false;
|
||||
@@ -172,7 +185,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..67188a5c3310ac694bde8112f5387eb7
|
||||
this._coreService.triggerDataEvent(input, true);
|
||||
} else {
|
||||
// Make a deep copy of the composition position here as a new compositionstart event may
|
||||
@@ -163,6 +177,7 @@ export class CompositionHelper {
|
||||
@@ -163,6 +179,7 @@ export class CompositionHelper {
|
||||
end: this._compositionPosition.end
|
||||
};
|
||||
const currentCompositionSuffix = this._compositionSuffix;
|
||||
@@ -180,7 +193,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..67188a5c3310ac694bde8112f5387eb7
|
||||
|
||||
// Since composition* events happen before the changes take place in the textarea on most
|
||||
// browsers, use a setTimeout with 0ms time to allow the native compositionend event to
|
||||
@@ -175,12 +190,10 @@ export class CompositionHelper {
|
||||
@@ -175,12 +192,10 @@ export class CompositionHelper {
|
||||
this._isSendingComposition = true;
|
||||
setTimeout(() => {
|
||||
// Ensure that the input has not already been sent
|
||||
@@ -196,7 +209,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..67188a5c3310ac694bde8112f5387eb7
|
||||
if (this._isComposing) {
|
||||
// Use the start position of the new composition to get the string
|
||||
// if a new composition has started.
|
||||
@@ -195,47 +208,22 @@ export class CompositionHelper {
|
||||
@@ -195,47 +210,22 @@ export class CompositionHelper {
|
||||
: value.length;
|
||||
input = value.substring(currentCompositionPosition.start, Math.max(currentCompositionPosition.start, valueEnd));
|
||||
}
|
||||
@@ -255,7 +268,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..67188a5c3310ac694bde8112f5387eb7
|
||||
/**
|
||||
* Positions the composition view on top of the cursor and the textarea just below it (so the
|
||||
* IME helper dialog is positioned correctly).
|
||||
@@ -260,6 +248,7 @@ export class CompositionHelper {
|
||||
@@ -260,6 +250,7 @@ export class CompositionHelper {
|
||||
this._compositionView.style.lineHeight = cellHeight + 'px';
|
||||
this._compositionView.style.fontFamily = this._optionsService.rawOptions.fontFamily;
|
||||
this._compositionView.style.fontSize = this._optionsService.rawOptions.fontSize + 'px';
|
||||
@@ -263,7 +276,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..67188a5c3310ac694bde8112f5387eb7
|
||||
// Limit the composition view width to the space between the cursor and
|
||||
// the terminal's right edge, preventing it from overflowing the terminal.
|
||||
const maxWidth = this._bufferService.cols * this._renderService.dimensions.css.cell.width - cursorLeft;
|
||||
@@ -281,4 +270,39 @@ export class CompositionHelper {
|
||||
@@ -281,4 +272,39 @@ export class CompositionHelper {
|
||||
setTimeout(() => this.updateCompositionElements(true), 0);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+9
-9
@@ -9,7 +9,7 @@ overrides:
|
||||
|
||||
patchedDependencies:
|
||||
'@xterm/xterm@6.1.0-beta.287':
|
||||
hash: 8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6
|
||||
hash: 4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379
|
||||
path: ../config/patches/@xterm__xterm@6.1.0-beta.287.patch
|
||||
react-native@0.83.9:
|
||||
hash: 1e3ab9b5d076bb62fe44450f30c97b71445aa431bdef314de9671c7ed6f095f1
|
||||
@@ -33,13 +33,13 @@ importers:
|
||||
version: 2.2.0(react-native@0.83.9(patch_hash=1e3ab9b5d076bb62fe44450f30c97b71445aa431bdef314de9671c7ed6f095f1)(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))
|
||||
'@xterm/addon-unicode11':
|
||||
specifier: 0.10.0-beta.287
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
|
||||
'@xterm/addon-webgl':
|
||||
specifier: 0.20.0-beta.286
|
||||
version: 0.20.0-beta.286(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))
|
||||
version: 0.20.0-beta.286(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
|
||||
'@xterm/xterm':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)
|
||||
version: 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
|
||||
buffer:
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.3
|
||||
@@ -10363,15 +10363,15 @@ snapshots:
|
||||
|
||||
'@xmldom/xmldom@0.9.10': {}
|
||||
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))':
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
|
||||
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))':
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
|
||||
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)': {}
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)': {}
|
||||
|
||||
abab@2.0.6: {}
|
||||
|
||||
|
||||
Generated
+24
-24
@@ -19,7 +19,7 @@ patchedDependencies:
|
||||
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
|
||||
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
|
||||
'@xterm/xterm@6.1.0-beta.287':
|
||||
hash: 8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6
|
||||
hash: 4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379
|
||||
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
|
||||
node-pty@1.1.0:
|
||||
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
|
||||
@@ -46,7 +46,7 @@ importers:
|
||||
version: 2.5.6
|
||||
'@xterm/addon-serialize':
|
||||
specifier: 0.15.0-beta.287
|
||||
version: 0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))
|
||||
version: 0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
|
||||
'@xterm/headless':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287
|
||||
@@ -206,25 +206,25 @@ importers:
|
||||
version: 5.2.0(rolldown-vite@7.3.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@25.9.5)(jiti@2.7.0)(yaml@2.8.4))
|
||||
'@xterm/addon-fit':
|
||||
specifier: 0.12.0-beta.287
|
||||
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))
|
||||
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
|
||||
'@xterm/addon-ligatures':
|
||||
specifier: 0.11.0-beta.287
|
||||
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))
|
||||
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
|
||||
'@xterm/addon-search':
|
||||
specifier: 0.17.0-beta.287
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))
|
||||
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
|
||||
'@xterm/addon-unicode11':
|
||||
specifier: 0.10.0-beta.287
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))
|
||||
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
|
||||
'@xterm/addon-web-links':
|
||||
specifier: 0.13.0-beta.287
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))
|
||||
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
|
||||
'@xterm/addon-webgl':
|
||||
specifier: 0.20.0-beta.286
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))
|
||||
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))
|
||||
'@xterm/xterm':
|
||||
specifier: 6.1.0-beta.287
|
||||
version: 6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)
|
||||
version: 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -9582,39 +9582,39 @@ snapshots:
|
||||
|
||||
'@xmldom/xmldom@0.8.13': {}
|
||||
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))':
|
||||
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
|
||||
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))':
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
|
||||
lru-cache: 11.5.1
|
||||
opentype.js: 2.0.0
|
||||
|
||||
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))':
|
||||
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
|
||||
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))':
|
||||
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=96f70e83261df6a29ad7590feb08b988670655ced7596e77253d524f73f608dd)(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
|
||||
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))':
|
||||
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
|
||||
|
||||
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))':
|
||||
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
|
||||
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6))':
|
||||
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379))':
|
||||
dependencies:
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)
|
||||
'@xterm/xterm': 6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)
|
||||
|
||||
'@xterm/headless@6.1.0-beta.287': {}
|
||||
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=8d63166272e9040a343ff7070fb5bf44d9b3465b8042211765341c5483db55a6)': {}
|
||||
'@xterm/xterm@6.1.0-beta.287(patch_hash=4f1b42d268f3964dc827e9eeb373a1b9f2d30773d1747a4876ddc2aa71d75379)': {}
|
||||
|
||||
abbrev@4.0.0: {}
|
||||
|
||||
|
||||
+33
-26
@@ -4,17 +4,26 @@
|
||||
// Two ways xterm's composition commit path corrupts the bytes the PTY child reads.
|
||||
// Both are asserted at onData, not in the DOM: the defect is in the data, not the paint.
|
||||
//
|
||||
// 1. A non-exempt keydown during a live composition reaches _finalizeComposition(false)
|
||||
// — the IMMEDIATE branch, which computes its range from a live selectionEnd and does
|
||||
// not consult _compositionSuffix at all. It commits that range at once, and nothing
|
||||
// records what it consumed, so the IME's own compositionend commits an overlapping
|
||||
// range and the syllable reaches onData TWICE. macOS Meta (91/93/224) is the
|
||||
// production instance: CompositionHelper.keydown exempts only 16/17/18 and 20/229, so
|
||||
// Cmd takes this path where Ctrl does not. This is the data consequence of the
|
||||
// teardown pinned by terminal-ime-xterm-composition-modifier-exemption.test.ts, which
|
||||
// deliberately asserted only the overlay and left the duplicated commit unpinned.
|
||||
// Cmd was checked against the swallow in (2) and does NOT reach it: Cmd duplicates,
|
||||
// it does not drop. The two hazards below share no trigger.
|
||||
// 1. FIXED — the two Cmd arms below now assert the repaired contract. A non-exempt keydown
|
||||
// during a live composition reaches _finalizeComposition(false) — the IMMEDIATE branch,
|
||||
// which computes its range from a live selectionEnd and does not consult
|
||||
// _compositionSuffix at all. It commits that range at once, and nothing records what it
|
||||
// consumed, so the IME's own compositionend commits an overlapping range and the
|
||||
// syllable reaches onData TWICE. macOS Meta was the production instance, because
|
||||
// CompositionHelper.keydown exempted only 16/17/18 and 20/229 and so Cmd took this path
|
||||
// where Ctrl did not. Our patch adds 91/93/224 to that exemption set, and both arms now
|
||||
// emit the single ['한'] this file already named as correct.
|
||||
//
|
||||
// ITS CAVEAT 3 BELOW IS NOW PART-MEASURED, and only part. Wave31 drove a bare Cmd into a
|
||||
// live 2-Set Korean preedit on real hardware (m4air, macOS 26.5.2, Apple M4) as a
|
||||
// CGEventType.flagsChanged — AppleScript `key code 55` posts nothing a browser can see,
|
||||
// which is why no earlier lane could reach this gesture. Chromium delivers
|
||||
// `keydown key="Meta" keyCode=91 isComposing=true`, and with the exemption removed the
|
||||
// preedit overlay went dark AND the live syllable was committed early to onData. So the
|
||||
// early commit is now observed rather than inferred. The SECOND half — a later
|
||||
// compositionend re-emitting the same range — is still inferred: that run escaped the
|
||||
// composition instead of finishing it. Evidence:
|
||||
// .tmp/ime-handoff/swarm-scratch/wave31-cmd-preedit/evidence/.
|
||||
//
|
||||
// 2. FIXED — the arm below now asserts the repaired contract, not a defect. An uncomposed
|
||||
// insertText landing in the window after the commit timer had already sent used to be
|
||||
@@ -84,9 +93,8 @@
|
||||
// The swallowed insertText was NOT: stock delivered the syllable and this bundle
|
||||
// dropped it, making it the one defect here that was ours rather than inherited.
|
||||
// That arm now asserts parity with stock.
|
||||
// 3. Inferred, not measured: that a real macOS IME delivers a compositionend for a
|
||||
// composition it kept alive across the Cmd. That is ordinary IME behaviour but no
|
||||
// capture contains the gesture, so the trigger is unverified on hardware.
|
||||
// 3. Partly measured now — see the wave31 note under hazard 1. The trigger and the early
|
||||
// commit are observed on hardware; the duplicating compositionend is still inferred.
|
||||
// 4. Unobserved, and the corpus cannot say more than that. Of 731 retained evidence
|
||||
// JSONs, 82 carry a keydown-bearing DOM trace; across those, all 3508 keydowns
|
||||
// during a live composition are 229 (3443) or Shift/16 (65), and none is
|
||||
@@ -97,9 +105,8 @@
|
||||
// during composition is keyCode 229 in every capture, and 229 returns early.
|
||||
// Do not reintroduce a Space arm.
|
||||
//
|
||||
// The Cmd arms still pin CURRENT broken behaviour. When someone fixes them they will fail
|
||||
// — update the expectations to the correct values named in each comment. Do not work
|
||||
// around them.
|
||||
// Nothing in this file pins broken behaviour any more. Both hazards are repaired and every
|
||||
// arm asserts the contract, so a failure here is a regression, not a known defect.
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -205,17 +212,17 @@ describe('xterm CompositionHelper — overlapping and swallowed commits at onDat
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it('commits the syllable twice when Cmd interrupts the composition', async () => {
|
||||
it('commits the syllable once when Cmd interrupts the composition', async () => {
|
||||
const { emitted, textarea } = openTerminal()
|
||||
await composeHanInterruptedBy(textarea, 'Meta')
|
||||
|
||||
// The early commit emits the bare jamo, then compositionend emits the finished
|
||||
// syllable over the same range. CORRECT would be ['한'].
|
||||
// Pristine beta.287 is worse here, emitting ['ㅎ', '한', '한'].
|
||||
expect(emitted).toEqual(['ㅎ', '한'])
|
||||
// Was ['ㅎ', '한'] — the early commit emitted the bare jamo, then compositionend emitted
|
||||
// the finished syllable over the same range. Pristine beta.287 is worse still, emitting
|
||||
// ['ㅎ', '한', '한'].
|
||||
expect(emitted).toEqual(['한'])
|
||||
})
|
||||
|
||||
it('commits the syllable twice when Cmd arrives after the last preedit', async () => {
|
||||
it('commits the syllable once when Cmd arrives after the last preedit', async () => {
|
||||
const { emitted, textarea } = openTerminal()
|
||||
dispatchProcessKeydown(textarea)
|
||||
dispatchCompositionEvent(textarea, 'compositionstart')
|
||||
@@ -226,9 +233,9 @@ describe('xterm CompositionHelper — overlapping and swallowed commits at onDat
|
||||
await nextEventLoop()
|
||||
await nextEventLoop()
|
||||
|
||||
// CORRECT would be ['한']. Pristine beta.287 emits the same two commits, which is
|
||||
// what makes this arm version-neutral.
|
||||
expect(emitted).toEqual(['한', '한'])
|
||||
// Was ['한', '한'], and pristine beta.287 still emits both — the defect this arm covers
|
||||
// was inherited, not ours, so the repair is ours alone.
|
||||
expect(emitted).toEqual(['한'])
|
||||
})
|
||||
|
||||
it('leaves the composition intact when Ctrl interrupts in the same position', async () => {
|
||||
|
||||
+55
-47
@@ -1,24 +1,29 @@
|
||||
// @vitest-environment happy-dom
|
||||
// HAZARD PIN — owns no reported row. Read this before treating it as a regression guard.
|
||||
// Guards the Meta entries in xterm's composition modifier exemption (our patch to
|
||||
// CompositionHelper.keydown). Every event shape below was recorded on real macOS 2-Set Korean
|
||||
// hardware — m4air, macOS 26.5.2, Apple M4 — not constructed from the spec; see
|
||||
// `.tmp/ime-handoff/swarm-scratch/wave31-cmd-preedit/evidence/`.
|
||||
//
|
||||
// xterm's CompositionHelper.keydown exempts keyCode 16/17/18 (Shift/Ctrl/Alt) and
|
||||
// 20/229 from tearing down a live composition. macOS Meta is 91/93/224 and is NOT in
|
||||
// that set, so a Cmd press mid-composition reaches _finalizeComposition(false), which
|
||||
// drops the compositionView's `active` class. The overlay never recovers, because the
|
||||
// IME does not re-fire compositionstart — the rest of the word composes with no visible
|
||||
// preedit. Linux/Windows users press Ctrl (17) and are exempt; macOS users are not.
|
||||
// What the hardware showed, A/B'd against a build with the guard removed:
|
||||
// - A lone Cmd press mid-composition arrives as `keydown key="Meta" code="MetaLeft"
|
||||
// keyCode=91 isComposing=true`, and macOS keeps the marked text alive across it.
|
||||
// - Without the exemption that keydown reaches `_finalizeComposition(false)`, which drops the
|
||||
// overlay's `active` class AND commits the live syllable early. The IME then keeps composing
|
||||
// the same syllable with no `compositionstart`, so nothing ever re-arms the overlay and the
|
||||
// rest of the word is invisible.
|
||||
// - A Cmd *chord* never travels this path: while composing, Chromium reports it as keyCode 229
|
||||
// (already exempt), and the IME ends the composition itself with a real `compositionend`.
|
||||
// So exempting Meta cannot keep a composition alive across Cmd+A — only across a lone Cmd.
|
||||
//
|
||||
// Three things a future reader must not misread:
|
||||
// 1. No reporter has filed this. It matched no open row: every candidate is bound to a
|
||||
// version window, and this is version-NEUTRAL — defective on both 1.4.162 (pristine
|
||||
// beta.287) and 1.4.163 (patched), in different shapes.
|
||||
// 2. The branch is unexercised in all 328 recorded IME traces. Every keydown ever
|
||||
// captured during composition is either 229 or Shift/16, so the exemption set covers
|
||||
// the whole corpus and nothing observed reaches the teardown.
|
||||
// 3. Only the teardown is asserted. A Cmd press also makes the syllable commit twice,
|
||||
// but that depends on the IME delivering a later compositionend for a composition it
|
||||
// kept alive across the Cmd — normal IME behavior, yet unverified on hardware, since
|
||||
// no capture contains this gesture. Deliberately not pinned.
|
||||
// Ghostty makes the same call independently: `flagsChanged` returns early under `hasMarkedText()`
|
||||
// for every modifier including Super (macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift).
|
||||
//
|
||||
// Scope, so nobody over-reads this file: the terminal PANE was never affected, because
|
||||
// `shouldSuppressTerminalModifierKeyboardEvent` drops a standalone Meta keydown before xterm sees
|
||||
// it — added for stale Kitty reporting, protective here by accident. That was proven on hardware:
|
||||
// deleting only 'Meta' from TERMINAL_MODIFIER_KEYS is what flipped the clean arm to the broken one.
|
||||
// The surfaces that did reach the teardown are the popout preview terminal, whose handler returns
|
||||
// `true` for an IME-owned event, and mobile's webview, which installs no handler at all.
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
@@ -29,6 +34,7 @@ function nextEventLoop(): Promise<void> {
|
||||
type Harness = {
|
||||
compositionView: HTMLElement
|
||||
textarea: HTMLTextAreaElement
|
||||
emitted: string[]
|
||||
}
|
||||
|
||||
function openTerminal(): Harness {
|
||||
@@ -44,7 +50,9 @@ function openTerminal(): Harness {
|
||||
if (!(compositionView instanceof HTMLElement)) {
|
||||
throw new Error('xterm composition view was not created')
|
||||
}
|
||||
return { compositionView, textarea }
|
||||
const emitted: string[] = []
|
||||
terminal.onData((data) => emitted.push(data))
|
||||
return { compositionView, textarea, emitted }
|
||||
}
|
||||
|
||||
function dispatchCompositionEvent(
|
||||
@@ -65,7 +73,7 @@ function dispatchKey(
|
||||
key: string,
|
||||
code: string
|
||||
): void {
|
||||
const event = new KeyboardEvent(type, { key, code, isComposing: keyCode === 229, bubbles: true })
|
||||
const event = new KeyboardEvent(type, { key, code, isComposing: true, bubbles: true })
|
||||
Object.defineProperty(event, 'keyCode', { value: keyCode })
|
||||
textarea.dispatchEvent(event)
|
||||
}
|
||||
@@ -99,7 +107,7 @@ async function composeHan(harness: Harness): Promise<void> {
|
||||
await typeJamo(harness, '한', 'KeyS')
|
||||
}
|
||||
|
||||
/** Press and release a modifier while the composition is live, then keep composing. */
|
||||
/** Press and release a lone modifier while the composition is live. */
|
||||
async function pressModifierMidComposition(
|
||||
harness: Harness,
|
||||
keyCode: number,
|
||||
@@ -115,10 +123,13 @@ async function pressModifierMidComposition(
|
||||
const EXEMPT_MODIFIERS: [string, number, string, string][] = [
|
||||
['Shift', 16, 'Shift', 'ShiftLeft'],
|
||||
['Ctrl', 17, 'Control', 'ControlLeft'],
|
||||
['Alt', 18, 'Alt', 'AltLeft']
|
||||
['Alt', 18, 'Alt', 'AltLeft'],
|
||||
['left Cmd', 91, 'Meta', 'MetaLeft'],
|
||||
['right Cmd', 93, 'Meta', 'MetaRight'],
|
||||
['Firefox Meta', 224, 'Meta', 'MetaLeft']
|
||||
]
|
||||
|
||||
describe('xterm composition modifier exemption — macOS Cmd is missing from the safe set', () => {
|
||||
describe('xterm composition modifier exemption', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
|
||||
measureText: () => ({ width: 10 })
|
||||
@@ -130,42 +141,39 @@ describe('xterm composition modifier exemption — macOS Cmd is missing from the
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['left Cmd', 91, 'Meta', 'MetaLeft'],
|
||||
['right Cmd', 93, 'Meta', 'MetaRight']
|
||||
])(
|
||||
'%s tears the preedit overlay down and it never comes back',
|
||||
async (_label, keyCode, key, code) => {
|
||||
const harness = await openTerminal()
|
||||
await composeHan(harness)
|
||||
// Precondition: without it, "inactive" below would be vacuous.
|
||||
expect(harness.compositionView.classList.contains('active')).toBe(true)
|
||||
|
||||
await pressModifierMidComposition(harness, keyCode, key, code)
|
||||
expect(harness.compositionView.classList.contains('active')).toBe(false)
|
||||
|
||||
// The IME never ended the composition, so the user keeps typing the same word —
|
||||
// and every further jamo stays invisible, because only compositionstart re-arms
|
||||
// the overlay and the IME has no reason to send one.
|
||||
await typeJamo(harness, '한ㄱ', 'KeyR')
|
||||
expect(harness.compositionView.textContent).toContain('한ㄱ')
|
||||
expect(harness.compositionView.classList.contains('active')).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it.each(EXEMPT_MODIFIERS)(
|
||||
'paired negative: %s is exempt, so the same gesture keeps the preedit displayed',
|
||||
'a lone %s keeps the preedit displayed and uncommitted',
|
||||
async (_label, keyCode, key, code) => {
|
||||
const harness = await openTerminal()
|
||||
await composeHan(harness)
|
||||
// Precondition: without it, "still active" below would be vacuous.
|
||||
expect(harness.compositionView.classList.contains('active')).toBe(true)
|
||||
|
||||
await pressModifierMidComposition(harness, keyCode, key, code)
|
||||
expect(harness.compositionView.classList.contains('active')).toBe(true)
|
||||
// The IME still owns the syllable, so nothing may reach the PTY yet.
|
||||
expect(harness.emitted).toEqual([])
|
||||
|
||||
// Recorded on hardware: composition continues with no further compositionstart, so an
|
||||
// overlay dropped by the modifier would never come back.
|
||||
await typeJamo(harness, '한ㄱ', 'KeyR')
|
||||
expect(harness.compositionView.textContent).toContain('한ㄱ')
|
||||
expect(harness.compositionView.classList.contains('active')).toBe(true)
|
||||
expect(harness.emitted).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
it('ordinary negative: Enter still finalizes the composition immediately', async () => {
|
||||
const harness = await openTerminal()
|
||||
await composeHan(harness)
|
||||
expect(harness.compositionView.classList.contains('active')).toBe(true)
|
||||
|
||||
dispatchKey(harness.textarea, 'keydown', 13, 'Enter', 'Enter')
|
||||
await nextEventLoop()
|
||||
|
||||
// This is the case the immediate-finalize branch exists for, so the ordering is the
|
||||
// assertion: the composition must reach the PTY before the carriage return does.
|
||||
expect(harness.compositionView.classList.contains('active')).toBe(false)
|
||||
expect(harness.emitted).toEqual(['한', '\r'])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user