fix(terminal): refresh deferred IME anchor after refit

This commit is contained in:
Brennan Benson
2026-08-04 00:55:07 -07:00
parent 6ebb2bb364
commit 186fa51142
2 changed files with 47 additions and 20 deletions
@@ -193,6 +193,21 @@ describe('installTerminalImeCandidateAnchor', () => {
expect(harness.style).toEqual({ top: `${2 * CELL_HEIGHT}px`, left: `${7 * CELL_WIDTH}px` })
})
it('refreshes the deferred metrics and anchor after a refit', () => {
const harness = createHarness()
harness.setLines(['Cursor Agent', '', '→ hello'])
installTerminalImeCandidateAnchor(harness.terminal)
typeHangulSyllable(harness, 0, 5, 1)
Object.assign(harness.terminal, { cols: 40 })
harness.setLines(['Cursor Agent', '', '', '→ hello'])
harness.style.top = '0px'
vi.runAllTimers()
expect(harness.counts.rectReads).toBe(2)
expect(harness.style).toEqual({ top: `${3 * CELL_HEIGHT}px`, left: `${14 * CELL_WIDTH}px` })
})
it('stops writing once the textarea has been detached', () => {
const harness = createHarness()
harness.setLines(['Cursor Agent', '', '→ hello'])
@@ -1,5 +1,5 @@
import type { Terminal } from '@xterm/xterm'
import { resolveCursorAgentImeAnchor } from './terminal-ime-anchor'
import { resolveCursorAgentImeAnchor, type TerminalImeAnchor } from './terminal-ime-anchor'
type ImeAnchorCellMetrics = {
cellWidth: number
@@ -40,7 +40,6 @@ export function installTerminalImeCandidateAnchor(terminal: Terminal): (() => vo
const textarea = terminal.textarea
let metrics: ImeAnchorCellMetrics | null = null
let deferredApply: number | null = null
let deferredAnchor: { row: number; column: number } | null = null
const measureCells = (): ImeAnchorCellMetrics | null => {
if (!screenElement) {
@@ -69,6 +68,26 @@ export function installTerminalImeCandidateAnchor(terminal: Terminal): (() => vo
writeOffset('left', `${column * cells.cellWidth}px`)
}
const resolveAnchor = (): { anchor: TerminalImeAnchor; isCursorAgent: boolean } => {
const buf = terminal.buffer.active
// Why: Cursor Agent draws its prompt UI while leaving xterm's public cursor
// on a blank row, so the OS IME anchor needs the rendered prompt row instead.
const cursorAgentAnchor = resolveCursorAgentImeAnchor({
buffer: buf,
rows: terminal.rows,
cols: terminal.cols,
cursorX: buf.cursorX,
cursorY: buf.cursorY
})
return {
anchor: cursorAgentAnchor ?? {
row: buf.cursorY,
column: Math.min(buf.cursorX, terminal.cols - 1)
},
isCursorAgent: cursorAgentAnchor !== null
}
}
const handler = (event?: Event): void => {
if (!screenElement) {
return
@@ -84,33 +103,26 @@ export function installTerminalImeCandidateAnchor(terminal: Terminal): (() => vo
if (!cells) {
return
}
const buf = terminal.buffer.active
// Why: Cursor Agent draws its prompt UI while leaving xterm's public cursor
// on a blank row, so the OS IME anchor needs the rendered prompt row instead.
const cursorAgentAnchor = resolveCursorAgentImeAnchor({
buffer: buf,
rows: terminal.rows,
cols: terminal.cols,
cursorX: buf.cursorX,
cursorY: buf.cursorY
})
const anchor = cursorAgentAnchor ?? {
row: buf.cursorY,
column: Math.min(buf.cursorX, terminal.cols - 1)
}
const { anchor, isCursorAgent } = resolveAnchor()
applyAnchor(anchor.row, anchor.column, cells)
// Why: xterm re-positions the textarea from a setTimeout(0) of its own after
// each compositionupdate, so the correction has to land after that timer —
// one pending timer per burst, re-reading the anchor when it fires.
if (!cursorAgentAnchor) {
if (!isCursorAgent) {
return
}
deferredAnchor = anchor
if (deferredApply === null) {
deferredApply = window.setTimeout(() => {
deferredApply = null
if (textarea.isConnected && metrics && deferredAnchor) {
applyAnchor(deferredAnchor.row, deferredAnchor.column, metrics)
if (!textarea.isConnected) {
return
}
if (!metrics || metrics.cols !== terminal.cols || metrics.rows !== terminal.rows) {
metrics = measureCells()
}
if (metrics) {
const currentAnchor = resolveAnchor().anchor
applyAnchor(currentAnchor.row, currentAnchor.column, metrics)
}
}, 0)
}