fix(terminal): a woken pane's new prompt starts on its own line when reattached (#33494)

After Wake the window paints the restored history and then blanks its viewport, so the new
shell's prompt starts on a clean screen. The daemon's own copy of the screen, seeded with the
same history, skipped that step. A later reattach (a reload, a second window, the phone)
therefore showed the new prompt beside the dead shell's last one: `p7/sub$ orca@awin:~/projects/p7$`
(awin verify-11). The seed now ends with the same viewport blanking the window writes, built by
one shared function, after the process-boundary ground, which still never moves the cursor.

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-25 03:11:14 -07:00
committed by GitHub
co-authored by Claude
parent 3e6aa82703
commit 069d85f113
8 changed files with 46 additions and 18 deletions
@@ -103,13 +103,23 @@ describe('process boundary ground at a cold restore', () => {
expect(checkpoint.snapshotAnsi).not.toContain('\x1b[?66h')
})
it('keeps the cursor where the recovered history left it', () => {
it('grounds without moving the cursor', () => {
const live = emulator(DAEMON_SESSION_SCROLLBACK_ROWS)
write(live, 'OLD_OUTPUT_1\r\n$ ')
write(live, PROCESS_BOUNDARY_GROUND)
write(live, 'X')
expect(live.getBufferTailLines(24)[1]).toBe('$ X')
})
// awin verify-11: a reattach after Wake showed the new prompt beside the dead shell's (`$ $`).
it("starts the new shell's prompt on its own line, keeping the recovered history", () => {
const live = seededLive(checkpointOfDeadProcess(''))
write(live, 'NEW_SHELL_PROMPT')
const rows = live.getBufferTailLines(24)
expect(rows[0]).toBe('OLD_OUTPUT_1')
expect(rows[2]).toBe('$ NEW_SHELL_PROMPT')
const rows = live.getBufferTailLines(60).filter((row) => row.length > 0)
expect(rows.slice(0, 2)).toEqual(['OLD_OUTPUT_1', 'OLD_OUTPUT_2'])
expect(rows.some((row) => row.trim() === '$')).toBe(true)
expect(rows.at(-1)).toBe('NEW_SHELL_PROMPT')
})
it('never lets the new shell complete a torn escape from the dead process', () => {
@@ -6,6 +6,7 @@ import {
PROCESS_BOUNDARY_GROUND,
RESET_GRAPHIC_RENDITION
} from '../../shared/terminal-mode-reset-profiles'
import { buildFreshShellViewportBlankingSequence } from '../../shared/terminal-fresh-shell-viewport'
import type { ColdRestoreInfo } from './terminal-history-cold-restore-info'
import type { TerminalModes } from './types'
@@ -20,6 +21,7 @@ const ARMED_MODES: TerminalModes = {
// Assert seed placement here; terminal-mode-reset-profiles.test.ts pins the reset bytes.
const GROUND = PROCESS_BOUNDARY_GROUND
const FRESH_VIEWPORT = buildFreshShellViewportBlankingSequence(24)
function restoreInfo(overrides: Partial<ColdRestoreInfo> = {}): ColdRestoreInfo {
return {
@@ -42,7 +44,8 @@ describe('getRecoveredHistorySeedSegments', () => {
expect(segments).toEqual([
`${RESET_GRAPHIC_RENDITION}\x1b[?1003h\x1b[?1006h`,
'user@host ~ $ \x1b[?1003h',
GROUND
GROUND,
FRESH_VIEWPORT
])
})
@@ -51,7 +54,7 @@ describe('getRecoveredHistorySeedSegments', () => {
getRecoveredHistorySeedSegments(
restoreInfo({ modes: { ...ARMED_MODES, alternateScreen: true } })
)
).toEqual([`${RESET_GRAPHIC_RENDITION}user@host ~ $ `, GROUND])
).toEqual([`${RESET_GRAPHIC_RENDITION}user@host ~ $ `, GROUND, FRESH_VIEWPORT])
})
it('stays empty when there is no recovered normal buffer', () => {
@@ -3,6 +3,7 @@ import {
PROCESS_BOUNDARY_GROUND,
RESET_GRAPHIC_RENDITION
} from '../../shared/terminal-mode-reset-profiles'
import { buildFreshShellViewportBlankingSequence } from '../../shared/terminal-fresh-shell-viewport'
// Reset modes after seeding the daemon too, or downstream snapshots (including mobile) re-arm dead TUI state.
@@ -10,7 +11,11 @@ export function getRecoveredHistorySeedSegments(restoreInfo: ColdRestoreInfo): r
if (restoreInfo.modes.alternateScreen) {
const normalBuffer = restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi
return normalBuffer
? [`${RESET_GRAPHIC_RENDITION}${normalBuffer}`, PROCESS_BOUNDARY_GROUND]
? [
`${RESET_GRAPHIC_RENDITION}${normalBuffer}`,
PROCESS_BOUNDARY_GROUND,
freshShellViewport(restoreInfo)
]
: []
}
const recovered = [restoreInfo.rehydrateSequences, restoreInfo.snapshotAnsi].filter(
@@ -25,6 +30,13 @@ export function getRecoveredHistorySeedSegments(restoreInfo: ColdRestoreInfo): r
return [
`${RESET_GRAPHIC_RENDITION}${firstRecovered}`,
...remainingRecovered,
PROCESS_BOUNDARY_GROUND
PROCESS_BOUNDARY_GROUND,
freshShellViewport(restoreInfo)
]
}
// Why: the window blanks its viewport after painting a restore, but the daemon's seeded screen
// did not, so a reattach showed the new shell's prompt beside the old one (`$ $`).
function freshShellViewport(restoreInfo: ColdRestoreInfo): string {
return buildFreshShellViewportBlankingSequence(restoreInfo.rows)
}
@@ -7,7 +7,7 @@ import {
} from '../../../../shared/terminal-mode-reset-profiles'
import { Terminal } from '@xterm/headless'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport'
import { buildFreshShellViewportBlankingSequence } from '../../../../shared/terminal-fresh-shell-viewport'
import {
flushAsyncTicks,
writeHeadlessTerminal,
@@ -8,7 +8,7 @@ import {
POST_REPLAY_REATTACH_RESET_KEEP_MOUSE,
buildKittyKeyboardRestore
} from '../../../../../shared/terminal-mode-reset-profiles'
import { buildFreshShellViewportBlankingSequence } from '../terminal-restored-viewport'
import { buildFreshShellViewportBlankingSequence } from '../../../../../shared/terminal-fresh-shell-viewport'
import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
import {
getTerminalScrollIntentKind,
@@ -1,7 +1,7 @@
import { Terminal } from '@xterm/headless'
import { describe, expect, it } from 'vitest'
import { buildFreshShellViewportBlankingSequence } from './terminal-restored-viewport'
import { buildFreshShellViewportBlankingSequence } from '../../../../shared/terminal-fresh-shell-viewport'
function writeTerminal(term: Terminal, data: string): Promise<void> {
return new Promise((resolve) => term.write(data, resolve))
@@ -1,10 +1,3 @@
import type { RefObject } from 'react'
export type RestoredViewportBlankingPanesRef = RefObject<Set<number>>
export function buildFreshShellViewportBlankingSequence(rows: number): string {
const viewportRows = Math.max(1, Math.floor(Number.isFinite(rows) ? rows : 24))
// Why: newline scrolling preserves restored rows in xterm scrollback; CSI S
// drops them. Reset margins first so stale TUI scroll regions cannot trap it.
return `\x1b[?6l\x1b[r\x1b[${viewportRows};1H${'\r\n'.repeat(viewportRows)}\x1b[H`
}
@@ -0,0 +1,10 @@
/**
* Scrolls a restored screen into scrollback and homes the cursor, so a fresh shell's first prompt
* starts on a blank viewport instead of beside the dead shell's last prompt.
*/
export function buildFreshShellViewportBlankingSequence(rows: number): string {
const viewportRows = Math.max(1, Math.floor(Number.isFinite(rows) ? rows : 24))
// Why: newline scrolling preserves restored rows in xterm scrollback; CSI S
// drops them. Reset margins first so stale TUI scroll regions cannot trap it.
return `\x1b[?6l\x1b[r\x1b[${viewportRows};1H${'\r\n'.repeat(viewportRows)}\x1b[H`
}