fix(terminal): keep a live TUI's mouse modes across daemon reattach (#12461)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
15ef69a814
commit
aca5e8b5b1
|
|
@ -8,6 +8,7 @@ import {
|
|||
POST_REPLAY_LIVE_SNAPSHOT_RESET,
|
||||
POST_REPLAY_MODE_RESET,
|
||||
POST_REPLAY_REATTACH_RESET,
|
||||
POST_REPLAY_REATTACH_RESET_KEEP_MOUSE,
|
||||
RESET_KITTY_KEYBOARD_PROTOCOL,
|
||||
RESET_TERMINAL_CURSOR_STYLE
|
||||
} from '../../../../shared/terminal-mode-reset-profiles'
|
||||
|
|
@ -9235,6 +9236,49 @@ describe('connectPanePty', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// Why: issue #8291 — the reattach reset wiped the mouse modes the daemon snapshot had just
|
||||
// rehydrated, so xterm re-enabled its row-wise selection over a still-running TUI.
|
||||
const reattachSnapshotResetFor = async (snapshot: string): Promise<string | undefined> => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('tab-pty')
|
||||
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) =>
|
||||
sessionId ? { id: sessionId, snapshot } : null
|
||||
)
|
||||
transportFactoryQueue.push(transport)
|
||||
setReattachPaneTitle('zsh')
|
||||
|
||||
const pane = createPane(1)
|
||||
const textarea = {} as HTMLTextAreaElement
|
||||
configureTerminalFocusMode(pane, textarea)
|
||||
return withMockedDocumentActiveElement(textarea, async () => {
|
||||
const manager = createManager(1)
|
||||
const deps = createDeps({
|
||||
restoredLeafId: LEAF_1,
|
||||
restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' }
|
||||
})
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
await flushAsyncTicks(20)
|
||||
return (pane.terminal.write as ReturnType<typeof vi.fn>).mock.calls
|
||||
.map((call) => String(call[0]))
|
||||
.find(
|
||||
(data) =>
|
||||
data === POST_REPLAY_REATTACH_RESET || data === POST_REPLAY_REATTACH_RESET_KEEP_MOUSE
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
it('keeps mouse reporting when a reattach snapshot restores a live alternate-screen TUI', async () => {
|
||||
await expect(
|
||||
reattachSnapshotResetFor('\x1b[?1049h\x1b[?1002h\x1b[?1006hthird-party tui session')
|
||||
).resolves.toBe(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE)
|
||||
})
|
||||
|
||||
it('still disarms mouse reporting when a reattach snapshot ends on the normal buffer', async () => {
|
||||
await expect(reattachSnapshotResetFor('\x1b[?1003h\x1b[?1006hdead tui residue')).resolves.toBe(
|
||||
POST_REPLAY_REATTACH_RESET
|
||||
)
|
||||
})
|
||||
|
||||
it('does not treat persisted tab launchAgent metadata as a live agent reattach', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('tab-pty')
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ import {
|
|||
POST_REPLAY_LIVE_SNAPSHOT_RESET,
|
||||
POST_REPLAY_MODE_RESET,
|
||||
POST_REPLAY_REATTACH_RESET,
|
||||
POST_REPLAY_REATTACH_RESET_KEEP_MOUSE,
|
||||
RESET_KITTY_KEYBOARD_PROTOCOL,
|
||||
RESET_TERMINAL_CURSOR_STYLE
|
||||
} from '../../../../shared/terminal-mode-reset-profiles'
|
||||
|
|
@ -5528,7 +5529,11 @@ export function connectPanePty(
|
|||
})
|
||||
}
|
||||
|
||||
const reattachReplayResetSequence = (payload: string, ownerProcessEnded = false): string => {
|
||||
const reattachReplayResetSequence = (
|
||||
payload: string,
|
||||
ownerProcessEnded = false,
|
||||
isAlternateScreen?: boolean
|
||||
): string => {
|
||||
// Why a cold restore overrides the agent signal: liveness is read from the
|
||||
// pane's status and title, both of which are persisted, so after a cold
|
||||
// restore they describe the process that died. Preserving "its" modes arms
|
||||
|
|
@ -5537,8 +5542,13 @@ export function connectPanePty(
|
|||
if (ownerProcessEnded) {
|
||||
return POST_REPLAY_MODE_RESET
|
||||
}
|
||||
return shouldPreserveAgentReattachModes()
|
||||
? buildPostReplayLiveAgentReattachReset(payload)
|
||||
if (shouldPreserveAgentReattachModes()) {
|
||||
return buildPostReplayLiveAgentReattachReset(payload)
|
||||
}
|
||||
// Why: an alt-screen pane is a live TUI Orca just does not recognise as an agent, and the
|
||||
// replay already re-armed its mouse modes — keep them instead of wiping them (#8291).
|
||||
return (isAlternateScreen ?? kittyKeyboardModes.isAlternateScreen)
|
||||
? POST_REPLAY_REATTACH_RESET_KEEP_MOUSE
|
||||
: POST_REPLAY_REATTACH_RESET
|
||||
}
|
||||
|
||||
|
|
@ -8056,7 +8066,11 @@ export function connectPanePty(
|
|||
writeReplayData(connectResult.snapshot)
|
||||
// Snapshot reattach keeps a live session, so drop only renderer-owned state instead of the broader mode reset — unless this is a cold restore, whose owner is gone.
|
||||
writeReplayData(
|
||||
reattachReplayResetSequence(connectResult.snapshot, Boolean(connectResult.coldRestore))
|
||||
reattachReplayResetSequence(
|
||||
connectResult.snapshot,
|
||||
Boolean(connectResult.coldRestore),
|
||||
connectResult.isAlternateScreen
|
||||
)
|
||||
)
|
||||
if (connectResult.pendingEscapeTailAnsi) {
|
||||
// Why last: re-arm the dangling mid-escape after the reset (whose ESC would abort it) so the live continuation completes it (#7329).
|
||||
|
|
@ -8111,7 +8125,11 @@ export function connectPanePty(
|
|||
writeReplayData(replayChunk)
|
||||
}
|
||||
writeReplayData(
|
||||
reattachReplayResetSequence(modelData, Boolean(connectResult?.coldRestore))
|
||||
reattachReplayResetSequence(
|
||||
modelData,
|
||||
Boolean(connectResult?.coldRestore),
|
||||
modelSnapshot.alternateScreen ?? connectResult?.isAlternateScreen
|
||||
)
|
||||
)
|
||||
if (modelSnapshot.pendingEscapeTailAnsi) {
|
||||
// Why last: re-arm the dangling mid-escape after the reset so the live continuation completes it (#7329).
|
||||
|
|
@ -8132,7 +8150,11 @@ export function connectPanePty(
|
|||
kittyKeyboardModes.scanReplay(connectResult.replay)
|
||||
writeReplayData(connectResult.replay)
|
||||
writeReplayData(
|
||||
reattachReplayResetSequence(connectResult.replay, Boolean(connectResult.coldRestore))
|
||||
reattachReplayResetSequence(
|
||||
connectResult.replay,
|
||||
Boolean(connectResult.coldRestore),
|
||||
connectResult.isAlternateScreen
|
||||
)
|
||||
)
|
||||
sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration)
|
||||
if (connectResult.coldRestore) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ export const POST_REPLAY_LIVE_SNAPSHOT_RESET = `${RESET_TERMINAL_CURSOR_STYLE}\x
|
|||
// Why: daemon reattach hits a live session, so skip the full reset; still clear cursor/focus/mouse/Kitty bits harmful to a plain shell after a bad TUI exit — safe for live TUIs since the post-reattach SIGWINCH repaints the cursor.
|
||||
export const POST_REPLAY_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h${RESET_MOUSE_REPORTING}\x1b[?1004l`
|
||||
|
||||
// Why: an alt-screen reattach replays the daemon's rehydrateSequences, which re-arm the live TUI's
|
||||
// mouse modes; wiping them one write later hands drags back to xterm's row selection (#8291).
|
||||
// Normal-buffer panes keep RESET_MOUSE_REPORTING so a dead TUI's stale modes never reach a shell (#7893).
|
||||
export const POST_REPLAY_REATTACH_RESET_KEEP_MOUSE = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h\x1b[?1004l`
|
||||
|
||||
// Why: a live agent owns focus reporting; resetting ?1004h suppresses the focus-in it needs to re-anchor its cursor (IME).
|
||||
export const POST_REPLAY_LIVE_AGENT_REATTACH_RESET = `${RESET_TERMINAL_CURSOR_STYLE}${RESET_KITTY_KEYBOARD_PROTOCOL}\x1b[?25h`
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,259 @@
|
|||
/**
|
||||
* Regression proof for #8291: a real alt-screen TUI survives an Orca quit/relaunch, and after the
|
||||
* warm reattach a drag over it must still go to the TUI as mouse reports, not to xterm's row
|
||||
* selection. Drives the rendered surface only — no mocks, no direct mode assertions.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import {
|
||||
execInTerminal,
|
||||
sendToTerminal,
|
||||
waitForActivePanePtyId,
|
||||
waitForActiveTerminalManager,
|
||||
waitForPaneCount
|
||||
} from './helpers/terminal'
|
||||
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart'
|
||||
|
||||
const VISIBLE_TUI_FIXTURE_PATH = path.join(
|
||||
process.cwd(),
|
||||
'tests/e2e/fixtures/visible-tui-scroll-fixture.cjs'
|
||||
)
|
||||
|
||||
// One SGR wheel-down report, as the fixture's stdin parser expects it. Doubles as a settle beacon:
|
||||
// only post-relaunch bytes can repaint `offset=1`, so seeing it proves the reattach replay landed.
|
||||
const WHEEL_DOWN_REPORT = '\x1b[<65;10;10M'
|
||||
|
||||
// Why not the shared seeded repo: a concurrent e2e globalTeardown deletes whatever repo the
|
||||
// machine-global pointer file names, which could be this one mid-restart.
|
||||
function createIsolatedProofRepo(): string {
|
||||
// Why realpathSync: macOS tmpdir symlinks through /private and Orca canonicalizes repo.path.
|
||||
const repoDir = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-mouse-reattach-repo-')))
|
||||
const git = (...args: string[]): void => {
|
||||
execFileSync('git', args, { cwd: repoDir, stdio: 'pipe' })
|
||||
}
|
||||
git('init', '-q')
|
||||
git('config', 'user.email', 'e2e@test.local')
|
||||
git('config', 'user.name', 'E2E Test')
|
||||
writeFileSync(path.join(repoDir, 'README.md'), '# Orca mouse-mode reattach proof repo\n')
|
||||
git('add', '-A')
|
||||
git('commit', '-q', '-m', 'Seed commit for the reattach mouse-mode proof')
|
||||
return repoDir
|
||||
}
|
||||
|
||||
type TerminalSurface = {
|
||||
mouseEventsClass: boolean
|
||||
mouseTrackingMode: string
|
||||
hasSelection: boolean
|
||||
selectionText: string
|
||||
visibleText: string
|
||||
screen: { left: number; top: number; width: number; height: number; cellHeight: number }
|
||||
}
|
||||
|
||||
// Why one evaluate for everything: it flushes xterm's write queue first, so a caller can never
|
||||
// sample mode/selection state mid-replay.
|
||||
async function readTerminalSurface(page: Page): Promise<TerminalSurface | null> {
|
||||
return page.evaluate(async () => {
|
||||
const state = window.__store?.getState()
|
||||
const worktreeId = state?.activeWorktreeId
|
||||
const tabId =
|
||||
state?.activeTabType === 'terminal'
|
||||
? state.activeTabId
|
||||
: worktreeId
|
||||
? (state?.activeTabIdByWorktree?.[worktreeId] ?? null)
|
||||
: null
|
||||
const manager = tabId ? window.__paneManagers?.get(tabId) : null
|
||||
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
|
||||
const element = pane?.terminal?.element ?? null
|
||||
const screenElement = element?.querySelector<HTMLElement>('.xterm-screen') ?? null
|
||||
if (!pane || !element || !screenElement) {
|
||||
return null
|
||||
}
|
||||
// Why a zero-length write: xterm's write queue is FIFO, so this callback fires only after
|
||||
// every earlier replay/reset write was parsed.
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, 3000)
|
||||
pane.terminal.write('', () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
const buffer = pane.terminal.buffer.active
|
||||
const lines: string[] = []
|
||||
for (let row = 0; row < pane.terminal.rows; row += 1) {
|
||||
lines.push(buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? '')
|
||||
}
|
||||
const rect = screenElement.getBoundingClientRect()
|
||||
return {
|
||||
mouseEventsClass: element.classList.contains('enable-mouse-events'),
|
||||
mouseTrackingMode: String(pane.terminal.modes?.mouseTrackingMode ?? 'unavailable'),
|
||||
hasSelection: pane.terminal.hasSelection(),
|
||||
selectionText: pane.terminal.getSelection(),
|
||||
visibleText: lines.join('\n'),
|
||||
screen: {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
cellHeight: rect.height / Math.max(1, pane.terminal.rows)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForTerminalSurface(
|
||||
page: Page,
|
||||
predicate: (surface: TerminalSurface) => boolean,
|
||||
message: string,
|
||||
timeoutMs = 30_000
|
||||
): Promise<TerminalSurface> {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const surface = await readTerminalSurface(page)
|
||||
return surface !== null && predicate(surface)
|
||||
},
|
||||
{ timeout: timeoutMs, message }
|
||||
)
|
||||
.toBe(true)
|
||||
const surface = await readTerminalSurface(page)
|
||||
if (!surface) {
|
||||
throw new Error(`${message}: terminal surface disappeared after settling`)
|
||||
}
|
||||
return surface
|
||||
}
|
||||
|
||||
function readRenderedTuiOffset(visibleText: string): number | null {
|
||||
const match = /TUI_SCROLL_READY offset=(\d+)/.exec(visibleText)
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
/** Real CDP drag across three TUI rows — the gesture from the bug report. */
|
||||
async function dragAcrossTuiRows(page: Page, screen: TerminalSurface['screen']): Promise<void> {
|
||||
const startX = screen.left + Math.min(24, screen.width / 4)
|
||||
const startY = screen.top + screen.cellHeight * 2.5
|
||||
const endX = screen.left + screen.width * 0.6
|
||||
const endY = startY + screen.cellHeight * 3
|
||||
await page.mouse.move(startX, startY)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(endX, endY, { steps: 12 })
|
||||
await page.mouse.up()
|
||||
}
|
||||
|
||||
// Why: this suite quits and relaunches Orca against one userDataDir, and the
|
||||
// second launch must find the daemon (and the TUI it owns) still alive.
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
test.describe('terminal reattach mouse mode', () => {
|
||||
test('live TUI keeps mouse tracking after an app restart reattach instead of falling back to drag selection', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set.
|
||||
{}, testInfo) => {
|
||||
const repoPath = createIsolatedProofRepo()
|
||||
const session = createRestartSession(testInfo)
|
||||
let firstApp: ElectronApplication | null = null
|
||||
let secondApp: ElectronApplication | null = null
|
||||
|
||||
try {
|
||||
// ── First launch: a real TUI arms mouse reporting ──────────────────
|
||||
const firstLaunch = await session.launch()
|
||||
firstApp = firstLaunch.app
|
||||
await attachRepoAndOpenTerminal(firstLaunch.page, repoPath)
|
||||
await waitForSessionReady(firstLaunch.page)
|
||||
await waitForActiveWorktree(firstLaunch.page)
|
||||
await ensureTerminalVisible(firstLaunch.page)
|
||||
await waitForActiveTerminalManager(firstLaunch.page, 30_000)
|
||||
await waitForPaneCount(firstLaunch.page, 1, 30_000)
|
||||
|
||||
const firstPtyId = await waitForActivePanePtyId(firstLaunch.page)
|
||||
await execInTerminal(
|
||||
firstLaunch.page,
|
||||
firstPtyId,
|
||||
`node ${JSON.stringify(VISIBLE_TUI_FIXTURE_PATH)}`
|
||||
)
|
||||
|
||||
const beforeRestart = await waitForTerminalSurface(
|
||||
firstLaunch.page,
|
||||
(surface) =>
|
||||
surface.visibleText.includes('TUI_SCROLL_READY') && surface.mouseEventsClass === true,
|
||||
'TUI fixture never armed mouse reporting before the restart'
|
||||
)
|
||||
expect(beforeRestart.mouseTrackingMode).toBe('any')
|
||||
|
||||
// Why: the daemon is a detached fork, so closing the app leaves this PTY
|
||||
// — and the TUI running inside it — alive for the relaunch to reattach.
|
||||
await session.close(firstApp)
|
||||
firstApp = null
|
||||
|
||||
// ── Second launch: warm reattach to the still-running TUI ──────────
|
||||
const secondLaunch = await session.launch()
|
||||
secondApp = secondLaunch.app
|
||||
await waitForSessionReady(secondLaunch.page)
|
||||
await waitForActiveWorktree(secondLaunch.page)
|
||||
await ensureTerminalVisible(secondLaunch.page)
|
||||
await waitForActiveTerminalManager(secondLaunch.page, 30_000)
|
||||
await waitForPaneCount(secondLaunch.page, 1, 30_000)
|
||||
await secondLaunch.page.evaluate(() =>
|
||||
window.__store?.getState().updateSettings({ terminalTuiScrollSensitivity: 1 })
|
||||
)
|
||||
|
||||
// Beacon: drive one report straight down the PTY so the TUI repaints
|
||||
// `offset=1`. That row can only reach the pane through the reattach
|
||||
// stream, so its arrival is the deterministic "replay + reset applied"
|
||||
// point — no sleep needed before sampling the modes.
|
||||
const secondPtyId = await waitForActivePanePtyId(secondLaunch.page)
|
||||
await sendToTerminal(secondLaunch.page, secondPtyId, WHEEL_DOWN_REPORT)
|
||||
const afterReattach = await waitForTerminalSurface(
|
||||
secondLaunch.page,
|
||||
(surface) => readRenderedTuiOffset(surface.visibleText) === 1,
|
||||
'Reattached pane never rendered the live TUI repaint after the restart'
|
||||
)
|
||||
|
||||
// ── The reported symptom: drag now paints a selection over the TUI ──
|
||||
await dragAcrossTuiRows(secondLaunch.page, afterReattach.screen)
|
||||
const afterDrag = await readTerminalSurface(secondLaunch.page)
|
||||
// Why a screenshot and not the video fixture: this spec quits and relaunches Orca,
|
||||
// so the recorder's WebM never flushes. This frame IS the proof — on main the drag
|
||||
// paints an xterm row selection across the live TUI; here it must stay clean.
|
||||
const proofShot = process.env.ORCA_E2E_PROOF_SCREENSHOT
|
||||
if (proofShot) {
|
||||
await secondLaunch.page.screenshot({ path: proofShot })
|
||||
}
|
||||
expect(afterDrag, 'terminal surface unavailable after the drag').not.toBeNull()
|
||||
expect(
|
||||
afterDrag!.selectionText,
|
||||
'dragging over a live mouse-tracking TUI must not paint xterm row selection'
|
||||
).toBe('')
|
||||
expect(afterDrag!.hasSelection).toBe(false)
|
||||
|
||||
// ── And the wheel must still reach the TUI as mouse reports ─────────
|
||||
const wheelTargetX = afterReattach.screen.left + afterReattach.screen.width / 2
|
||||
const wheelTargetY = afterReattach.screen.top + afterReattach.screen.height / 2
|
||||
await secondLaunch.page.mouse.move(wheelTargetX, wheelTargetY)
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await secondLaunch.page.mouse.wheel(0, Math.min(49, afterReattach.screen.cellHeight))
|
||||
}
|
||||
const afterWheel = await waitForTerminalSurface(
|
||||
secondLaunch.page,
|
||||
(surface) => (readRenderedTuiOffset(surface.visibleText) ?? 0) > 1,
|
||||
'Wheel gestures never reached the reattached TUI — its rendered offset row never advanced',
|
||||
15_000
|
||||
)
|
||||
expect(readRenderedTuiOffset(afterWheel.visibleText)).toBeGreaterThan(1)
|
||||
expect(afterWheel.mouseEventsClass).toBe(true)
|
||||
expect(afterWheel.mouseTrackingMode).toBe('any')
|
||||
} finally {
|
||||
if (secondApp) {
|
||||
await session.close(secondApp)
|
||||
}
|
||||
if (firstApp) {
|
||||
await session.close(firstApp)
|
||||
}
|
||||
await session.dispose()
|
||||
rmSync(repoPath, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue