fix(terminal): align Cursor Agent IME preedit anchor (#14982)

This commit is contained in:
Neil
2026-08-16 17:18:24 -07:00
committed by GitHub
parent a324ee20d4
commit 96b03bab4f
4 changed files with 108 additions and 18 deletions
@@ -91,7 +91,7 @@ describe('resolveCursorAgentImeAnchor', () => {
})
it('does not override normal terminal cursor positioning', () => {
const buffer = makeBuffer(['', ' → Plan, search, build anything', '', ''])
const buffer = makeBuffer(['', ' → ordinary shell output', '', ''])
expect(
resolveCursorAgentImeAnchor({
@@ -104,6 +104,20 @@ describe('resolveCursorAgentImeAnchor', () => {
).toBeNull()
})
it('anchors the follow-up placeholder after the Cursor Agent header scrolls away', () => {
const buffer = makeBuffer(['transcript', '', ' → Add a follow-up', '', ''])
expect(
resolveCursorAgentImeAnchor({
buffer,
rows: 5,
cols: 80,
cursorX: 0,
cursorY: 4
})
).toEqual({ row: 2, column: 4 })
})
it('does not override when xterm already exposes a non-stale cursor position', () => {
const buffer = makeBuffer(['', ' Cursor Agent', '', ' → Plan, search, build anything'])
@@ -7,7 +7,7 @@ export type TerminalImeAnchor = {
const CURSOR_AGENT_HEADER = 'Cursor Agent'
const CURSOR_AGENT_INPUT_MARKER = '→'
const CURSOR_AGENT_EMPTY_PROMPT = 'Plan, search, build anything'
const CURSOR_AGENT_EMPTY_PROMPTS = ['Plan, search, build anything', 'Add a follow-up'] as const
const CURSOR_AGENT_HEADER_SCAN_ROWS = 6
export function resolveCursorAgentImeAnchor(args: {
@@ -16,6 +16,7 @@ export function resolveCursorAgentImeAnchor(args: {
cols: number
cursorX: number
cursorY: number
knownCursorAgent?: boolean
}): TerminalImeAnchor | null {
const cursorLine = getVisibleLine(args.buffer, args.cursorY)
if (args.cursorX !== 0 || !isBlankLine(cursorLine)) {
@@ -28,10 +29,9 @@ function findCursorAgentScreenInputAnchor(args: {
buffer: IBuffer
rows: number
cols: number
knownCursorAgent?: boolean
}): TerminalImeAnchor | null {
if (!hasCursorAgentHeader(args.buffer, args.rows)) {
return null
}
const allowTypedInput = args.knownCursorAgent || hasCursorAgentHeader(args.buffer, args.rows)
// Why: the input box sits below the transcript, so scan bottom-up — a
// transcript line containing "→ " (e.g. a rename diff) must not win.
@@ -40,7 +40,7 @@ function findCursorAgentScreenInputAnchor(args: {
if (!line) {
continue
}
const column = resolveCursorAgentInputColumn(line, args.cols)
const column = resolveCursorAgentInputColumn(line, args.cols, Boolean(allowTypedInput))
if (column !== null) {
return { row, column: Math.min(column, Math.max(args.cols - 1, 0)) }
}
@@ -63,16 +63,24 @@ function hasCursorAgentHeader(buffer: IBuffer, rows: number): boolean {
return false
}
function resolveCursorAgentInputColumn(line: IBufferLine, cols: number): number | null {
function resolveCursorAgentInputColumn(
line: IBufferLine,
cols: number,
allowTypedInput: boolean
): number | null {
const inputColumn = findCursorAgentInputStartColumn(line, cols)
if (inputColumn === null) {
return null
}
const inputText = line.translateToString(true, inputColumn, cols)
if (!inputText.trim() || inputText.startsWith(CURSOR_AGENT_EMPTY_PROMPT)) {
const isEmptyPrompt = CURSOR_AGENT_EMPTY_PROMPTS.some((prompt) => inputText.startsWith(prompt))
if (!inputText.trim() || isEmptyPrompt) {
return inputColumn
}
if (!allowTypedInput) {
return null
}
return findLineContentEndColumn(line, inputColumn, cols) ?? inputColumn
}
@@ -13,6 +13,7 @@ type AnchorHarness = {
terminal: Terminal
element: HTMLElement
style: { top: string; left: string }
compositionStyle: CSSStyleDeclaration
counts: { rectReads: number; styleWrites: number }
setCursor: (cursorX: number, cursorY: number) => void
setLines: (lines: string[]) => void
@@ -45,6 +46,9 @@ function createHarness(): AnchorHarness {
const element = document.createElement('div')
const screen = document.createElement('div')
screen.className = 'xterm-screen'
const compositionView = document.createElement('div')
compositionView.className = 'composition-view'
screen.appendChild(compositionView)
element.appendChild(screen)
document.body.appendChild(element)
@@ -86,6 +90,7 @@ function createHarness(): AnchorHarness {
terminal,
element,
style,
compositionStyle: compositionView.style,
counts,
setCursor: (cursorX: number, cursorY: number) => {
Object.assign(buffer, { cursorX, cursorY })
@@ -211,6 +216,45 @@ describe('installTerminalImeCandidateAnchor', () => {
expect(harness.style.top).toBe(`${2 * CELL_HEIGHT}px`)
})
it('keeps the Cursor Agent preedit overlay on the textarea anchor', () => {
const harness = createHarness()
harness.setLines(['Cursor Agent', '', '→ hello'])
harness.element.addEventListener('compositionupdate', () => {
window.setTimeout(() => {
harness.style.top = `${CELL_HEIGHT}px`
harness.compositionStyle.top = `${CELL_HEIGHT}px`
harness.compositionStyle.left = '0px'
}, 0)
})
installTerminalImeCandidateAnchor(harness.terminal)
harness.setCursor(0, 1)
fire(harness.element, 'compositionstart')
fire(harness.element, 'compositionupdate')
vi.runAllTimers()
expect(harness.style).toEqual({ top: `${2 * CELL_HEIGHT}px`, left: `${7 * CELL_WIDTH}px` })
expect(harness.compositionStyle.top).toBe(`${2 * CELL_HEIGHT}px`)
expect(harness.compositionStyle.left).toBe(`${7 * CELL_WIDTH}px`)
expect(harness.compositionStyle.height).toBe(`${CELL_HEIGHT}px`)
expect(harness.compositionStyle.lineHeight).toBe(`${CELL_HEIGHT}px`)
})
it('keeps typed follow-ups anchored after recognizing the initial Cursor Agent screen', () => {
const harness = createHarness()
harness.setLines(['Cursor Agent', '', '→ Plan, search, build anything', ''])
installTerminalImeCandidateAnchor(harness.terminal)
typeHangulSyllable(harness, 0, 1, 3)
harness.setLines(['transcript', '', '→ hello', ''])
harness.style.left = '0px'
fire(harness.element, 'compositionupdate')
vi.runAllTimers()
expect(harness.style).toEqual({ top: `${2 * CELL_HEIGHT}px`, left: `${7 * CELL_WIDTH}px` })
expect(harness.compositionStyle.left).toBe(`${7 * CELL_WIDTH}px`)
})
it('refreshes the deferred metrics and anchor after a refit', () => {
const harness = createHarness()
harness.setLines(['Cursor Agent', '', '→ hello'])
@@ -8,6 +8,8 @@ type ImeAnchorCellMetrics = {
rows: number
}
type ImeAnchorStyleProperty = 'top' | 'left' | 'height' | 'lineHeight'
/**
* Keep the OS IME candidate window anchored to the cell the user is typing in.
*
@@ -37,9 +39,11 @@ export function installTerminalImeCandidateAnchor(terminal: Terminal): (() => vo
return null
}
const screenElement = terminal.element.querySelector<HTMLElement>('.xterm-screen')
const compositionView = terminal.element.querySelector<HTMLElement>('.composition-view')
const textarea = terminal.textarea
let metrics: ImeAnchorCellMetrics | null = null
let deferredApply: number | null = null
let cursorAgentSeen = false
const measureCells = (): ImeAnchorCellMetrics | null => {
if (!screenElement) {
@@ -57,15 +61,33 @@ export function installTerminalImeCandidateAnchor(terminal: Terminal): (() => vo
// Why: xterm rewrites these between our events, so compare against the live
// inline value — a CSSOM read, unlike getBoundingClientRect — and skip the
// write when it already matches instead of re-invalidating layout.
const writeOffset = (property: 'top' | 'left', value: string): void => {
if (textarea.style[property] !== value) {
textarea.style[property] = value
const writeStyle = (
element: HTMLElement,
property: ImeAnchorStyleProperty,
value: string
): void => {
if (element.style[property] !== value) {
element.style[property] = value
}
}
const applyAnchor = (row: number, column: number, cells: ImeAnchorCellMetrics): void => {
writeOffset('top', `${row * cells.cellHeight}px`)
writeOffset('left', `${column * cells.cellWidth}px`)
const applyAnchor = (
row: number,
column: number,
cells: ImeAnchorCellMetrics,
isCursorAgent: boolean
): void => {
const top = `${row * cells.cellHeight}px`
const left = `${column * cells.cellWidth}px`
writeStyle(textarea, 'top', top)
writeStyle(textarea, 'left', left)
if (isCursorAgent && compositionView) {
const height = `${cells.cellHeight}px`
writeStyle(compositionView, 'top', top)
writeStyle(compositionView, 'left', left)
writeStyle(compositionView, 'height', height)
writeStyle(compositionView, 'lineHeight', height)
}
}
const resolveAnchor = (): { anchor: TerminalImeAnchor; isCursorAgent: boolean } => {
@@ -77,8 +99,10 @@ export function installTerminalImeCandidateAnchor(terminal: Terminal): (() => vo
rows: terminal.rows,
cols: terminal.cols,
cursorX: buf.cursorX,
cursorY: buf.cursorY
cursorY: buf.cursorY,
knownCursorAgent: cursorAgentSeen
})
cursorAgentSeen ||= cursorAgentAnchor !== null
return {
anchor: cursorAgentAnchor ?? {
row: buf.cursorY,
@@ -104,7 +128,7 @@ export function installTerminalImeCandidateAnchor(terminal: Terminal): (() => vo
return
}
const { anchor, isCursorAgent } = resolveAnchor()
applyAnchor(anchor.row, anchor.column, cells)
applyAnchor(anchor.row, anchor.column, cells, isCursorAgent)
// 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.
@@ -128,8 +152,8 @@ export function installTerminalImeCandidateAnchor(terminal: Terminal): (() => vo
metrics = measureCells()
}
if (metrics) {
const currentAnchor = resolveAnchor().anchor
applyAnchor(currentAnchor.row, currentAnchor.column, metrics)
const current = resolveAnchor()
applyAnchor(current.anchor.row, current.anchor.column, metrics, current.isCursorAgent)
}
}, 0)
}