diff --git a/src/main/daemon/process-boundary-ground.test.ts b/src/main/daemon/process-boundary-ground.test.ts index 180c4bdc5dc..b76485fcced 100644 --- a/src/main/daemon/process-boundary-ground.test.ts +++ b/src/main/daemon/process-boundary-ground.test.ts @@ -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', () => { diff --git a/src/main/daemon/terminal-history-seed-segments.test.ts b/src/main/daemon/terminal-history-seed-segments.test.ts index bdcfe5c8d11..ea63a0cac8b 100644 --- a/src/main/daemon/terminal-history-seed-segments.test.ts +++ b/src/main/daemon/terminal-history-seed-segments.test.ts @@ -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 { 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', () => { diff --git a/src/main/daemon/terminal-history-seed-segments.ts b/src/main/daemon/terminal-history-seed-segments.ts index 9606a5743eb..0fc017243c1 100644 --- a/src/main/daemon/terminal-history-seed-segments.ts +++ b/src/main/daemon/terminal-history-seed-segments.ts @@ -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) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.spec.ts index 4e21646a02e..33f8ecb1cef 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-cold-restore-repaint.spec.ts @@ -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, diff --git a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts index cb553ebc7de..fdb71dc72e0 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/fresh-spawn-follow-reset.ts @@ -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, diff --git a/src/renderer/src/components/terminal-pane/terminal-restored-viewport.test.ts b/src/renderer/src/components/terminal-pane/terminal-restored-viewport.test.ts index 02517dc6735..d78061ce46a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-restored-viewport.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-restored-viewport.test.ts @@ -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 { return new Promise((resolve) => term.write(data, resolve)) diff --git a/src/renderer/src/components/terminal-pane/terminal-restored-viewport.ts b/src/renderer/src/components/terminal-pane/terminal-restored-viewport.ts index 6aff5b34d9a..75acab6b1e8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-restored-viewport.ts +++ b/src/renderer/src/components/terminal-pane/terminal-restored-viewport.ts @@ -1,10 +1,3 @@ import type { RefObject } from 'react' export type RestoredViewportBlankingPanesRef = RefObject> - -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` -} diff --git a/src/shared/terminal-fresh-shell-viewport.ts b/src/shared/terminal-fresh-shell-viewport.ts new file mode 100644 index 00000000000..450d10a124b --- /dev/null +++ b/src/shared/terminal-fresh-shell-viewport.ts @@ -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` +}