test(terminal): pin that the CJK block is the preedit overlay, not the cursor

A report described the cursor sitting on a wide character's first cell
and hiding its right half, with cursor style and opacity settings
ignored. Neither defect reproduces.

Replaying the reporter's own captured byte stream leaves the cursor at
column 11, exactly where the application asked, with correct wide and
continuation cells. A block cursor also cannot hide half a glyph: it
inverts the cell and the syllable renders inside the cursor span.

The black block is the IME preedit overlay. macOS 2-set Korean keeps the
trailing syllable composing until a terminator, so it sits in an opaque
absolutely-positioned box over the grid rather than in the buffer. That
box took stock upstream colours, black on white. It explains what no
cursor theory can: the block appears at the composing cursor cell, no
cursor option reaches it, it is identical with GPU acceleration off
since it is a DOM node above both renderers, Latin never triggers it
because Latin opens no composition, and Enter clears it because Enter
commits the composition.

Already fixed by the overlay theming in #15014, which landed a day after
the reported release, so the fix ships in the next one.

Tests only, no production change. Two pin the negative results so the
cursor explanation cannot be re-derived, and one pins the actual
mechanism at end of row, beside the existing mid-line arm.

Separately confirmed and not fixed here: the WebGL renderer drops the
cursor colour's alpha, so terminal cursor opacity genuinely does nothing
for a block cursor, which is the default style on the default renderer.
That is in the webgl addon rather than in xterm or in our code.

