fix(mobile): keep terminal caret visible without focus (#10101)

This commit is contained in:
BingZ 2026-07-24 14:55:35 +08:00 committed by GitHub
parent 4274dbc48a
commit 2cf41ab864
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 51 additions and 5 deletions

View File

@ -726,7 +726,9 @@ ${TERMINAL_WEBGL_RECOVERY_JS}
disableStdin: false,
cursorBlink: false,
cursorStyle: 'bar',
cursorInactiveStyle: 'none',
// Why: native TextInput owns mobile keyboard focus, so xterm stays inactive.
// Match its active bar while still honoring application cursor-hide sequences.
cursorInactiveStyle: 'bar',
convertEol: false,
allowProposedApi: true
});

View File

@ -1,5 +1,5 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { XTERM_HTML } from './terminal-webview-html'
function iifeSource(): string {
@ -15,6 +15,15 @@ function bodyMarkup(): string {
}
type TerminalStub = ReturnType<typeof makeTerminal>
type TerminalOptions = {
cursorInactiveStyle?: string
cursorStyle?: string
}
type RegisteredWindowListener = {
listener: EventListenerOrEventListenerObject
options?: boolean | AddEventListenerOptions
type: string
}
function makeTerminal(writeCallbacks: Array<() => void>) {
const terminal = {
@ -79,13 +88,26 @@ function dispatchInit(cols: number, initialData: string): void {
describe('terminal WebView init surface replacement', () => {
let animationFrames: Array<() => void>
let registeredWindowListeners: RegisteredWindowListener[]
let terminalOptions: TerminalOptions[]
let terminals: TerminalStub[]
let writeCallbacks: Array<() => void>
beforeEach(() => {
animationFrames = []
registeredWindowListeners = []
terminalOptions = []
terminals = []
writeCallbacks = []
const addWindowEventListener = window.addEventListener.bind(window)
vi.spyOn(window, 'addEventListener').mockImplementation(((
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions
) => {
registeredWindowListeners.push({ type, listener, options })
addWindowEventListener(type, listener, options)
}) as typeof window.addEventListener)
vi.stubGlobal('requestAnimationFrame', (callback: () => void) => {
animationFrames.push(callback)
return animationFrames.length
@ -93,20 +115,42 @@ describe('terminal WebView init surface replacement', () => {
Object.defineProperty(window, 'innerWidth', { value: 381, configurable: true })
Object.defineProperty(window, 'innerHeight', { value: 612, configurable: true })
const webWindow = window as unknown as {
Terminal: new () => TerminalStub
Terminal: new (options: TerminalOptions) => TerminalStub
ReactNativeWebView: { postMessage: (data: string) => void }
}
webWindow.Terminal = function () {
webWindow.Terminal = function (options: TerminalOptions) {
terminalOptions.push(options)
const terminal = makeTerminal(writeCallbacks)
terminals.push(terminal)
return terminal
} as unknown as new () => TerminalStub
} as unknown as new (options: TerminalOptions) => TerminalStub
webWindow.ReactNativeWebView = { postMessage: vi.fn() }
document.body.innerHTML = bodyMarkup()
// eslint-disable-next-line no-new-func
new Function(iifeSource())()
})
afterEach(() => {
for (const { type, listener, options } of registeredWindowListeners) {
window.removeEventListener(type, listener as EventListener, options)
}
vi.restoreAllMocks()
})
it("keeps xterm's inactive cursor visible across replacement surfaces", () => {
dispatchInit(120, 'desktop')
dispatchInit(51, 'phone-resize')
dispatchInit(51, 'phone-scrollback')
expect(terminalOptions).toHaveLength(3)
for (const options of terminalOptions) {
expect(options).toMatchObject({
cursorStyle: 'bar',
cursorInactiveStyle: 'bar'
})
}
})
it('commits only the newest surface when phone-fit init calls overlap', () => {
// Why: restored terminals can receive desktop scrollback, a phone resize,
// and phone scrollback before any xterm replay callback has completed.