fix(terminal): keep IME caret visible at row edge

This commit is contained in:
Neil
2026-08-29 05:29:28 -07:00
parent 84259ea589
commit e9f4a53e3f
7 changed files with 90 additions and 60 deletions
File diff suppressed because one or more lines are too long
@@ -88,7 +88,7 @@ index 497afcf535f3eaca00889525a77e15eb633ccd96..96d499b34605f860608382114c3fbdc0
export interface IBrowser {
diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..946a95d3b87d68f0f0324b5d74346d3f3ff3710c 100644
index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..af918fdac462a77517d9755bea3cb73aed97bfc4 100644
--- a/src/browser/input/CompositionHelper.ts
+++ b/src/browser/input/CompositionHelper.ts
@@ -3,8 +3,9 @@
@@ -933,7 +933,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..946a95d3b87d68f0f0324b5d74346d3f
this._dataAlreadySent = diff;
if (newValue.length > oldValue.length) {
@@ -236,6 +831,130 @@ export class CompositionHelper {
@@ -236,6 +831,133 @@ export class CompositionHelper {
}, 0);
}
@@ -948,14 +948,14 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..946a95d3b87d68f0f0324b5d74346d3f
+ this._resetCompositionView();
+ return;
+ }
+ // Keep DOM order LTR; right alignment follows the insertion end for long compositions while
+ // retaining the caret after the preedit.
+ // Keep DOM order LTR so the insertion caret follows the preedit.
+ const preeditText = `‎${data}‎`;
+ this._compositionViewData = data;
+ const doc = this._compositionView.ownerDocument;
+ const preedit = doc.createElement('span');
+ preedit.className = 'xterm-composition-preedit';
+ // Underlined so the composing text stays distinguishable from the tail it pushed right.
+ preedit.style.flexShrink = '0';
+ preedit.style.textDecoration = 'underline';
+ preedit.textContent = preeditText;
+ const caret = doc.createElement('span');
@@ -1024,6 +1024,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..946a95d3b87d68f0f0324b5d74346d3f
+ caret.style.backgroundColor =
+ this._themeService?.colors.cursor.css ?? theme?.cursor ?? theme?.foreground ?? '#FFF';
+ caret.style.display = 'inline-block';
+ caret.style.flexShrink = '0';
+ caret.style.height = cellHeight + 'px';
+ caret.style.marginLeft = -width + 'px';
+ caret.style.verticalAlign = 'top';
@@ -1037,6 +1038,8 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..946a95d3b87d68f0f0324b5d74346d3f
+ this._compositionRemainderMasked = false;
+ this._compositionCaret = undefined;
+ this._compositionViewData = '';
+ this._compositionView.style.display = '';
+ this._compositionView.style.justifyContent = '';
+ this._compositionView.style.textAlign = '';
+ }
+
@@ -1064,7 +1067,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..946a95d3b87d68f0f0324b5d74346d3f
/**
* Positions the composition view on top of the cursor and the textarea just below it (so the
* IME helper dialog is positioned correctly).
@@ -243,10 +962,24 @@ export class CompositionHelper {
@@ -243,10 +965,24 @@ export class CompositionHelper {
* necessary as the IME events across browsers are not consistently triggered.
*/
public updateCompositionElements(dontRecurse?: boolean): void {
@@ -1090,7 +1093,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..946a95d3b87d68f0f0324b5d74346d3f
if (this._bufferService.buffer.isCursorInViewport) {
const cursorX = Math.min(this._bufferService.buffer.x, this._bufferService.cols - 1);
@@ -265,10 +998,21 @@ export class CompositionHelper {
@@ -265,10 +1001,23 @@ export class CompositionHelper {
const maxWidth = this._bufferService.cols * this._renderService.dimensions.css.cell.width - cursorLeft;
this._compositionView.style.maxWidth = maxWidth + 'px';
this._compositionView.style.overflow = 'hidden';
@@ -1098,10 +1101,12 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..946a95d3b87d68f0f0324b5d74346d3f
- // Sync the textarea to the exact position of the composition view so the IME knows where the
- // text is.
- const compositionViewBounds = this._compositionView.getBoundingClientRect();
+ // Keep DOM order as preedit, caret, tail. At the right edge, right alignment clips the start
+ // of a long preedit while retaining its insertion caret, matching xterm's former rtl trick.
+ // End-aligned flex keeps an over-wide trailing preedit's caret inside the screen.
+ const hasRemainder = Boolean(this._compositionRemainder);
+ this._compositionView.style.direction = 'ltr';
+ this._compositionView.style.textAlign = this._compositionRemainder ? 'left' : 'right';
+ this._compositionView.style.display = hasRemainder ? '' : 'flex';
+ this._compositionView.style.justifyContent = hasRemainder ? '' : 'flex-end';
+ this._compositionView.style.textAlign = hasRemainder ? 'left' : '';
+ // Themed rather than the stock #000/#FFF, so the pushed tail reads as ordinary terminal text
+ // and light themes keep contrast.
+ this._compositionView.style.background = this._opaqueViewBackground();
@@ -1116,7 +1121,7 @@ index c9ec396ab66cb966d49aa63bed09cdf9cd6c4246..946a95d3b87d68f0f0324b5d74346d3f
this._textarea.style.left = cursorLeft + 'px';
this._textarea.style.top = cursorTop + 'px';
// Ensure the text area is at least 1x1, otherwise certain IMEs may break
@@ -278,7 +1022,8 @@ export class CompositionHelper {
@@ -278,7 +1027,8 @@ export class CompositionHelper {
}
if (!dontRecurse) {
+24 -24
View File
@@ -21,7 +21,7 @@ patchedDependencies:
hash: 118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
'@xterm/xterm@6.1.0-beta.287':
hash: 8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68
hash: 76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
lint-staged@16.4.0:
hash: 7333b3837f80a7fbd045964db6d76ba4fc118e49134bdbabb00585b6b7b60673
@@ -51,7 +51,7 @@ importers:
version: 2.5.6
'@xterm/addon-serialize':
specifier: 0.15.0-beta.287
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68))
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b))
'@xterm/headless':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
@@ -220,25 +220,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=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68))
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b))
'@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=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68))
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b))
'@xterm/addon-search':
specifier: 0.17.0-beta.287
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68))
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.287
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68))
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b))
'@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=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68))
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.286
version: 0.20.0-beta.286(patch_hash=118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68))
version: 0.20.0-beta.286(patch_hash=118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68)
version: 6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -9624,39 +9624,39 @@ snapshots:
'@xmldom/xmldom@0.8.13': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68))':
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b)
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68))':
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b)
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=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68))':
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68))':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b)
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68))':
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b)
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68))':
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68))':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=118f649459ba56a51627d46603e2d5d81d837ac9c4dc393f5cc20263d0ac1b91)(@xterm/xterm@6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b)
'@xterm/headless@6.1.0-beta.287': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=8ed744c9ce7594ef00062494dc54c59abaf4c71bb892a268b4e109890b363c68)': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=76a05b8d4afb40b6f39a428e6192f9dccc781b376928459331eb04a194f6842b)': {}
abbrev@4.0.0: {}
@@ -183,6 +183,9 @@ describe('mid-line composition renders the covered row tail after the preedit',
expect(remainder!.textContent).toBe('하세요')
// Start-anchored so the preedit stays put and the pushed tail clips at the right edge.
expect(rig.compositionView.style.direction).toBe('ltr')
expect(rig.compositionView.style.display).toBe('')
expect(rig.compositionView.style.justifyContent).toBe('')
expect(rig.compositionView.style.textAlign).toBe('left')
})
it('keeps the tail current as the preedit grows through the composition', async () => {
@@ -231,7 +234,9 @@ describe('mid-line composition renders the covered row tail after the preedit',
expect(Array.from(rig.compositionView.children)).toEqual([preedit, caret])
expect(remainder).toBeNull()
expect(stripMarks(rig.compositionView.textContent)).toBe('가')
expect(rig.compositionView.style.textAlign).toBe('right')
expect(rig.compositionView.style.display).toBe('flex')
expect(rig.compositionView.style.justifyContent).toBe('flex-end')
expect(rig.compositionView.style.textAlign).toBe('')
})
it('keeps the themed insertion caret visible inside the final cell', async () => {
@@ -244,7 +249,10 @@ describe('mid-line composition renders the covered row tail after the preedit',
expect(rig.terminal.buffer.active.cursorX).toBe(79)
expect(Array.from(rig.compositionView.children)).toEqual([preedit, caret])
expect(rig.compositionView.style.maxWidth).toBe(`${CELL_WIDTH_PX}px`)
expect(rig.compositionView.style.textAlign).toBe('right')
expect(rig.compositionView.style.display).toBe('flex')
expect(rig.compositionView.style.justifyContent).toBe('flex-end')
expect(preedit!.style.flexShrink).toBe('0')
expect(caret!.style.flexShrink).toBe('0')
expect(caret!.style.width).toBe('2px')
expect(caret!.style.marginLeft).toBe('-2px')
expect([THEME.cursor, 'rgb(68, 85, 102)']).toContain(caret!.style.backgroundColor)
@@ -276,6 +284,8 @@ describe('mid-line composition renders the covered row tail after the preedit',
)
expect(rig.compositionView.classList.contains('active')).toBe(false)
expect(rig.compositionView.children).toHaveLength(0)
expect(rig.compositionView.style.display).toBe('')
expect(rig.compositionView.style.justifyContent).toBe('')
rig.composeUpdate('글')
expect(rig.compositionView.classList.contains('active')).toBe(true)
@@ -283,6 +293,8 @@ describe('mid-line composition renders the covered row tail after the preedit',
rig.composeUpdate('')
expect(rig.compositionView.classList.contains('active')).toBe(false)
expect(rig.compositionView.children).toHaveLength(0)
expect(rig.compositionView.style.display).toBe('')
expect(rig.compositionView.style.justifyContent).toBe('')
})
it('masks a dim Codex placeholder instead of repeating it after the preedit', async () => {
@@ -137,8 +137,11 @@ describe('#12729 — the block over a trailing Korean syllable is the preedit ov
expect(rig.terminal.buffer.active.cursorX).toBe(6)
expect(rig.compositionView.classList.contains('active')).toBe(true)
expect(stripMarks(rig.compositionView.textContent)).toBe('라')
// Nothing follows the cursor, so the overlay carries the preedit alone.
expect(rig.compositionView.children).toHaveLength(0)
// Nothing follows the cursor, so the overlay needs only the preedit and its caret.
const preedit = rig.compositionView.querySelector('.xterm-composition-preedit')
const caret = rig.compositionView.querySelector('.xterm-composition-caret')
expect(Array.from(rig.compositionView.children)).toEqual([preedit, caret])
expect(rig.compositionView.querySelector('.xterm-composition-remainder')).toBeNull()
})
it('anchors the overlay to the cursor cell so the preedit continues the run', async () => {
@@ -24,6 +24,7 @@ export type MidlinePreeditOcclusionSample = {
caretRect: { left: number; right: number; width: number; height: number } | null
overlayActive: boolean
cursorColumn: number
terminalColumns: number
/** Columns covered by the overlay, as `[first, last]`; null when it covers none. */
coveredColumns: [number, number] | null
cellWidth: number
@@ -107,6 +108,7 @@ function readMidlinePreeditOcclusion(): MidlinePreeditOcclusionSample {
: null,
overlayActive: view.classList.contains('active'),
cursorColumn,
terminalColumns: terminal.cols,
coveredColumns: first === null ? null : [first, last],
cellWidth,
overlayRect: {
@@ -60,13 +60,16 @@ test.describe('Terminal end-of-row Korean preedit cell span', () => {
const arena = await openTerminalImePaneArena(orcaPage)
let completed = false
try {
await writeToActiveTerminal(orcaPage, '\x1b[2J\x1b[H\x1b[80G')
// CHA clamps past-the-edge columns, keeping this valid when the runner resizes the grid.
await writeToActiveTerminal(orcaPage, '\x1b[2J\x1b[H\x1b[999G')
await setImeComposition(arena.session, '가')
const sample = await sampleOpenComposition(orcaPage)
const caret = sample.caretRect
const screenRight = sample.screenRect.left + sample.screenRect.width
expect(sample.cursorColumn, 'the cursor is not in the final column').toBe(79)
expect(sample.cursorColumn, 'the cursor is not in the final column').toBe(
sample.terminalColumns - 1
)
expect(caret, 'the active preedit has no caret element').not.toBeNull()
expect(caret!.width, 'the preedit caret has zero width').toBeGreaterThan(0)
expect(caret!.height, 'the preedit caret has zero height').toBeGreaterThan(0)