From 96b03bab4f3797e1233e73ccffca6c3d5177a445 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:18:24 -0700 Subject: [PATCH] fix(terminal): align Cursor Agent IME preedit anchor (#14982) --- .../pane-manager/terminal-ime-anchor.test.ts | 16 ++++++- .../lib/pane-manager/terminal-ime-anchor.ts | 22 +++++++--- .../terminal-ime-candidate-anchor.test.ts | 44 +++++++++++++++++++ .../terminal-ime-candidate-anchor.ts | 44 ++++++++++++++----- 4 files changed, 108 insertions(+), 18 deletions(-) diff --git a/src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts index 19de8afef2c..0045b1c000c 100644 --- a/src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.test.ts @@ -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']) diff --git a/src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts index bda4c751a88..e5ada5b3a97 100644 --- a/src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts +++ b/src/renderer/src/lib/pane-manager/terminal-ime-anchor.ts @@ -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 } diff --git a/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.test.ts b/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.test.ts index c59b62f8751..8b0ef503ded 100644 --- a/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.test.ts @@ -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']) diff --git a/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.ts b/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.ts index 64893f84332..2d4349d67d2 100644 --- a/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.ts +++ b/src/renderer/src/lib/pane-manager/terminal-ime-candidate-anchor.ts @@ -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('.xterm-screen') + const compositionView = terminal.element.querySelector('.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) }