Refs #12729
This commit is contained in:
Neil
2026-08-18 02:25:07 -07:00
parent 442b46f020
commit ef59c1931a
3 changed files with 399 additions and 0 deletions
@@ -0,0 +1,129 @@
// @vitest-environment happy-dom
/**
* Issue #12729 — "the cursor lands on the wide character's first cell and clips its right half".
*
* The report pins two claims about the cursor. Both are pinned here against the vendored xterm,
* because neither reproduces and a regression in either would change what the answer to that
* issue is:
*
* 1. the reporter's captured pty stream must leave the cursor on the column the application
* asked for (`CSI 25;11H`), not on the last syllable's starting cell; and
* 2. a block cursor sitting on a full-width glyph must carry the whole syllable, so nothing
* about the cursor can hide half a character.
*
* The occluding box in that report is the IME preedit overlay, not the cursor — see
* `terminal-ime-xterm-trailing-preedit-occlusion.test.ts`.
*/
import { Terminal } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Escapes made visible in the issue: hide cursor, home, CUF 2, CUD 24, the text, then two CUPs.
const REPORTED_PTY_STREAM = '\x1b[?25l\x1b[H\x1b[2C\x1b[24B가나다라\x1b[30;1H\x1b[25;11H\x1b[?25h'
const openTerminals: Terminal[] = []
function openTerminal(): Terminal {
const container = document.createElement('div')
document.body.appendChild(container)
const terminal = new Terminal({ cols: 80, rows: 30 })
terminal.open(container)
openTerminals.push(terminal)
return terminal
}
function write(terminal: Terminal, data: string): Promise<void> {
return new Promise((resolve) => terminal.write(data, resolve))
}
/** Awaits the repaint the write triggers, so the row spans under assertion are the rendered ones. */
async function writeAwaitingRender(terminal: Terminal, data: string): Promise<void> {
await write(terminal, data)
await new Promise<void>((resolve) => {
const rendered = terminal.onRender(() => {
rendered.dispose()
resolve()
})
})
}
function describeCells(terminal: Terminal, count: number): string[] {
const buffer = terminal.buffer.active
const line = buffer.getLine(buffer.cursorY)
if (!line) {
throw new Error('cursor row is missing')
}
const cells: string[] = []
for (let x = 0; x < count; x++) {
const cell = line.getCell(x)
cells.push(`${cell?.getChars() ?? ''}:${cell?.getWidth() ?? -1}`)
}
return cells
}
describe('#12729 — the cursor is not displaced by full-width text', () => {
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(() => {
while (openTerminals.length > 0) {
openTerminals.pop()?.dispose()
}
vi.restoreAllMocks()
document.body.replaceChildren()
})
it('leaves the reported pty stream with the cursor on the requested column', async () => {
const terminal = openTerminal()
await write(terminal, REPORTED_PTY_STREAM)
const buffer = terminal.buffer.active
// `CSI 25;11H` is 1-based; the last syllable's starting cell would be 8.
expect(buffer.cursorX).toBe(10)
expect(buffer.cursorY).toBe(24)
// Each syllable owns its cell plus a zero-width continuation, so the run ends at cell 9.
// Cells 0-1 were never written: the row was reached with CUD, so they stay null.
expect(describeCells(terminal, 11)).toEqual([
':1',
':1',
'가:2',
':0',
'나:2',
':0',
'다:2',
':0',
'라:2',
':0',
':1'
])
})
it('paints the cursor after the run, not on the last syllable', async () => {
const terminal = openTerminal()
terminal.focus()
await writeAwaitingRender(terminal, '> 가나다라')
const cursorSpan = document.querySelector('.xterm-rows .xterm-cursor')
expect(cursorSpan?.previousElementSibling?.textContent).toBe('> 가나다라')
expect(cursorSpan?.textContent).toBe(' ')
})
it('carries the whole syllable when the cursor does sit on a full-width glyph', async () => {
const terminal = openTerminal()
terminal.focus()
// CHA 9: back onto 라's starting cell, the placement the report describes.
await writeAwaitingRender(terminal, '> 가나다라\x1b[9G')
expect(terminal.buffer.active.cursorX).toBe(8)
const cursorSpan = document.querySelector('.xterm-rows .xterm-cursor')
// The whole syllable is inside the cursor span, so a block cursor cannot clip its right half.
expect(cursorSpan?.textContent).toBe('라')
expect(cursorSpan?.classList.contains('xterm-cursor-block')).toBe(true)
})
})
@@ -0,0 +1,92 @@
// @vitest-environment happy-dom
/**
* Issue #12729's second claim — "`terminalCursorStyle` / `terminalCursorOpacity` are ignored".
*
* Neither reproduces as a plumbing defect: an explicit `bar` survives the settings round trip and
* the opacity composes into the theme's cursor colour. What the report was looking at is the IME
* preedit overlay, which no cursor option reaches by construction
* (`terminal-ime-xterm-trailing-preedit-occlusion.test.ts`).
*
* The one thing that does override the preference is a `DECSCUSR` from the running program, which
* every terminal honours and which prompt frameworks emit routinely. That precedence is pinned
* here because it is the answer to "my setting does nothing" whenever the overlay is not involved.
*/
import { Terminal } from '@xterm/xterm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { normalizeTerminalCursorStyleDefault } from '../../../../shared/terminal-cursor-style-settings'
import { composeActiveTerminalTheme } from './terminal-appearance'
function decPrivateCursorStyle(terminal: Terminal): string | undefined {
return (
terminal as unknown as {
_core: { coreService: { decPrivateModes: { cursorStyle?: string } } }
}
)._core.coreService.decPrivateModes.cursorStyle
}
describe('#12729 — cursor style and opacity reach the terminal', () => {
it('keeps an explicitly chosen bar across the settings write and the next load', () => {
// The write path stamps the migration flag alongside the user's choice.
const written = normalizeTerminalCursorStyleDefault(
{ terminalCursorStyle: 'bar' },
{ preserveExplicitValue: true }
)
expect(written).toEqual({
terminalCursorStyle: 'bar',
terminalCursorStyleDefaultedToBlock: true
})
// The load path re-runs the migration over what was persisted and must not re-default it.
expect(normalizeTerminalCursorStyleDefault(written).terminalCursorStyle).toBe('bar')
})
it('composes terminalCursorOpacity into the theme cursor colour', () => {
const theme = composeActiveTerminalTheme(
{ background: '#112233', foreground: '#aabbcc', cursor: '#ffffff' },
{ terminalCursorOpacity: 0.1 }
)
expect(theme?.cursor).toBe('rgba(255, 255, 255, 0.1)')
})
})
describe('#12729 — DECSCUSR from the program outranks the preference', () => {
const openTerminals: Terminal[] = []
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(() => {
while (openTerminals.length > 0) {
openTerminals.pop()?.dispose()
}
vi.restoreAllMocks()
document.body.replaceChildren()
})
it('pins the shape a prompt asked for until CSI 0 SP q hands it back', async () => {
const container = document.createElement('div')
document.body.appendChild(container)
const terminal = new Terminal({ cols: 40, rows: 6, cursorStyle: 'bar' })
terminal.open(container)
openTerminals.push(terminal)
expect(decPrivateCursorStyle(terminal)).toBeUndefined()
// `CSI 2 SP q` — steady block, what a prompt framework emits on every redraw.
await new Promise<void>((resolve) => terminal.write('\x1b[2 q', resolve))
expect(decPrivateCursorStyle(terminal)).toBe('block')
// Changing the preference while that is set cannot win it back; only the reset does.
terminal.options.cursorStyle = 'underline'
expect(decPrivateCursorStyle(terminal)).toBe('block')
// Orca sends this reset on replay and on the agent-idle path (RESET_TERMINAL_CURSOR_STYLE).
await new Promise<void>((resolve) => terminal.write('\x1b[0 q', resolve))
expect(decPrivateCursorStyle(terminal)).toBeUndefined()
})
})
@@ -0,0 +1,178 @@
// @vitest-environment happy-dom
/**
* Issue #12729 — typing `가나다라` leaves the last syllable inside an opaque block.
*
* The report reads the block as a mis-placed cursor, but no cursor can produce it: the cursor sits
* on the column the application asked for and a block cursor carries the whole wide glyph
* (`terminal-cjk-cursor-cell-placement.test.ts`). What sits there is the preedit overlay.
*
* macOS 2-set Korean keeps the trailing syllable composing until a terminator, so after four
* keystrokes only `가나다` has reached the pty; `라` lives in `.composition-view`, an opaque box
* anchored to the cursor cell. Stock xterm styles that box `#000`/`#FFF` in its own stylesheet,
* which is the black block the report measured, and no cursor setting can reach it — matching the
* report's second claim that `terminalCursorStyle` / `terminalCursorOpacity` do nothing.
*
* #15014 themed the overlay from `options.theme`; this pins the end-of-row arm of that, which is
* the shape #12729 hits. happy-dom performs no layout, so the cell size is supplied and only the
* anchor is asserted, not glyph geometry.
*/
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 = {
commit: (syllable: string) => Promise<void>
compositionView: HTMLElement
composeUpdate: (preedit: string) => void
composeStart: () => void
sent: string[]
terminal: Terminal
}
function openTerminal(): Rig {
const container = document.createElement('div')
document.body.appendChild(container)
const terminal = new Terminal({ cols: 80, rows: 24, theme: 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
// A local-echo shell: committed syllables come back through the pty and land in the buffer.
const sent: string[] = []
terminal.onData((data) => {
sent.push(data)
terminal.write(data)
})
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 commit = async (syllable: string): Promise<void> => {
const end = new CompositionEvent('compositionend', { bubbles: true })
Object.defineProperty(end, 'data', { value: syllable })
textarea.dispatchEvent(end)
textarea.value = ''
await nextEventLoop()
await nextEventLoop()
}
return { commit, compositionView, composeStart, composeUpdate, sent, terminal }
}
function stripMarks(text: string | null): string {
return (text ?? '').replaceAll('‎', '')
}
/** Rolling 2-set Korean: each new consonant commits the previous syllable and opens the next. */
async function typeHangulRun(rig: Rig, syllables: readonly string[]): Promise<void> {
for (let index = 0; index < syllables.length; index++) {
if (index > 0) {
await rig.commit(syllables[index - 1]!)
}
rig.composeStart()
rig.composeUpdate(syllables[index]!)
await nextEventLoop()
}
}
describe('#12729 — the block over a trailing Korean syllable is the preedit overlay', () => {
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('holds the trailing syllable in the overlay while the earlier ones reach the pty', async () => {
const rig = openTerminal()
await typeHangulRun(rig, ['가', '나', '다', '라'])
expect(rig.sent.join('')).toBe('가나다')
// Three committed syllables at two cells each: the cursor is at the next free cell.
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)
})
it('anchors the overlay to the cursor cell so the preedit continues the run', async () => {
const rig = openTerminal()
await typeHangulRun(rig, ['가', '나', '다', '라'])
expect(rig.compositionView.style.left).toBe(`${6 * CELL_WIDTH_PX}px`)
expect(rig.compositionView.style.height).toBe(`${CELL_HEIGHT_PX}px`)
// Synced to the grid's font so the preedit keeps the advance the committed syllables have.
expect(rig.compositionView.style.fontFamily).toBe(rig.terminal.options.fontFamily)
expect(rig.compositionView.style.fontSize).toBe(`${rig.terminal.options.fontSize}px`)
})
it('themes the trailing overlay instead of the stock opaque #000/#FFF block', async () => {
const rig = openTerminal()
await typeHangulRun(rig, ['가', '나', '다', '라'])
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('clears the overlay once the trailing syllable commits', async () => {
const rig = openTerminal()
await typeHangulRun(rig, ['가', '나', '다', '라'])
await rig.commit('라')
expect(rig.sent.join('')).toBe('가나다라')
expect(rig.compositionView.classList.contains('active')).toBe(false)
expect(rig.compositionView.textContent).toBe('')
// The whole run is now committed cells, with the cursor after it.
expect(rig.terminal.buffer.active.cursorX).toBe(8)
})
})