From 7dd3a3dc9cefa2447a800821416856ce59c752f3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:29:55 -0700 Subject: [PATCH] fix(terminal): recover floating input after app refocus --- .../FloatingTerminalPanel.test.tsx | 105 +++++++++++++++++- .../FloatingTerminalPanel.tsx | 66 ++++++++++- .../regular-terminal-focus-ownership.test.ts | 6 +- .../regular-terminal-focus-ownership.ts | 11 +- ...terminal-ime-input-context-refresh.test.ts | 43 ++++++- .../terminal-ime-input-context-refresh.ts | 18 ++- .../lib/focus-terminal-tab-surface.test.ts | 53 +++++++++ .../src/lib/focus-terminal-tab-surface.ts | 41 ++++++- 8 files changed, 330 insertions(+), 13 deletions(-) diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx index dfcf8337e3a..f762a627d42 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx @@ -106,12 +106,14 @@ const mocks = vi.hoisted(() => ({ getFloatingMarkdownDirectory: vi.fn(), getFloatingTerminalCwd: vi.fn(), getInstallStatus: vi.fn(), + isTerminalImeInputContextRefreshing: vi.fn(), isWebRuntimeSessionActive: vi.fn(), markFileDirty: vi.fn(), makePreviewFilePermanent: vi.fn(), openFile: vi.fn(), pickFloatingMarkdownDocument: vi.fn(), pinFile: vi.fn(), + setFloatingTerminalInputFocused: vi.fn(), setActiveTab: vi.fn(), setRenamingTabId: vi.fn(), setTabColor: vi.fn(), @@ -185,6 +187,10 @@ vi.mock('@/components/terminal-pane/TerminalPane', () => ({ } })) +vi.mock('@/components/terminal-pane/terminal-ime-input-context-refresh', () => ({ + isTerminalImeInputContextRefreshing: mocks.isTerminalImeInputContextRefreshing +})) + vi.mock('@/components/browser-pane/BrowserPane', () => ({ default: function BrowserPane() { return null @@ -744,6 +750,7 @@ describe('FloatingTerminalPanel close behavior', () => { mocks.getFloatingMarkdownDirectory.mockResolvedValue('/tmp/orca/floating-notes') mocks.getFloatingTerminalCwd.mockResolvedValue('/tmp/orca') mocks.getInstallStatus.mockResolvedValue({ state: 'installed', pathConfigured: true }) + mocks.isTerminalImeInputContextRefreshing.mockReturnValue(false) mocks.isWebRuntimeSessionActive.mockReturnValue(false) mocks.pickFloatingMarkdownDocument.mockResolvedValue(null) const localStorage = { @@ -762,7 +769,7 @@ describe('FloatingTerminalPanel close behavior', () => { }, browser: { notifyActiveTabChanged: vi.fn() }, cli: { getInstallStatus: mocks.getInstallStatus }, - ui: { setFloatingTerminalInputFocused: vi.fn() } + ui: { setFloatingTerminalInputFocused: mocks.setFloatingTerminalInputFocused } }, innerHeight: 800, innerWidth: 1200, @@ -799,6 +806,102 @@ describe('FloatingTerminalPanel close behavior', () => { expect(getPanelClassName(element)).toContain('z-30') }) + it('refreshes terminal native input focus when the floating panel opens', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + + await renderPanel(true) + runEffects() + + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith( + 'tab-1', + null, + expect.objectContaining({ + onImeRefocusSkipped: expect.any(Function), + refreshImeContext: true + }) + ) + mocks.setFloatingTerminalInputFocused.mockClear() + mocks.focusTerminalTabSurface.mock.calls[0]?.[2].onImeRefocusSkipped() + expect(mocks.setFloatingTerminalInputFocused).toHaveBeenCalledWith(false) + }) + + it('preserves and reclaims terminal input ownership across window blur', async () => { + setFloatingTabs([makeTab({ id: 'tab-1' })]) + const element = await renderPanel(true) + const panel = findByProp(element, 'data-floating-terminal-panel') + const panelElement = { contains: vi.fn().mockReturnValue(true), focus: vi.fn() } + const terminalInput = { + blur: vi.fn(), + classList: { contains: vi.fn((token: string) => token === 'xterm-helper-textarea') }, + closest: vi.fn((selector: string) => { + if (selector === '[data-floating-terminal-panel]') { + return panelElement + } + return selector === '[data-leaf-id]' + ? { + getAttribute: (attribute: string) => (attribute === 'data-leaf-id' ? 'leaf-1' : null) + } + : null + }), + isConnected: true + } + Object.setPrototypeOf(panelElement, HTMLElement.prototype) + Object.setPrototypeOf(terminalInput, HTMLElement.prototype) + attachRef(panel.props.ref, panelElement) + mocks.isTerminalImeInputContextRefreshing.mockReturnValueOnce(true) + const onBlurCapture = panel.props.onBlurCapture as (event: unknown) => void + onBlurCapture({ + relatedTarget: null, + target: terminalInput + }) + expect(mocks.setFloatingTerminalInputFocused).not.toHaveBeenCalled() + const documentState = { + activeElement: terminalInput as unknown as HTMLElement | null, + addEventListener: vi.fn(), + body: {} as HTMLElement, + removeEventListener: vi.fn() + } + vi.stubGlobal('document', documentState) + runEffects() + const blurListener = vi + .mocked(window.addEventListener) + .mock.calls.findLast(([type]) => type === 'blur')?.[1] as (() => void) | undefined + const focusListener = vi + .mocked(window.addEventListener) + .mock.calls.find(([type]) => type === 'focus')?.[1] as (() => void) | undefined + if (!blurListener || !focusListener) { + throw new Error('floating terminal window focus listeners not registered') + } + + blurListener() + expect(terminalInput.blur).not.toHaveBeenCalled() + + focusListener() + expect(mocks.setFloatingTerminalInputFocused).toHaveBeenCalledWith(true) + + documentState.activeElement = terminalInput as unknown as HTMLElement + blurListener() + documentState.activeElement = documentState.body + mocks.focusTerminalTabSurface.mockClear() + focusListener() + expect(mocks.focusTerminalTabSurface).not.toHaveBeenCalled() + + documentState.activeElement = terminalInput as unknown as HTMLElement + blurListener() + terminalInput.isConnected = false + documentState.activeElement = documentState.body + focusListener() + expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith( + 'tab-1', + 'leaf-1', + expect.objectContaining({ + onlyIfFocusUnclaimed: true, + onImeRefocusSkipped: expect.any(Function), + refreshImeContext: true + }) + ) + }) + it('falls back to default bounds when persisted geometry is malformed', async () => { getMockedLocalStorage().getItem.mockImplementation((key: string) => key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index 4f0b65ea3dc..bbe19e800ba 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -12,6 +12,7 @@ import { useContextualTour } from '@/components/contextual-tours/use-contextual- import TabBar from '@/components/tab-bar/TabBar' import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id' import TerminalPane from '@/components/terminal-pane/TerminalPane' +import { isTerminalImeInputContextRefreshing } from '@/components/terminal-pane/terminal-ime-input-context-refresh' import { Button } from '@/components/ui/button' import { useMountedRef } from '@/hooks/useMountedRef' import { useShortcutKeyDetails, type ShortcutKeyComboDetails } from '@/hooks/useShortcutLabel' @@ -222,6 +223,10 @@ export function FloatingTerminalPanel({ } const shortcutFocusFrameRef = useRef(null) const shortcutFocusTimeoutRef = useRef(null) + const reclaimTerminalInputOnWindowFocusRef = useRef<{ + helper: HTMLElement + leafId: string | null + } | null>(null) const mountedRef = useMountedRef() const dragRef = useRef<{ pointerId: number @@ -578,7 +583,10 @@ export function FloatingTerminalPanel({ if (!open || !activeTerminalId) { return } - focusTerminalTabSurface(activeTerminalId) + focusTerminalTabSurface(activeTerminalId, null, { + onImeRefocusSkipped: () => setFloatingTerminalInputFocusedInMain(false), + refreshImeContext: true + }) }, [activeTerminalId, open]) useEffect(() => { @@ -1313,22 +1321,68 @@ export function FloatingTerminalPanel({ const handleWindowBlur = (): void => { const panel = panelRef.current const active = document.activeElement + reclaimTerminalInputOnWindowFocusRef.current = null if (!panel || !(active instanceof HTMLElement) || !panel.contains(active)) { return } // Why: browser webviews focus out-of-process and do not emit renderer // pointerdown events, so release floating ownership on renderer blur too. setFloatingTerminalInputFocusedInMain(false) + if (isFloatingWorkspaceTerminalInputTarget(active)) { + // Why: the terminal focus lifecycle preserves this exact helper across + // app blur so macOS can rebuild its native input context on return. + reclaimTerminalInputOnWindowFocusRef.current = { + helper: active, + leafId: active.closest('[data-leaf-id]')?.getAttribute('data-leaf-id') ?? null + } + return + } active.blur() } + const handleWindowFocus = (): void => { + const reclaim = reclaimTerminalInputOnWindowFocusRef.current + if (!reclaim) { + return + } + reclaimTerminalInputOnWindowFocusRef.current = null + const panel = panelRef.current + const active = document.activeElement + if ( + panel && + active instanceof HTMLElement && + panel.contains(active) && + isFloatingWorkspaceTerminalInputTarget(active) + ) { + setFloatingTerminalInputFocusedInMain(true) + return + } + if ((active === null || active === document.body) && activeTerminalId) { + if (reclaim.helper.isConnected && panel?.contains(reclaim.helper)) { + // Why: TerminalPane owns exact-helper reclaim and IME refresh. Avoid + // racing it with a second floating-layer blur/refocus cycle. + return + } + // Why: only a helper that genuinely remounted while backgrounded needs + // tab/leaf recovery; the shared TerminalPane owner cannot reclaim it. + focusTerminalTabSurface(activeTerminalId, reclaim.leafId, { + onlyIfFocusUnclaimed: true, + onImeRefocusSkipped: () => setFloatingTerminalInputFocusedInMain(false), + refreshImeContext: true + }) + } + } + document.addEventListener('pointerdown', handleOutsidePointerDown, true) window.addEventListener('blur', handleWindowBlur) + window.addEventListener('focus', handleWindowFocus) return () => { + reclaimTerminalInputOnWindowFocusRef.current = null document.removeEventListener('pointerdown', handleOutsidePointerDown, true) window.removeEventListener('blur', handleWindowBlur) + window.removeEventListener('focus', handleWindowFocus) } - }, [open]) + }, [activeTerminalId, open]) const handleDragStart = (event: React.PointerEvent): void => { if (maximized) { return @@ -1423,7 +1477,13 @@ export function FloatingTerminalPanel({ commitUserBounds({ ...stagedBoundsRef.current, width: rect.width, height: rect.height }) }} onFocusCapture={(event) => setFloatingTerminalInputFocused(event.target)} - onBlurCapture={(event) => setFloatingTerminalInputFocused(event.relatedTarget)} + onBlurCapture={(event) => { + // Why: keep terminal-first shortcut ownership latched during the + // synchronous macOS IME refresh blur; refocus or its skip callback settles it. + if (!isTerminalImeInputContextRefreshing(event.target)) { + setFloatingTerminalInputFocused(event.relatedTarget) + } + }} onKeyDownCapture={handleShortcutSurfaceKeyDown} >
diff --git a/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts b/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts index 516a19f39c6..f3d81a3cc31 100644 --- a/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts +++ b/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.test.ts @@ -154,12 +154,13 @@ describe('regular terminal focus ownership', () => { }) expect(synced).toBe(true) - expect(syncFocused).toHaveBeenCalledWith(true) + expect(syncFocused).not.toHaveBeenCalled() // Why: reclaim is deferred so a newer focus owner during reactivation wins. expect(focus).not.toHaveBeenCalled() for (const run of scheduled) { run() } + expect(syncFocused).toHaveBeenCalledWith(true) expect(focus).toHaveBeenCalledOnce() expect(document.activeElement).toBe(helper) }) @@ -220,6 +221,7 @@ describe('regular terminal focus ownership', () => { syncFocused }) document.body.focus() + syncFocused.mockClear() focus.mockClear() const scheduled: (() => void)[] = [] @@ -238,6 +240,7 @@ describe('regular terminal focus ownership', () => { } expect(focus).not.toHaveBeenCalled() + expect(syncFocused).toHaveBeenCalledWith(false) expect(document.activeElement).toBe(outside) }) @@ -355,6 +358,7 @@ describe('regular terminal focus ownership', () => { run() } expect(focus).not.toHaveBeenCalled() + expect(syncFocused).toHaveBeenLastCalledWith(false) expect(document.activeElement).toBe(outside) }) diff --git a/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.ts b/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.ts index 3f50f574f81..75d82f92bdd 100644 --- a/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.ts +++ b/src/renderer/src/components/terminal-pane/regular-terminal-focus-ownership.ts @@ -102,8 +102,6 @@ export function resyncTerminalFocusForWindowFocus(args: { } } - args.syncFocused(true) - const reclaimedHelper = helper // Why: defer the reclaim refocus to the next frame and only take focus if @@ -115,6 +113,7 @@ export function resyncTerminalFocusForWindowFocus(args: { const schedule = args.scheduleRefocus ?? scheduleNextFrame schedule(() => { if (!reclaimedHelper.isConnected) { + args.syncFocused(false) return } const active = reclaimedHelper.ownerDocument.activeElement @@ -122,16 +121,24 @@ export function resyncTerminalFocusForWindowFocus(args: { active === reclaimedHelper || isDocumentBodyOrNull(active, reclaimedHelper.ownerDocument) ) { + args.syncFocused(true) reclaimedHelper.focus() + return } + args.syncFocused(false) }) return true } + args.syncFocused(true) + // Why: macOS app reactivation leaves a stale NSTextInputContext on the // still-focused helper (electron#32307/#34952); non-mac returns false inside. refreshTerminalImeInputContext(reclaimedHelper, { isMac: args.isMac, + // Why: if another control wins during the refresh frame, the terminal + // mirror must follow that owner instead of remaining latched true. + onRefocusSkipped: () => args.syncFocused(false), scheduleRefocus: args.scheduleRefocus }) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.test.ts index 69b73c586d9..b9901d81302 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.test.ts @@ -1,6 +1,9 @@ // @vitest-environment happy-dom import { beforeEach, describe, expect, it, vi } from 'vitest' -import { refreshTerminalImeInputContext } from './terminal-ime-input-context-refresh' +import { + isTerminalImeInputContextRefreshing, + refreshTerminalImeInputContext +} from './terminal-ime-input-context-refresh' describe('refreshTerminalImeInputContext', () => { beforeEach(() => { @@ -38,6 +41,23 @@ describe('refreshTerminalImeInputContext', () => { expect(document.activeElement).toBe(helper) }) + it('marks only the synchronous refresh blur so focus ownership can stay latched', () => { + const helper = appendHelper() + let refreshingDuringBlur = false + helper.addEventListener('blur', (event) => { + refreshingDuringBlur = isTerminalImeInputContextRefreshing(event.target) + }) + helper.focus() + + refreshTerminalImeInputContext(helper, { + isMac: true, + scheduleRefocus: vi.fn() + }) + + expect(refreshingDuringBlur).toBe(true) + expect(isTerminalImeInputContextRefreshing(helper)).toBe(false) + }) + it('does not steal focus if another element grabbed it before the refocus frame', () => { const helper = appendHelper() const outside = document.createElement('input') @@ -60,6 +80,27 @@ describe('refreshTerminalImeInputContext', () => { expect(document.activeElement).toBe(outside) }) + it('reports when a newer focus owner wins the refocus guard', () => { + const helper = appendHelper() + const outside = document.createElement('input') + document.body.appendChild(outside) + const onRefocusSkipped = vi.fn() + const scheduled: (() => void)[] = [] + helper.focus() + + refreshTerminalImeInputContext(helper, { + isMac: true, + onRefocusSkipped, + scheduleRefocus: (callback) => scheduled.push(callback) + }) + + outside.focus() + for (const run of scheduled) { + run() + } + expect(onRefocusSkipped).toHaveBeenCalledOnce() + }) + it('skips non-macOS platforms', () => { const helper = appendHelper() const blur = vi.spyOn(helper, 'blur') diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.ts b/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.ts index e86fc79bde5..3e97032ee5a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-input-context-refresh.ts @@ -3,10 +3,18 @@ export type TerminalImeInputContextRefocusScheduler = (callback: () => void) => export type TerminalImeInputContextRefreshOptions = { /** Override the macOS check (tests). Defaults to the navigator user agent. */ isMac?: boolean + /** Called when a newer focus owner prevents the scheduled refocus. */ + onRefocusSkipped?: () => void /** Override the refocus scheduler (tests). Defaults to requestAnimationFrame. */ scheduleRefocus?: TerminalImeInputContextRefocusScheduler } +const refreshingHelpers = new WeakSet() + +export function isTerminalImeInputContextRefreshing(target: EventTarget | null): boolean { + return target instanceof HTMLElement && refreshingHelpers.has(target) +} + function isMacUserAgent(): boolean { return typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') } @@ -38,17 +46,25 @@ export function refreshTerminalImeInputContext( const ownerDocument = helper.ownerDocument // Why: Electron/Chromium can keep a stale NSTextInputContext on the xterm // helper after focus handoffs; blur/refocus rebuilds it so CJK IMEs work. - helper.blur() + refreshingHelpers.add(helper) + try { + helper.blur() + } finally { + refreshingHelpers.delete(helper) + } const schedule = options.scheduleRefocus ?? scheduleNextFrame schedule(() => { const active = ownerDocument.activeElement if (!helper.isConnected) { + options.onRefocusSkipped?.() return } if (active === helper || isDocumentBodyOrNull(active, ownerDocument)) { helper.focus() + return } + options.onRefocusSkipped?.() }) return true diff --git a/src/renderer/src/lib/focus-terminal-tab-surface.test.ts b/src/renderer/src/lib/focus-terminal-tab-surface.test.ts index 9d2313da44a..9df0ef4132d 100644 --- a/src/renderer/src/lib/focus-terminal-tab-surface.test.ts +++ b/src/renderer/src/lib/focus-terminal-tab-surface.test.ts @@ -1,8 +1,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { focusTerminalTabSurface } from './focus-terminal-tab-surface' +const mocks = vi.hoisted(() => ({ + refreshTerminalImeInputContext: vi.fn() +})) + +vi.mock('@/components/terminal-pane/terminal-ime-input-context-refresh', () => ({ + refreshTerminalImeInputContext: mocks.refreshTerminalImeInputContext +})) + describe('focusTerminalTabSurface', () => { afterEach(() => { + mocks.refreshTerminalImeInputContext.mockClear() vi.unstubAllGlobals() }) @@ -28,6 +37,50 @@ describe('focusTerminalTabSurface', () => { expect(textarea.focus).toHaveBeenCalled() }) + it('optionally refreshes the focused helper native input context', () => { + flushAnimationFrames() + const textarea = { focus: vi.fn() } + vi.stubGlobal('document', { + querySelector: vi.fn((selector: string) => + selector === '[data-terminal-tab-id="tab-1"] .xterm-helper-textarea' ? textarea : null + ) + }) + + focusTerminalTabSurface('tab-1', null, { refreshImeContext: true }) + + expect(textarea.focus).toHaveBeenCalledOnce() + expect(mocks.refreshTerminalImeInputContext).toHaveBeenCalledWith(textarea, { + onRefocusSkipped: undefined + }) + }) + + it('does not steal focus from a newer owner during guarded remount recovery', () => { + const frames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frames.push(callback) + return frames.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + const textarea = { focus: vi.fn() } + const body = {} + const outside = {} + const documentState = { + activeElement: body as unknown, + body, + querySelector: vi.fn((selector: string) => + selector === '[data-terminal-tab-id="tab-1"] .xterm-helper-textarea' ? textarea : null + ) + } + vi.stubGlobal('document', documentState) + + focusTerminalTabSurface('tab-1', null, { onlyIfFocusUnclaimed: true }) + frames.shift()?.(0) + documentState.activeElement = outside + frames.shift()?.(0) + + expect(textarea.focus).not.toHaveBeenCalled() + }) + it('does not steal focus while inline tab rename is open', () => { flushAnimationFrames() const textarea = { focus: vi.fn() } diff --git a/src/renderer/src/lib/focus-terminal-tab-surface.ts b/src/renderer/src/lib/focus-terminal-tab-surface.ts index a41913c2ab8..88716c0306b 100644 --- a/src/renderer/src/lib/focus-terminal-tab-surface.ts +++ b/src/renderer/src/lib/focus-terminal-tab-surface.ts @@ -1,3 +1,5 @@ +import { refreshTerminalImeInputContext } from '@/components/terminal-pane/terminal-ime-input-context-refresh' + /** * Move keyboard focus into the xterm instance for a freshly-mounted terminal * tab. Handles the two-step race where React must first mount the new @@ -11,6 +13,29 @@ function cssAttributeString(value: string): string { let pendingFocusFrameIds: number[] = [] +type FocusTerminalTabSurfaceOptions = { + onlyIfFocusUnclaimed?: boolean + onImeRefocusSkipped?: () => void + refreshImeContext?: boolean +} + +function focusTerminalHelper(helper: HTMLElement, options: FocusTerminalTabSurfaceOptions): void { + if (options.onlyIfFocusUnclaimed) { + const active = document.activeElement + if (active !== helper && active !== null && active !== document.body) { + return + } + } + helper.focus() + if (options.refreshImeContext) { + // Why: a CSS-hidden, long-lived xterm can retain a stale macOS native text + // input context even after DOM focus returns; blur/refocus rebuilds it. + refreshTerminalImeInputContext(helper, { + onRefocusSkipped: options.onImeRefocusSkipped + }) + } +} + function cancelPendingFocusFrames(): void { if (typeof cancelAnimationFrame === 'function') { for (const frameId of pendingFocusFrameIds) { @@ -29,7 +54,11 @@ function canUseSinglePaneStaleLeafFallback(tabId: string, leafId: string): boole return expectedLeafIds?.length === 1 && !expectedLeafIds.includes(leafId) } -export function focusTerminalTabSurface(tabId: string, leafId?: string | null): void { +export function focusTerminalTabSurface( + tabId: string, + leafId?: string | null, + options: FocusTerminalTabSurfaceOptions = {} +): void { cancelPendingFocusFrames() const firstFrameId = requestAnimationFrame(() => { pendingFocusFrameIds = pendingFocusFrameIds.filter((frameId) => frameId !== firstFrameId) @@ -46,7 +75,7 @@ export function focusTerminalTabSurface(tabId: string, leafId?: string | null): : `[data-terminal-tab-id="${escapedTabId}"] .xterm-helper-textarea` const scoped = document.querySelector(scopedSelector) as HTMLElement | null if (scoped) { - scoped.focus() + focusTerminalHelper(scoped, options) return } if (leafId) { @@ -62,13 +91,17 @@ export function focusTerminalTabSurface(tabId: string, leafId?: string | null): ) if (tabScopedHelpers.length === 1) { const fallback = tabScopedHelpers.item(0) as HTMLElement | null - fallback?.focus() + if (fallback) { + focusTerminalHelper(fallback, options) + } return } return } const fallback = document.querySelector('.xterm-helper-textarea') as HTMLElement | null - fallback?.focus() + if (fallback) { + focusTerminalHelper(fallback, options) + } }) pendingFocusFrameIds.push(secondFrameId) })