mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(terminal): align Cursor Agent IME preedit anchor (#14982)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user