mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
* fix(terminal): reset stale mouse tracking on cold restore (#12101); stop OSC color-reply echo leak in POSIX agent panes (#12112) #12101: a force-killed TUI never emits its DECRST reset, so its armed mouse mode is latched into the on-disk checkpoint and re-derived into the replacement process's emulator via the cold-restore history seed -- through both rehydrateSequences and SerializeAddon's own mode trailer. The revived bare shell then echoed SGR motion reports at the prompt. Seed a RESET_MOUSE_REPORTING segment after the snapshot (before the torn escape tail), only when there is real recovered content so the empty-array "nothing to recover" sentinel survives. #12112: agent panes arm a main-side PtyStartupIngress that answered opencode's startup OSC 10/11 queries synchronously inside node-pty's onData, while the POSIX tty still had ECHO on. The line discipline echoed Orca's own reply back out as visible text. Echo suppression existed but was gated on windows-conpty. Add PtyStartupReplyDelivery: POSIX defers the write off the query's turn and recognizes its own echo anywhere in a span (bounded, non-destructive); ConPTY keeps its synchronous write; windows-wsl is byte-identical to before. Fixes #12101 Co-authored-by: Orca <help@stably.ai> * fix(terminal): read the slave's ECHO bit before answering a color query The startup color reply was written into a PTY still in cooked mode, so the line discipline echoed it back as visible junk (#12112). Whether that will happen is readable state on the slave rather than something to infer from returning bytes, so the reply now waits until the ECHO bit is observably clear instead of guessing at echo shapes. Two echo sources exist and only one is readable. A `quiet` verdict proves the kernel will not echo, so it retires the caret projection; readline echoes a master write in software with the tty already raw, so that projection stays armed on every path. Scoping `quiet` narrowly is the whole correctness argument here: reading it as "no suppression needed" reintroduces the bug at a plain shell prompt. Polling is bounded by a wall-clock budget rather than an attempt count, because each probe is a subprocess and a multi-pane restore serializes them on fork. Withholding measures flat at ~210ms from 1 to 100 panes. Also resets a cold-restored pane's mouse reporting (#12101). The armed mode is re-derived from the dead process's own persisted bytes through two channels, so the daemon seeds a reset into recovered history and the renderer stops trusting a persisted "live agent" signal after a cold restore. The reset literals move to one shared profile module. Fixes #12101 Fixes #12112 Co-authored-by: Orca <help@stably.ai> * test(terminal): pin the cold-restore reset on the spawn-adopted reattach path A spawn can be answered with an adopted session, which reaches the reattach handler by a door that skips the restored-session path. Pin that the cold-restore signal survives it, so #12101's junk cannot come back through it. Co-authored-by: Orca <help@stably.ai> * test(terminal): note why the adopted-reattach snapshot leaves the cursor visible Co-authored-by: Orca <help@stably.ai> * fix(terminal): harden startup reply delivery --------- Co-authored-by: Orca <help@stably.ai>
487 lines
18 KiB
TypeScript
487 lines
18 KiB
TypeScript
import type { Page } from '@stablyai/playwright-test'
|
|
import { test, expect } from './helpers/orca-app'
|
|
import {
|
|
execInTerminal,
|
|
waitForActivePanePtyId,
|
|
waitForActiveTerminalManager
|
|
} from './helpers/terminal'
|
|
import {
|
|
ensureTerminalVisible,
|
|
getActiveTabId,
|
|
waitForActiveWorktree,
|
|
waitForSessionReady
|
|
} from './helpers/store'
|
|
import { getRendererTitleLog, installRendererTitleLog } from './helpers/terminal-title-log'
|
|
import { POST_REPLAY_MODE_RESET } from '../../src/shared/terminal-mode-reset-profiles'
|
|
import { waitForPtyShellEcho } from './terminal-pty-readiness'
|
|
|
|
test.describe.configure({ mode: 'serial' })
|
|
|
|
async function countRenderedTabs(page: Page): Promise<number> {
|
|
return page.locator('[data-testid="sortable-tab"]').count()
|
|
}
|
|
|
|
async function createTerminalTab(page: Page): Promise<string> {
|
|
const tabsBefore = await countRenderedTabs(page)
|
|
const activeBefore = await getActiveTabId(page)
|
|
|
|
await page.getByRole('button', { name: 'New tab' }).click()
|
|
await page
|
|
.getByRole('menuitem', { name: /New Terminal/i })
|
|
.first()
|
|
.click()
|
|
|
|
await expect
|
|
.poll(() => countRenderedTabs(page), {
|
|
timeout: 5_000,
|
|
message: 'New Terminal did not render a new tab in the tab bar'
|
|
})
|
|
.toBe(tabsBefore + 1)
|
|
|
|
let tabId: string | null = null
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
tabId = await getActiveTabId(page)
|
|
return Boolean(tabId && tabId !== activeBefore)
|
|
},
|
|
{
|
|
timeout: 5_000,
|
|
message: 'New Terminal did not become the active tab'
|
|
}
|
|
)
|
|
.toBe(true)
|
|
|
|
if (!tabId) {
|
|
throw new Error('createTerminalTab: active tab id was unavailable after creating terminal')
|
|
}
|
|
|
|
return tabId
|
|
}
|
|
|
|
async function activateTerminalTab(page: Page, tabId: string): Promise<void> {
|
|
await page.evaluate((targetTabId) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
throw new Error('activateTerminalTab: window.__store is unavailable')
|
|
}
|
|
const state = store.getState()
|
|
state.setActiveTabType('terminal')
|
|
state.setActiveTab(targetTabId)
|
|
}, tabId)
|
|
|
|
await expect
|
|
.poll(async () => getActiveTabId(page), {
|
|
timeout: 5_000,
|
|
message: `Terminal tab ${tabId} did not become active`
|
|
})
|
|
.toBe(tabId)
|
|
}
|
|
|
|
async function emitBellAndWaitForTitleFlush(
|
|
page: Page,
|
|
ptyId: string,
|
|
markerTitle: string
|
|
): Promise<void> {
|
|
await waitForPtyShellEcho(page, ptyId, 30_000)
|
|
// Why: the OSC title marker is a deterministic byte-stream fence. Once it
|
|
// lands in the renderer, the preceding BEL has traversed the same PTY path.
|
|
// printf is a shell builtin, so this still works in stripped CI PATHs.
|
|
await execInTerminal(page, ptyId, `printf '\\a\\033]0;${markerTitle}\\007'`)
|
|
await expect
|
|
.poll(async () => (await getRendererTitleLog(page)).includes(markerTitle), {
|
|
timeout: 10_000,
|
|
message: 'Marker title did not land — byte stream may not have been flushed'
|
|
})
|
|
.toBe(true)
|
|
}
|
|
|
|
async function getUnreadTerminalTabIds(page: Page): Promise<string[]> {
|
|
return page.evaluate(() => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return []
|
|
}
|
|
return Object.keys(store.getState().unreadTerminalTabs)
|
|
})
|
|
}
|
|
|
|
async function getUnreadTerminalPaneKeys(page: Page): Promise<string[]> {
|
|
return page.evaluate(() => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
return []
|
|
}
|
|
return Object.keys(store.getState().unreadTerminalPanes)
|
|
})
|
|
}
|
|
|
|
async function getActivePaneKey(page: Page, tabId: string): Promise<string> {
|
|
return page.evaluate((targetTabId) => {
|
|
const manager = window.__paneManagers?.get(targetTabId)
|
|
const pane = manager?.getActivePane?.()
|
|
const leafId = pane?.leafId ?? null
|
|
if (!leafId) {
|
|
throw new Error(`No active pane leaf for terminal tab ${targetTabId}`)
|
|
}
|
|
return `${targetTabId}:${leafId}`
|
|
}, tabId)
|
|
}
|
|
|
|
async function focusActiveXterm(page: Page, tabId: string): Promise<void> {
|
|
await page.evaluate((targetTabId) => {
|
|
const manager = window.__paneManagers?.get(targetTabId)
|
|
const pane = manager?.getActivePane?.()
|
|
const textarea = pane?.container.querySelector<HTMLTextAreaElement>('.xterm-helper-textarea')
|
|
if (!pane || !textarea) {
|
|
throw new Error(`No active xterm textarea for terminal tab ${targetTabId}`)
|
|
}
|
|
pane.terminal.focus()
|
|
textarea.focus()
|
|
}, tabId)
|
|
|
|
await expect
|
|
.poll(
|
|
() =>
|
|
page.evaluate(
|
|
() => document.activeElement?.classList.contains('xterm-helper-textarea') ?? false
|
|
),
|
|
{
|
|
timeout: 5_000,
|
|
message: 'xterm helper textarea did not receive keyboard focus'
|
|
}
|
|
)
|
|
.toBe(true)
|
|
}
|
|
|
|
test.describe('Terminal attention', () => {
|
|
// Why: pty-connection unit tests own raw BEL-byte detection. This E2E owns
|
|
// the cross-component attention contract: background terminal attention
|
|
// raises the tab indicator, and focusing the tab clears it.
|
|
test('background terminal attention marks a tab unread and clears on focus', async ({
|
|
orcaPage
|
|
}) => {
|
|
await waitForSessionReady(orcaPage)
|
|
await waitForActiveWorktree(orcaPage)
|
|
await ensureTerminalVisible(orcaPage)
|
|
await waitForActiveTerminalManager(orcaPage, 30_000)
|
|
|
|
const firstTabId = await getActiveTabId(orcaPage)
|
|
if (!firstTabId) {
|
|
throw new Error('Expected an initial terminal tab')
|
|
}
|
|
|
|
const secondTabId = await createTerminalTab(orcaPage)
|
|
await waitForActiveTerminalManager(orcaPage, 30_000)
|
|
|
|
// Focus the first tab so the second becomes a background tab; attention
|
|
// arriving there should raise its indicator.
|
|
await activateTerminalTab(orcaPage, firstTabId)
|
|
await orcaPage.evaluate((tabId) => {
|
|
const store = window.__store
|
|
if (!store) {
|
|
throw new Error('window.__store is unavailable')
|
|
}
|
|
const state = store.getState()
|
|
const ownerWorktreeId =
|
|
Object.entries(state.tabsByWorktree).find(([, tabs]) =>
|
|
tabs.some((tab) => tab.id === tabId)
|
|
)?.[0] ?? null
|
|
if (!ownerWorktreeId) {
|
|
throw new Error(`No owner worktree found for terminal tab ${tabId}`)
|
|
}
|
|
state.markWorktreeUnread(ownerWorktreeId)
|
|
state.markTerminalTabUnread(tabId)
|
|
}, secondTabId)
|
|
|
|
await expect
|
|
.poll(async () => (await getUnreadTerminalTabIds(orcaPage)).includes(secondTabId), {
|
|
timeout: 10_000,
|
|
message: 'Background tab did not become unread after BEL'
|
|
})
|
|
.toBe(true)
|
|
|
|
const secondTabBell = orcaPage
|
|
.locator(
|
|
`[data-testid="sortable-tab"][data-tab-id="${secondTabId}"] [data-testid="tab-activity-bell"]`
|
|
)
|
|
.first()
|
|
await expect(secondTabBell).toBeVisible()
|
|
|
|
// Activating the tab counts as "the user saw it" — the indicator clears.
|
|
await activateTerminalTab(orcaPage, secondTabId)
|
|
|
|
await expect
|
|
.poll(async () => (await getUnreadTerminalTabIds(orcaPage)).includes(secondTabId), {
|
|
timeout: 5_000,
|
|
message: 'Unread state did not clear when the user focused the tab'
|
|
})
|
|
.toBe(false)
|
|
await expect(secondTabBell).toBeHidden()
|
|
})
|
|
|
|
// Why (show-until-interact): ghostty's model fires the bell even on the
|
|
// currently-focused tab — the user only dismisses it by actually engaging
|
|
// with the pane. This test proves the BEL on a focused tab is visible
|
|
// until a pointerdown on the terminal container clears it.
|
|
test('a BEL on the focused tab raises, then clears on click', async ({ orcaPage }) => {
|
|
await waitForSessionReady(orcaPage)
|
|
await waitForActiveWorktree(orcaPage)
|
|
await ensureTerminalVisible(orcaPage)
|
|
await waitForActiveTerminalManager(orcaPage, 30_000)
|
|
|
|
const activeTabId = await getActiveTabId(orcaPage)
|
|
if (!activeTabId) {
|
|
throw new Error('Expected an active terminal tab')
|
|
}
|
|
const activePtyId = await waitForActivePanePtyId(orcaPage)
|
|
await installRendererTitleLog(orcaPage)
|
|
|
|
await emitBellAndWaitForTitleFlush(
|
|
orcaPage,
|
|
activePtyId,
|
|
`focused-tab-bell-marker-${Date.now()}`
|
|
)
|
|
|
|
// The focused tab is now unread — the bell persists until the user
|
|
// actually interacts with the pane.
|
|
expect((await getUnreadTerminalTabIds(orcaPage)).includes(activeTabId)).toBe(true)
|
|
const activeTabBell = orcaPage
|
|
.locator(
|
|
`[data-testid="sortable-tab"][data-tab-id="${activeTabId}"] [data-testid="tab-activity-bell"]`
|
|
)
|
|
.first()
|
|
await expect(activeTabBell).toBeVisible()
|
|
|
|
// A pointerdown inside the terminal container counts as interaction
|
|
// (matches the pointerdown handler added in TerminalPane.tsx). Drive it
|
|
// via the DOM so we exercise the real listener path rather than bypassing
|
|
// to the store action.
|
|
await orcaPage.evaluate((tabId) => {
|
|
const managers = window.__paneManagers
|
|
const manager = managers?.get(tabId)
|
|
const pane = manager?.getActivePane()
|
|
const container = pane?.container
|
|
if (!container) {
|
|
throw new Error('No active pane container to click')
|
|
}
|
|
container.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true, cancelable: true }))
|
|
}, activeTabId)
|
|
|
|
await expect
|
|
.poll(async () => (await getUnreadTerminalTabIds(orcaPage)).includes(activeTabId), {
|
|
timeout: 5_000,
|
|
message: 'Unread state did not clear after interacting with the pane'
|
|
})
|
|
.toBe(false)
|
|
await expect(activeTabBell).toBeHidden()
|
|
})
|
|
|
|
// Why (plain Escape regression): Escape also emits real terminal input, but
|
|
// the interrupt-intent branch returns early. It must still dismiss focused
|
|
// terminal attention just like other user key input.
|
|
test('a BEL on the focused tab raises, then clears on plain Escape', async ({ orcaPage }) => {
|
|
await waitForSessionReady(orcaPage)
|
|
await waitForActiveWorktree(orcaPage)
|
|
await ensureTerminalVisible(orcaPage)
|
|
await waitForActiveTerminalManager(orcaPage, 30_000)
|
|
|
|
const activeTabId = await getActiveTabId(orcaPage)
|
|
if (!activeTabId) {
|
|
throw new Error('Expected an active terminal tab')
|
|
}
|
|
const activePaneKey = await getActivePaneKey(orcaPage, activeTabId)
|
|
const activePtyId = await waitForActivePanePtyId(orcaPage)
|
|
await installRendererTitleLog(orcaPage)
|
|
|
|
await emitBellAndWaitForTitleFlush(
|
|
orcaPage,
|
|
activePtyId,
|
|
`focused-tab-escape-marker-${Date.now()}`
|
|
)
|
|
|
|
await expect
|
|
.poll(async () => (await getUnreadTerminalTabIds(orcaPage)).includes(activeTabId), {
|
|
timeout: 10_000,
|
|
message: 'Focused tab did not become unread after BEL'
|
|
})
|
|
.toBe(true)
|
|
|
|
// Focused BEL owns the tab indicator; seed pane attention separately so the
|
|
// Escape path proves it clears both store surfaces that pty-connection owns.
|
|
await orcaPage.evaluate((paneKey) => {
|
|
window.__store?.getState().markTerminalPaneUnread(paneKey)
|
|
}, activePaneKey)
|
|
await expect
|
|
.poll(async () => (await getUnreadTerminalPaneKeys(orcaPage)).includes(activePaneKey), {
|
|
timeout: 5_000,
|
|
message: 'Seeded focused pane attention did not land'
|
|
})
|
|
.toBe(true)
|
|
|
|
const activeTabBell = orcaPage
|
|
.locator(
|
|
`[data-testid="sortable-tab"][data-tab-id="${activeTabId}"] [data-testid="tab-activity-bell"]`
|
|
)
|
|
.first()
|
|
await expect(activeTabBell).toBeVisible()
|
|
|
|
await focusActiveXterm(orcaPage, activeTabId)
|
|
await orcaPage.keyboard.press('Escape')
|
|
|
|
await expect
|
|
.poll(async () => (await getUnreadTerminalTabIds(orcaPage)).includes(activeTabId), {
|
|
timeout: 5_000,
|
|
message: 'Unread tab state did not clear after pressing Escape in xterm'
|
|
})
|
|
.toBe(false)
|
|
await expect
|
|
.poll(async () => (await getUnreadTerminalPaneKeys(orcaPage)).includes(activePaneKey), {
|
|
timeout: 5_000,
|
|
message: 'Unread pane state did not clear after pressing Escape in xterm'
|
|
})
|
|
.toBe(false)
|
|
await expect(activeTabBell).toBeHidden()
|
|
})
|
|
|
|
// Why (restart regression guard): the original user-reported bug was that
|
|
// after restarting Orca with a Claude Code session open, clicking between
|
|
// panes on the restored tab produced undismissable bell indicators. Root
|
|
// cause: xterm's SerializeAddon captures the TUI's mode-setting bytes
|
|
// (e.g. `\e[?1004h` for focus reporting) in the scrollback snapshot, and
|
|
// replaying that snapshot on restart re-enables focus reporting in xterm
|
|
// even though the underlying shell is fresh. Pane clicks then emit
|
|
// `\e[I` / `\e[O` into zsh, which rings the bell as unbound-key input.
|
|
//
|
|
// POST_REPLAY_MODE_RESET (in shared/terminal-mode-reset-profiles.ts) clears these mode
|
|
// bits after every scrollback replay so the mode state matches the fresh
|
|
// shell. This test pins that fix: after writing a DECSET 1004 byte into
|
|
// the terminal, focus events should NOT be emitted back to the PTY.
|
|
//
|
|
// We drive xterm directly with the focus-enable escape (simulating what
|
|
// the replay would do) and then simulate focus changes — without the
|
|
// reset, xterm would dutifully emit focus escapes; with the reset, mode
|
|
// 1004 is off and nothing leaks to the shell, so no BELs fire.
|
|
test('mode bits replayed into xterm do not leak focus escapes to the shell', async ({
|
|
orcaPage
|
|
}) => {
|
|
await waitForSessionReady(orcaPage)
|
|
await waitForActiveWorktree(orcaPage)
|
|
await ensureTerminalVisible(orcaPage)
|
|
await waitForActiveTerminalManager(orcaPage, 30_000)
|
|
|
|
const firstTabId = await getActiveTabId(orcaPage)
|
|
if (!firstTabId) {
|
|
throw new Error('Expected an initial terminal tab')
|
|
}
|
|
|
|
const secondTabId = await createTerminalTab(orcaPage)
|
|
await waitForActiveTerminalManager(orcaPage, 30_000)
|
|
|
|
// secondTabId is already active after createTerminalTab. Simulate what
|
|
// scrollback replay does: a DECSET 1004 byte landing in xterm. Then
|
|
// install an onData spy so we can observe everything xterm emits from
|
|
// this point on — crucially, the focus escapes `\e[I` / `\e[O` that
|
|
// leak when mode 1004 is still enabled. The POST_REPLAY_MODE_RESET
|
|
// bundle should turn mode 1004 OFF; if it does, no focus escape is
|
|
// emitted on the next blur and the spy's buffer stays empty.
|
|
// Why: xterm's parser is async — bytes passed to `write()` are queued and
|
|
// consumed on a later tick. During the brief window when mode 1004 is
|
|
// enabled, xterm emits a synchronous focus-IN (`\e[I`) because the
|
|
// terminal is focused; that emission MUST NOT land in the spy or the
|
|
// assertion below will false-positive even when the post-replay reset
|
|
// worked correctly.
|
|
//
|
|
// We use xterm's `write(data, callback)` overload: the callback fires
|
|
// AFTER the parser has consumed that write. By installing the spy inside
|
|
// the callback for the POST_REPLAY_MODE_RESET write, we guarantee any
|
|
// transient focus escapes emitted while mode 1004 was briefly on have
|
|
// already fired before the spy exists. No fixed sleep needed.
|
|
await orcaPage.evaluate(
|
|
({ tabId, modeReset }) =>
|
|
new Promise<void>((resolve, reject) => {
|
|
const managers = window.__paneManagers
|
|
const manager = managers?.get(tabId)
|
|
const pane = manager?.getActivePane()
|
|
if (!pane) {
|
|
reject(new Error('No active pane on restored tab'))
|
|
return
|
|
}
|
|
pane.terminal.write('\x1b[?1004h')
|
|
pane.terminal.write(modeReset, () => {
|
|
// Parser has consumed both the DECSET and the reset. Any focus
|
|
// escapes from the brief 1004-ON window have already been emitted
|
|
// (and dropped on the floor, since nothing was listening). Install
|
|
// the spy now to observe only post-reset output.
|
|
const recorded: string[] = []
|
|
;(window as unknown as { __XTERM_ONDATA_SPY__: string[] }).__XTERM_ONDATA_SPY__ =
|
|
recorded
|
|
const disposer = pane.terminal.onData((data) => {
|
|
recorded.push(data)
|
|
})
|
|
;(
|
|
window as unknown as { __XTERM_ONDATA_DISPOSE__?: () => void }
|
|
).__XTERM_ONDATA_DISPOSE__ = () => disposer.dispose()
|
|
resolve()
|
|
})
|
|
}),
|
|
{ tabId: secondTabId, modeReset: POST_REPLAY_MODE_RESET }
|
|
)
|
|
|
|
// Why (try/finally): the onData spy + disposer live on window globals on
|
|
// the shared renderer. If any assertion below throws, we still MUST tear
|
|
// down the spy so it doesn't leak into subsequent tests (which would see
|
|
// stale captured data and/or a dangling xterm onData subscription).
|
|
try {
|
|
// Trigger focus change away from secondTabId. If mode 1004 is still
|
|
// enabled, xterm will emit `\e[O` via onData — captured by the spy above.
|
|
// Also explicitly blur the xterm instance so the DOM focus actually moves
|
|
// (setActiveTab alone doesn't blur focus).
|
|
await activateTerminalTab(orcaPage, firstTabId)
|
|
await orcaPage.evaluate((tabId) => {
|
|
const managers = window.__paneManagers
|
|
const manager = managers?.get(tabId)
|
|
const pane = manager?.getActivePane()
|
|
if (!pane) {
|
|
return
|
|
}
|
|
pane.terminal.blur()
|
|
}, secondTabId)
|
|
|
|
// Why: xterm does not reliably answer DA1 writes in hidden Electron
|
|
// windows, but focus-reporting leaks are emitted as part of the focus
|
|
// task itself. Let that task settle, then inspect the captured bytes.
|
|
await orcaPage.waitForTimeout(100)
|
|
|
|
// Mode 1004 reset succeeded iff no focus escapes are emitted — we assert
|
|
// on the precise byte-level mechanism the fix guards against (`\e[I`
|
|
// focus-in / `\e[O` focus-out), not tab unread state, because under the
|
|
// show-until-interact model that state can be flipped by unrelated
|
|
// shell-startup BELs.
|
|
const emittedFromXterm = await orcaPage.evaluate(
|
|
() =>
|
|
(window as unknown as { __XTERM_ONDATA_SPY__: string[] | undefined })
|
|
.__XTERM_ONDATA_SPY__ ?? []
|
|
)
|
|
// Join before matching: individual chunks could split an escape
|
|
// across onData calls (unlikely but possible — e.g. if xterm
|
|
// flushes mid-escape).
|
|
// eslint-disable-next-line no-control-regex -- intentional terminal escape sequence matching
|
|
expect(emittedFromXterm.join('')).not.toMatch(/\x1b\[[IO]/)
|
|
} finally {
|
|
// Dispose the onData subscription and clear the globals so nothing leaks
|
|
// across tests on the shared renderer. Runs even if an assertion above
|
|
// failed.
|
|
await orcaPage.evaluate(() => {
|
|
const w = window as unknown as {
|
|
__XTERM_ONDATA_DISPOSE__?: () => void
|
|
__XTERM_ONDATA_SPY__?: string[]
|
|
}
|
|
w.__XTERM_ONDATA_DISPOSE__?.()
|
|
delete w.__XTERM_ONDATA_DISPOSE__
|
|
delete w.__XTERM_ONDATA_SPY__
|
|
})
|
|
}
|
|
})
|
|
})
|