fix(terminal): render the row tail the IME preedit overlay covers (#15014)

* fix(terminal): render the covered row tail inside the IME preedit overlay

Closes #12545.

Composing mid-line hid the character at the cursor for the whole composition.
The preedit overlay is an opaque box anchored to the cursor cell, and nothing
reaches the pty while composing, so those cells still held their characters —
the box simply covered them.

`CompositionHelper` now draws the rest of the row after the preedit inside the
view, so the composition reads as inserted text pushing the tail right. Four
details come with it:

- The view is start-anchored while it carries a tail, so the preedit stays put
  and the pushed tail clips at the right edge; alone, `rtl` still keeps a long
  preedit's end in view.
- It is themed from `options.theme` instead of the stock `#000`/`#FFF`, with any
  alpha dropped — the view masks the cells it draws over, so a see-through
  background would re-expose the very characters the tail stands in for.
- The helper textarea syncs to the preedit's own bounds, so IME candidate
  dialogs anchor to the composing text rather than past the rendered tail.
- A TUI can repaint the row under an open composition, so
  `updateCompositionElements` — which already runs on every render — re-reads
  the remainder and re-renders on change. A string compare adds no layout read.

The tail is read with an explicit end column: the cacheable form of
`translateToString` arms the line string cache's self-renewing idle-clear timer,
and the composition path must own no timers.

Geometry is not the cause. Two mature reference terminal implementations compose
marked text into the grid rather than into a floating box, and both still blank
the cells under it — one of them literally substitutes the marked characters
into the row's character array before rasterizing. Moving off the overlay would
not have fixed this report; rendering the covered tail is what does.

The e2e arm asserts the invariant an opaque overlay owes the grid: it must
render every committed cell its bounding rect covers. That is measured from the
real rect against the real cell grid, so it fails on the unfixed build with
`covers "하" / renders "가"`.

Known limitation: the rendered tail is plain-styled while composing (theme
foreground on theme background, no per-cell colors); colors return on commit.
This is inherent to the overlay, and drawing the preedit into the cell renderer
instead would be a far larger change.

Co-authored-by: rayim <rayim@fxy.global>

* test(e2e): assert the occlusion invariant, not the runner's cell width

CI covered four columns where this machine covers two — 34.4px over an 8.43px
grid against 12.3px over an 8px grid — so pinning the covered text verbatim
pinned the font metrics rather than the behaviour. Assert instead that every
committed cell the overlay covers appears in what it draws, which is the actual
invariant and holds at any cell width.

Still fails against main: covers "하" / renders "가".

* fix(terminal): keep the rendered tail's spacing on the grid

The composition view is white-space: nowrap, which collapses runs of spaces
exactly like normal — it only suppresses wrapping. So a committed tail carrying
padding drew its trailing glyph cells left of where the grid has them: measured
in Chromium with xterm's own rule, twenty spaces plus a border rendered two
cells wide instead of twenty-one.

The visible case is Orca's most common IME context — composing inside an agent
TUI input box, where the row is a prompt, padding, then a real border glyph the
trim cannot drop. A stray border appeared a cell after the preedit while the
real one stayed put.

xterm sets white-space: pre on its grid rows for this reason; the view was only
nowrap-safe while it held preedit text alone.

The existing fixtures are all space-free, and the e2e invariant is that the
overlay renders everything it covers — collapsing makes it cover less, so both
stayed green. Pinned with a padded-row fixture.

---------

Co-authored-by: rayim <rayim@fxy.global>
This commit is contained in:
Neil
2026-08-17 00:18:11 -07:00
committed by GitHub
co-authored by rayim
parent 8e9b5c908c
commit 453237cc57
5 changed files with 695 additions and 53 deletions
File diff suppressed because one or more lines are too long
+24 -24
View File
@@ -18,7 +18,7 @@ patchedDependencies:
hash: 6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258
path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch
'@xterm/xterm@6.1.0-beta.287':
hash: 08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089
hash: 0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067
path: config/patches/@xterm__xterm@6.1.0-beta.287.patch
node-pty@1.1.0:
hash: 8fc49f17011b6611a5b8c00e83a6f12e14e75aada2b0ef26dc5393f8376d20e8
@@ -45,7 +45,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=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
version: 0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
'@xterm/headless':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287
@@ -208,25 +208,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=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
version: 0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
'@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=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
version: 0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
'@xterm/addon-search':
specifier: 0.17.0-beta.287
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
version: 0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.287
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
version: 0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
'@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=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
version: 0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
'@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=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))
version: 0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))
'@xterm/xterm':
specifier: 6.1.0-beta.287
version: 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
version: 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -9591,39 +9591,39 @@ snapshots:
'@xmldom/xmldom@0.8.13': {}
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
'@xterm/addon-fit@0.12.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
'@xterm/addon-ligatures@0.11.0-beta.287(patch_hash=47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
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=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
'@xterm/addon-search@0.17.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
'@xterm/addon-serialize@0.15.0-beta.287(patch_hash=af3b156143ed2ee9903b753145b63dd4a2ee72cadd7ef333ab0f4d5d3ebfc32c)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
'@xterm/addon-unicode11@0.10.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
'@xterm/addon-web-links@0.13.0-beta.287(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089))':
'@xterm/addon-webgl@0.20.0-beta.286(patch_hash=6da7d7770b6427246f2a0d057d97da418040e498068b41d0c2d3c6b20bf49258)(@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067))':
dependencies:
'@xterm/xterm': 6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)
'@xterm/xterm': 6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)
'@xterm/headless@6.1.0-beta.287': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=08595fefcbccc27a3e5e0805beca66b9c31d44dbfadb93a42e48d24f7d738089)': {}
'@xterm/xterm@6.1.0-beta.287(patch_hash=0793ce045b9ec6e19c5802658e0b28aec89517e6986c496515c4d8def083e067)': {}
abbrev@4.0.0: {}
@@ -0,0 +1,263 @@
// @vitest-environment happy-dom
/**
* A mid-line composition must not visually swallow the character after the cursor.
*
* The preedit overlay (`.composition-view`) is an opaque box anchored to the cursor cell. Nothing
* reaches the PTY while composing, so the covered cells still hold their characters — the box just
* hides them for the whole composition (#12545). Composing `가` with the cursor before `하` in
* `안녕하세요` blanks `하` until the syllable commits.
*
* The fix renders the rest of the row's committed text after the preedit inside the overlay, so the
* composition reads as inserted text pushing the tail right. The overlay is also themed from
* `options.theme` instead of the stock `#000`/`#FFF`, with any alpha dropped — a see-through mask
* would re-expose the very cells the rendered tail stands in for.
*
* happy-dom performs no layout, so the cell size is supplied and geometry is not asserted; the
* on-screen geometry arm lives in `tests/e2e/terminal-korean-midline-preedit-occlusion.spec.ts`.
*/
import { Terminal } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const CELL_WIDTH_PX = 8
const CELL_HEIGHT_PX = 16
const THEME = { background: '#112233', foreground: '#aabbcc' }
const openTerminals: Terminal[] = []
function nextEventLoop(): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, 0))
}
type Rig = {
compositionView: HTMLElement
compose: (preedit: string) => void
composeStart: () => void
composeUpdate: (preedit: string) => void
terminal: Terminal
write: (data: string) => Promise<void>
writeAwaitingRender: (data: string) => Promise<void>
}
function openTerminal(theme: { background: string; foreground: string } = THEME): Rig {
const container = document.createElement('div')
document.body.appendChild(container)
const terminal = new Terminal({ cols: 80, rows: 24, theme })
terminal.open(container)
const textarea = terminal.textarea
const compositionView = container.querySelector<HTMLElement>('.composition-view')
if (!textarea || !compositionView) {
throw new Error('xterm did not create the helper textarea and composition view')
}
openTerminals.push(terminal)
const cell = (
terminal as unknown as {
_core: {
_renderService: { dimensions: { css: { cell: { height: number; width: number } } } }
}
}
)._core._renderService.dimensions.css.cell
cell.width = CELL_WIDTH_PX
cell.height = CELL_HEIGHT_PX
const write = (data: string): Promise<void> =>
new Promise((resolve) => terminal.write(data, resolve))
// Awaits the repaint the write triggers, so the tail refresh runs through the production
// terminal.onRender path rather than a test shortcut. The listener arms only after the write's
// parse callback, because a repaint scheduled by an earlier write can fire first and still show
// the old row.
const writeAwaitingRender = async (data: string): Promise<void> => {
await write(data)
await new Promise<void>((resolve) => {
const rendered = terminal.onRender(() => {
rendered.dispose()
resolve()
})
})
}
const composeStart = (): void => {
const start = new CompositionEvent('compositionstart', { bubbles: true })
Object.defineProperty(start, 'data', { value: '' })
textarea.dispatchEvent(start)
}
const composeUpdate = (preedit: string): void => {
const update = new CompositionEvent('compositionupdate', { bubbles: true })
Object.defineProperty(update, 'data', { value: preedit })
textarea.value = preedit
textarea.dispatchEvent(update)
}
const compose = (preedit: string): void => {
composeStart()
composeUpdate(preedit)
}
return {
compositionView,
compose,
composeStart,
composeUpdate,
terminal,
write,
writeAwaitingRender
}
}
function stripMarks(text: string | null): string {
return (text ?? '').replaceAll('', '')
}
describe('mid-line composition renders the covered row tail after the preedit', () => {
beforeEach(() => {
// happy-dom has no 2d context, which the DOM renderer's WidthCache requires.
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
measureText: () => ({ width: 10 })
} as unknown as CanvasRenderingContext2D)
})
afterEach(async () => {
// updateCompositionElements re-arms on a timer; let the pending one run before dispose.
await nextEventLoop()
await nextEventLoop()
while (openTerminals.length > 0) {
openTerminals.pop()?.dispose()
}
vi.restoreAllMocks()
document.body.replaceChildren()
})
it('shows the tail from the cursor when composing before committed text (#12545 repro)', async () => {
const rig = openTerminal()
// 안녕하세요 then CUB 6: each Hangul syllable is two cells, so the cursor lands on 하 (x=4).
await rig.write('안녕하세요\x1b[6D')
rig.compose('가')
const spans = Array.from(rig.compositionView.children) as HTMLElement[]
expect(spans).toHaveLength(2)
expect(stripMarks(spans[0]!.textContent)).toBe('가')
expect(spans[0]!.style.textDecoration).toBe('underline')
expect(spans[1]!.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')
})
it('keeps the tail current as the preedit grows through the composition', async () => {
const rig = openTerminal()
await rig.write('안녕하세요\x1b[6D')
// One composition stays active while the preedit grows through updates,
// matching how an IME actually streams ㄱ → 가 → 강.
rig.composeStart()
rig.composeUpdate('ㄱ')
rig.composeUpdate('가')
rig.composeUpdate('강')
const spans = Array.from(rig.compositionView.children) as HTMLElement[]
expect(spans).toHaveLength(2)
expect(stripMarks(spans[0]!.textContent)).toBe('강')
expect(spans[1]!.textContent).toBe('하세요')
})
// The view is `white-space: nowrap`, which collapses runs of spaces exactly like `normal`.
// Without `pre` on the tail, an agent TUI's padded input row — `> text …spaces… |` — renders its
// right border a cell after the preedit while the real border stays put. xterm sets `pre` on its
// grid rows for the same reason.
it('preserves the tail spacing of a padded row so its trailing glyph stays on the grid', async () => {
const rig = openTerminal()
// A TUI input row: text, padding, then a real border glyph the trim cannot drop.
await rig.write('> hi |\x1b[13D')
rig.compose('가')
const spans = Array.from(rig.compositionView.children) as HTMLElement[]
expect(spans).toHaveLength(2)
expect(spans[1]!.textContent, 'the tail must keep every padding cell').toBe('hi |')
expect(
spans[1]!.style.whiteSpace,
'nowrap collapses the padding, so the border lands left of its grid column'
).toBe('pre')
})
it('keeps the plain single-text overlay when composing at the end of the row', async () => {
const rig = openTerminal()
await rig.write('안녕하세요')
rig.compose('가')
expect(rig.compositionView.children).toHaveLength(0)
expect(stripMarks(rig.compositionView.textContent)).toBe('가')
// The rtl trick still keeps a long preedit's end in view when nothing follows the cursor.
expect(rig.compositionView.style.direction).toBe('rtl')
})
it('themes the overlay from options.theme instead of the stock #000/#FFF', async () => {
const rig = openTerminal()
await rig.write('안녕하세요\x1b[6D')
rig.compose('가')
const { background, color } = rig.compositionView.style
expect([THEME.background, 'rgb(17, 34, 51)']).toContain(background)
expect([THEME.foreground, 'rgb(170, 187, 204)']).toContain(color)
})
it('drops the alpha of a translucent theme background so the mask stays opaque', async () => {
// terminalBackgroundOpacity composes theme.background down to rgba(); carried through as-is it
// would let the covered cells show straight through the tail this renders.
const rig = openTerminal({ background: 'rgba(17, 34, 51, 0.6)', foreground: '#aabbcc' })
await rig.write('안녕하세요\x1b[6D')
rig.compose('가')
expect(rig.compositionView.style.background).toBe('rgb(17, 34, 51)')
})
it('refreshes the tail when the row repaints under an open composition', async () => {
const rig = openTerminal()
await rig.write('안녕하세요\x1b[6D')
rig.compose('가')
// A TUI repaint: erase from the cursor, draw a different tail, put the cursor back.
await rig.writeAwaitingRender('\x1b[K체크\x1b[4D')
const spans = Array.from(rig.compositionView.children) as HTMLElement[]
expect(spans).toHaveLength(2)
expect(stripMarks(spans[0]!.textContent)).toBe('가')
expect(spans[1]!.textContent).toBe('체크')
})
it('starts rendering a tail when text lands after an end-of-row composition began', async () => {
const rig = openTerminal()
await rig.write('안녕')
rig.compose('가')
expect(rig.compositionView.children).toHaveLength(0)
// Streamed output arrives to the right of the cursor while the composition is open.
await rig.writeAwaitingRender('하세요\x1b[6D')
const spans = Array.from(rig.compositionView.children) as HTMLElement[]
expect(spans).toHaveLength(2)
expect(stripMarks(spans[0]!.textContent)).toBe('가')
expect(spans[1]!.textContent).toBe('하세요')
})
it('leaves no tail behind for the next composition after one ends', async () => {
const rig = openTerminal()
await rig.write('안녕하세요\x1b[6D')
rig.compose('가')
const end = new CompositionEvent('compositionend', { bubbles: true })
Object.defineProperty(end, 'data', { value: '가' })
rig.terminal.textarea!.dispatchEvent(end)
await nextEventLoop()
await nextEventLoop()
expect(rig.compositionView.classList.contains('active')).toBe(false)
expect(rig.compositionView.children).toHaveLength(0)
expect(rig.compositionView.textContent).toBe('')
})
})
@@ -0,0 +1,123 @@
import type { Page } from '@stablyai/playwright-test'
/**
* Measures what an active preedit overlay hides, against what it renders.
*
* The overlay has an opaque background and is absolutely positioned over the grid, so every cell
* its bounding rect covers is unreadable for the duration of the composition. That gives one
* invariant worth asserting on screen: **the overlay must render everything it covers**. Composing
* mid-line broke it — the box covered the character at the cursor and rendered only the preedit.
*
* This has to be measured from the real rect against the real cell grid. The class, the
* `textContent` and `display` are all identical either way, and a DOM emulator reports every rect
* as zero, so a unit-level arm cannot see the difference.
*/
export type MidlinePreeditOcclusionSample = {
/** Row text from the cursor rightwards — the committed characters a mid-line preedit sits over. */
rowTailFromCursor: string
/** Characters in the grid columns the overlay's bounding rect covers. */
hiddenByOverlay: string
/** Overlay text as rendered, LRM marks stripped. */
overlayText: string
overlayActive: boolean
cursorColumn: number
/** Columns covered by the overlay, as `[first, last]`; null when it covers none. */
coveredColumns: [number, number] | null
cellWidth: number
/** Kept in the sample so a geometry mismatch prints the rects that produced it. */
overlayRect: { left: number; right: number; width: number }
screenRect: { left: number; width: number }
}
function readMidlinePreeditOcclusion(): MidlinePreeditOcclusionSample {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
throw new Error('No active terminal pane to sample')
}
const terminal = pane.terminal
const screen = pane.container.querySelector<HTMLElement>('.xterm-screen')
const view = pane.container.querySelector<HTMLElement>('.composition-view')
if (!screen || !view) {
throw new Error('Active terminal has no screen element or composition view')
}
const buffer = terminal.buffer.active
const line = buffer.getLine(buffer.baseY + buffer.cursorY)
const cursorColumn = buffer.cursorX
const rowTailFromCursor = (line?.translateToString(true, cursorColumn) ?? '').trimEnd()
const screenRect = screen.getBoundingClientRect()
const overlayRect = view.getBoundingClientRect()
const cellWidth = terminal.cols > 0 ? screenRect.width / terminal.cols : 0
// A column counts as hidden when the overlay covers most of its cell, which keeps the sample
// stable against the sub-pixel width a proportional text node lands on.
let first: number | null = null
let last = -1
let hiddenByOverlay = ''
if (cellWidth > 0 && overlayRect.width > 0) {
for (let column = 0; column < terminal.cols; column++) {
const cellLeft = screenRect.left + column * cellWidth
const overlap =
Math.min(cellLeft + cellWidth, overlayRect.right) - Math.max(cellLeft, overlayRect.left)
if (overlap <= cellWidth / 2) {
continue
}
first ??= column
last = column
hiddenByOverlay += line?.getCell(column)?.getChars() ?? ''
}
}
return {
rowTailFromCursor,
hiddenByOverlay: hiddenByOverlay.trimEnd(),
// The overlay wraps its text in LRM marks; strip them so assertions read as the user sees it.
overlayText: (view.textContent ?? '').replaceAll('', ''),
overlayActive: view.classList.contains('active'),
cursorColumn,
coveredColumns: first === null ? null : [first, last],
cellWidth,
overlayRect: {
left: overlayRect.left,
right: overlayRect.right,
width: overlayRect.width
},
screenRect: { left: screenRect.left, width: screenRect.width }
}
}
export async function sampleMidlinePreeditOcclusion(
page: Page
): Promise<MidlinePreeditOcclusionSample> {
return page.evaluate(readMidlinePreeditOcclusion)
}
/** Writes straight to the emulator: the defect is in what the overlay draws, not in the pty. */
export async function writeToActiveTerminal(page: Page, data: string): Promise<void> {
await page.evaluate(async (payload: string) => {
const state = window.__store?.getState()
const worktreeId = state?.activeWorktreeId
const tabId =
state?.activeTabType === 'terminal'
? state.activeTabId
: worktreeId
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
: null
const manager = tabId ? window.__paneManagers?.get(tabId) : null
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!pane) {
throw new Error('No active terminal pane to write to')
}
await new Promise<void>((resolve) => pane.terminal.write(payload, resolve))
}, data)
}
@@ -0,0 +1,130 @@
/**
* Headless end-to-end coverage for #12545: a mid-line Korean composition must not swallow the
* character after the cursor.
*
* The preedit overlay is an opaque, absolutely-positioned box over the grid, so every cell its
* bounding rect covers is unreadable while the composition is open. That gives the invariant
* asserted here — **the overlay renders every committed cell it covers** — and it is the one the
* bug broke: the box covered `하` and drew only `가`. It is measured from the overlay's real rect
* against the real cell grid, which is what makes it an on-screen assertion rather than a
* `textContent` one; a DOM emulator reports every rect as zero and cannot see the difference.
*
* Reference terminal implementations do not settle this by geometry. Two mature ones compose marked
* text into the grid rather than into a floating box, and both still blank the cells under it — so
* moving off the overlay would not fix the report. Rendering the covered tail is what does.
*
* Composition is driven through CDP `Input.imeSetComposition`, so this runs in the normal headless
* project with no native input source. The row is written straight to the emulator because the
* defect is in what the overlay draws over the buffer, not in anything reaching the pty.
*/
import type { Page } from '@stablyai/playwright-test'
import { expect, test } from './helpers/orca-app'
import { closeTerminalImePaneArena, openTerminalImePaneArena } from './terminal-ime-pane-arena'
import { setImeComposition } from './terminal-ime-cdp-composition'
import {
sampleMidlinePreeditOcclusion,
writeToActiveTerminal,
type MidlinePreeditOcclusionSample
} from './terminal-ime-midline-occlusion-probe'
import { samplePreeditOverlay } from './terminal-ime-preedit-overlay-probe'
/**
* Waits for the preedit to reach the overlay at a non-zero size, then samples once.
*
* Sampled once rather than polled: the pane runs a live shell that can repaint the row out from
* under the composition, so only the frame the composition opened on carries the state the
* assertion is about. The readiness gate is deliberately blind to the tail, so it holds identically
* with and without the fix and the occlusion assertion is what discriminates.
*/
async function sampleOpenComposition(page: Page): Promise<MidlinePreeditOcclusionSample> {
await expect
.poll(
async () => {
const overlay = await samplePreeditOverlay(page)
return overlay.active && overlay.rect.width > 0 && overlay.text.startsWith('가')
},
{ message: 'the preedit never reached the overlay at a non-zero size' }
)
.toBe(true)
return sampleMidlinePreeditOcclusion(page)
}
/** Reads as one string so a failure prints what the overlay covered next to what it drew. */
function describeOcclusion(sample: MidlinePreeditOcclusionSample): string {
return `covers ${JSON.stringify(sample.hiddenByOverlay)} / renders ${JSON.stringify(sample.overlayText)}`
}
/**
* The invariant, stated so it survives a different cell width: every committed cell the overlay
* covers must appear in what it draws. How MANY cells it covers is a function of the runner's
* font metrics — 34.4px over an 8.43px grid spans four columns where an 8px grid spans two — so
* asserting the covered text verbatim pins the machine, not the behaviour.
*/
function rendersEverythingItCovers(sample: MidlinePreeditOcclusionSample): boolean {
return sample.overlayText.includes(sample.hiddenByOverlay)
}
test.describe('Terminal mid-line Korean preedit occlusion', () => {
test('renders the row tail it covers, so the character after the cursor stays readable', async ({
orcaPage
}, testInfo) => {
const arena = await openTerminalImePaneArena(orcaPage)
let completed = false
try {
// The issue's repro: 안녕하세요, then CUB 6. Each Hangul syllable is two cells, so the
// cursor lands on 하.
await writeToActiveTerminal(orcaPage, '\x1b[2J\x1b[H안녕하세요\x1b[6D')
await setImeComposition(arena.session, '가')
const sample = await sampleOpenComposition(orcaPage)
expect(sample.cursorColumn, 'the cursor is not sitting on 하').toBe(4)
expect(sample.rowTailFromCursor, 'the row under the overlay is not the repro').toBe('하세요')
expect(sample.coveredColumns, 'the overlay covers no grid columns to assert about').not.toBe(
null
)
// The load-bearing assertion, and the only one that reads the overlay's geometry against the
// grid: an opaque box that covers committed cells has to reproduce them, or the user loses
// text for the length of the composition.
expect(
sample.hiddenByOverlay.length,
`the overlay covers no committed text, so there is nothing to assert — ${describeOcclusion(sample)}`
).toBeGreaterThan(0)
expect(
rendersEverythingItCovers(sample),
`the preedit overlay must render every committed cell it covers — ${describeOcclusion(sample)} ${JSON.stringify(sample)}`
).toBe(true)
completed = true
} finally {
await closeTerminalImePaneArena(
arena,
testInfo,
'korean-midline-preedit-occlusion',
!completed
)
}
})
test('leaves the end-of-row composition untouched, with nothing covered to render', async ({
orcaPage
}, testInfo) => {
// Passes before and after the fix by design: the guard is against over-correcting into
// rendering a tail where the row has none.
const arena = await openTerminalImePaneArena(orcaPage)
let completed = false
try {
await writeToActiveTerminal(orcaPage, '\x1b[2J\x1b[H안녕하세요')
await setImeComposition(arena.session, '가')
const sample = await sampleOpenComposition(orcaPage)
expect(sample.rowTailFromCursor, 'text still sits after the cursor').toBe('')
expect(
describeOcclusion(sample),
`an end-of-row composition covers no committed text — ${JSON.stringify(sample)}`
).toBe('covers "" / renders "가"')
completed = true
} finally {
await closeTerminalImePaneArena(arena, testInfo, 'korean-endofrow-preedit', !completed)
}
})
})