diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.test.ts b/src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.test.ts index 34c41bc5fb6..21379833bc9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.test.ts @@ -3,7 +3,6 @@ import { Terminal } from '@xterm/xterm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { installTerminalImeComposerPlaceholderMask, - MAX_ACTIVE_COMPOSITION_SESSIONS, TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS } from './terminal-ime-composer-placeholder-mask' import { @@ -200,7 +199,7 @@ describe('terminal IME composer placeholder mask', () => { expect(rig.element.classList.contains(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS)).toBe(false) }) - it('resets stale ownership at the session bound while keeping the newest session active', async () => { + it('keeps the newest session owner when an older session ends first', async () => { const rig = openTerminal() await rig.write(codexPlaceholderFrame()) @@ -213,17 +212,60 @@ describe('terminal IME composer placeholder mask', () => { ) } - for (let id = 1; id <= MAX_ACTIVE_COMPOSITION_SESSIONS + 1; id += 1) { - dispatchSession(XTERM_COMPOSITION_SESSION_START_EVENT, id) - } + dispatchSession(XTERM_COMPOSITION_SESSION_START_EVENT, 1) + dispatchSession(XTERM_COMPOSITION_SESSION_START_EVENT, 2) + dispatchSession(XTERM_COMPOSITION_SESSION_END_EVENT, 1) expect(rig.element.classList.contains(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS)).toBe(true) - for (let id = 1; id <= MAX_ACTIVE_COMPOSITION_SESSIONS; id += 1) { + dispatchSession(XTERM_COMPOSITION_SESSION_END_EVENT, 2) + expect(rig.element.classList.contains(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS)).toBe(false) + }) + + it('clears the visible owner on a current end and ignores a late older end', async () => { + const rig = openTerminal() + await rig.write(codexPlaceholderFrame()) + + const dispatchSession = (type: string, id: number): void => { + rig.element.dispatchEvent( + new CustomEvent(type, { + bubbles: true, + detail: { id } + }) + ) + } + + dispatchSession(XTERM_COMPOSITION_SESSION_START_EVENT, 11) + dispatchSession(XTERM_COMPOSITION_SESSION_START_EVENT, 12) + dispatchSession(XTERM_COMPOSITION_SESSION_END_EVENT, 12) + expect(rig.element.classList.contains(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS)).toBe(false) + + dispatchSession(XTERM_COMPOSITION_SESSION_END_EVENT, 11) + expect(rig.element.classList.contains(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS)).toBe(false) + }) + + it('keeps ownership bounded across repeated starts and recovers on the newest end', async () => { + const rig = openTerminal() + await rig.write(codexPlaceholderFrame()) + + const dispatchSession = (type: string, id: number): void => { + rig.element.dispatchEvent( + new CustomEvent(type, { + bubbles: true, + detail: { id } + }) + ) + } + + const newestId = 2048 + for (let id = 1; id <= newestId; id += 1) { + dispatchSession(XTERM_COMPOSITION_SESSION_START_EVENT, id) + } + for (let id = 1; id < newestId; id += 1) { dispatchSession(XTERM_COMPOSITION_SESSION_END_EVENT, id) } expect(rig.element.classList.contains(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS)).toBe(true) - dispatchSession(XTERM_COMPOSITION_SESSION_END_EVENT, MAX_ACTIVE_COMPOSITION_SESSIONS + 1) + dispatchSession(XTERM_COMPOSITION_SESSION_END_EVENT, newestId) expect(rig.element.classList.contains(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS)).toBe(false) }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.ts b/src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.ts index 3d48d4502b3..bc009421598 100644 --- a/src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.ts +++ b/src/renderer/src/components/terminal-pane/terminal-ime-composer-placeholder-mask.ts @@ -7,9 +7,6 @@ import { } from './terminal-ime-composition-route' export const TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS = 'orca-ime-composer-placeholder' -// One live composition and a few delayed turnovers are enough for xterm's lifecycle; a missing -// session-end event must not retain ownership forever. -export const MAX_ACTIVE_COMPOSITION_SESSIONS = 8 function compositionSessionId(event: Event): number | null { if (!(event instanceof CustomEvent)) { @@ -25,10 +22,12 @@ export function installTerminalImeComposerPlaceholderMask(terminal: Terminal): I return { dispose: () => undefined } } - const activeSessions = new Set() + // xterm renders one composition view; a newer transaction supersedes any older pending one. + // Keeping only its id makes malformed/repeated starts bounded without evicting live ownership. + let activeSessionId: number | null = null const syncPlaceholderOwnership = (): void => { const ownsPlaceholder = - activeSessions.size > 0 && + activeSessionId !== null && hasTerminalComposerPlaceholder(readTerminalCursorLineContext(terminal, terminal.rows)) element.classList.toggle(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS, ownsPlaceholder) } @@ -37,11 +36,7 @@ export function installTerminalImeComposerPlaceholderMask(terminal: Terminal): I if (id === null) { return } - if (!activeSessions.has(id) && activeSessions.size >= MAX_ACTIVE_COMPOSITION_SESSIONS) { - // Drop stale ownership before adopting the newest transaction. - activeSessions.clear() - } - activeSessions.add(id) + activeSessionId = id syncPlaceholderOwnership() } const handleSessionEnd = (event: Event): void => { @@ -49,11 +44,13 @@ export function installTerminalImeComposerPlaceholderMask(terminal: Terminal): I if (id === null) { return } - activeSessions.delete(id) + if (activeSessionId === id) { + activeSessionId = null + } syncPlaceholderOwnership() } const handleBlur = (): void => { - activeSessions.clear() + activeSessionId = null syncPlaceholderOwnership() } @@ -61,14 +58,14 @@ export function installTerminalImeComposerPlaceholderMask(terminal: Terminal): I element.addEventListener(XTERM_COMPOSITION_SESSION_END_EVENT, handleSessionEnd) element.addEventListener('blur', handleBlur, true) const renderDisposable = terminal.onRender(() => { - if (activeSessions.size > 0) { + if (activeSessionId !== null) { syncPlaceholderOwnership() } }) return { dispose: () => { - activeSessions.clear() + activeSessionId = null element.classList.remove(TERMINAL_IME_COMPOSER_PLACEHOLDER_CLASS) element.removeEventListener(XTERM_COMPOSITION_SESSION_START_EVENT, handleSessionStart) element.removeEventListener(XTERM_COMPOSITION_SESSION_END_EVENT, handleSessionEnd)