fix(terminal): track latest IME placeholder session

This commit is contained in:
Neil
2026-08-29 20:41:59 -07:00
parent 364d6c670e
commit d7525aa085
2 changed files with 60 additions and 21 deletions
@@ -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)
})
})
@@ -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<number>()
// 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)