mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(terminal): a slept pane wakes in its own folder, with its prompt on a fresh row (#34172)
* fix(terminal): a slept native pane wakes in its own folder, with its prompt on a fresh row On a WSL host (orcad on Linux, bash, no OSC 7) Sleep then Wake reopened the pane in the project root with the new prompt joined to the restored one: - Sleep recorded the shell's folder only for WSL bridge panes. A native shell's checkpoint kept its spawn folder, so Wake started there. Sleep now reads the live shell's cwd (OSC 7, else its process cwd) for the final checkpoint. - The runtime's copy of a cold-restored pane, which a paired host's desktop (and a phone) paints, was seeded without the fresh-shell viewport the window adds, so the new prompt landed on the last restored row. Co-Authored-By: Claude <noreply@anthropic.com> * fix(terminal): skip the process cwd lookup at Sleep on Windows The lookup falls back to lsof, which Windows lacks, so every native pane Sleep would spawn a missing binary. Windows keeps the OSC 7 cwd only. * fix(terminal): bound the Sleep folder read and drop deleted folders The live cwd read now gets min(1.5s, 25% of the remaining Sleep deadline), the same share as the WSL read, so a busy daemon can't eat the final checkpoint's window. A /proc cwd ending in ' (deleted)' maps to null. * test(terminal): pin the Sleep folder read budget without the wall clock * test(restore): read cold-restored history from scrollback above the fresh viewport --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
import './mock-descendant-sweep'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type * as pty from 'node-pty'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { loadNodePtyForTests } from '../orcad/orcad-node-slot-fixture'
|
||||
import { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import {
|
||||
startDaemonAdapterHarness,
|
||||
type DaemonAdapterHarness
|
||||
} from './daemon-pty-adapter-test-harness'
|
||||
import { getHistorySessionDirName } from './history-paths'
|
||||
import { createDaemonPtySubprocessHandle } from './pty-subprocess/subprocess-handle'
|
||||
|
||||
const describePosix = process.platform === 'win32' ? describe.skip : describe
|
||||
const SESSION_ID = 'wt-1::/project@@sleep-cwd'
|
||||
|
||||
// verify-15b: a WSL host pane (orcad on Linux) woke in the project root after `cd deep`.
|
||||
describePosix('a real bash slept and woken by the daemon', () => {
|
||||
let nodePty: typeof pty
|
||||
let harness: DaemonAdapterHarness | null = null
|
||||
let shells: pty.IPty[] = []
|
||||
let root = ''
|
||||
let adapter: DaemonPtyAdapter | null = null
|
||||
|
||||
beforeAll(async () => {
|
||||
nodePty = await loadNodePtyForTests()
|
||||
})
|
||||
afterEach(async () => {
|
||||
for (const shell of shells.splice(0)) {
|
||||
try {
|
||||
shell.kill('SIGKILL')
|
||||
} catch {
|
||||
// Already exited.
|
||||
}
|
||||
}
|
||||
adapter?.dispose()
|
||||
await harness?.server.shutdown()
|
||||
rmSync(harness?.dir ?? '', { recursive: true, force: true })
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('wakes in the folder bash was in, though bash prints no OSC 7', async () => {
|
||||
root = realpathSync(mkdtempSync(join(tmpdir(), 'orca-sleep-cwd-')))
|
||||
const deep = join(root, 'deep')
|
||||
mkdirSync(deep)
|
||||
const spawnedCwds: string[] = []
|
||||
let output = ''
|
||||
harness = await startDaemonAdapterHarness((opts) => {
|
||||
const cwd = opts.cwd ?? root
|
||||
spawnedCwds.push(cwd)
|
||||
const shell = nodePty.spawn('/bin/bash', ['--norc', '--noprofile', '-i'], {
|
||||
cwd,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
env: { TERM: 'xterm-256color', PATH: '/usr/bin:/bin', PS1: 'PROMPT$ ' }
|
||||
})
|
||||
shell.onData((data) => {
|
||||
output += data
|
||||
})
|
||||
shells.push(shell)
|
||||
return createDaemonPtySubprocessHandle({
|
||||
process: shell,
|
||||
shellPath: '/bin/bash',
|
||||
spawnCwd: cwd,
|
||||
env: {},
|
||||
startupCommandDeliveredInShellArgs: false,
|
||||
reportsChildExitStatus: true,
|
||||
sessionId: SESSION_ID,
|
||||
startupAgentRecognition: null
|
||||
})
|
||||
})
|
||||
const historyDir = join(harness.dir, 'history')
|
||||
adapter = new DaemonPtyAdapter({
|
||||
socketPath: harness.socketPath,
|
||||
tokenPath: harness.tokenPath,
|
||||
historyPath: historyDir
|
||||
})
|
||||
|
||||
await adapter.spawn({ cols: 80, rows: 24, cwd: root, sessionId: SESSION_ID })
|
||||
await vi.waitFor(() => expect(output).toContain('PROMPT$'), { timeout: 5000 })
|
||||
adapter.write(SESSION_ID, 'cd deep && echo IN-DEEP\n')
|
||||
await vi.waitFor(() => expect(output).toMatch(/IN-DEEP\r?\n/), { timeout: 5000 })
|
||||
|
||||
await adapter.shutdown(SESSION_ID, { immediate: true, keepHistory: true })
|
||||
const saved = JSON.parse(
|
||||
readFileSync(
|
||||
join(historyDir, getHistorySessionDirName(SESSION_ID), 'checkpoint.json'),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
expect(saved.cwd).toBe(deep)
|
||||
|
||||
await adapter.spawn({ cols: 80, rows: 24, cwd: root, sessionId: SESSION_ID })
|
||||
expect(spawnedCwds.at(-1)).toBe(deep)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import './mock-descendant-sweep'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { join } from 'node:path'
|
||||
import { readFileSync, realpathSync, rmSync } from 'node:fs'
|
||||
import { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import { DaemonClient } from './client'
|
||||
import type { DaemonServer } from './daemon-server'
|
||||
import { getHistorySessionDirName } from './history-paths'
|
||||
import { createMockSubprocess, startDaemonAdapterHarness } from './daemon-pty-adapter-test-harness'
|
||||
import type * as DaemonHealthModule from './daemon-health'
|
||||
import type * as DaemonTccAttributionModule from './daemon-tcc-attribution'
|
||||
import type * as ProcessCwdModule from '../providers/process-cwd'
|
||||
|
||||
const {
|
||||
getMacDaemonSystemResolverHealthMock,
|
||||
getMacDaemonTccAttributionHealthMock,
|
||||
resolveProcessCwdSpy
|
||||
} = vi.hoisted(() => ({
|
||||
resolveProcessCwdSpy: vi.fn(),
|
||||
getMacDaemonSystemResolverHealthMock: vi.fn(
|
||||
async (): Promise<'unknown' | 'unhealthy'> => 'unknown'
|
||||
),
|
||||
getMacDaemonTccAttributionHealthMock: vi.fn(
|
||||
async (): Promise<'intact' | 'severed' | 'unknown'> => 'unknown'
|
||||
)
|
||||
}))
|
||||
|
||||
vi.mock('../providers/process-cwd', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof ProcessCwdModule>()
|
||||
resolveProcessCwdSpy.mockImplementation(actual.resolveProcessCwd)
|
||||
return { ...actual, resolveProcessCwd: resolveProcessCwdSpy }
|
||||
})
|
||||
|
||||
vi.mock('./daemon-health', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof DaemonHealthModule>()
|
||||
return {
|
||||
...actual,
|
||||
getMacDaemonSystemResolverHealth: getMacDaemonSystemResolverHealthMock
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./daemon-tcc-attribution', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof DaemonTccAttributionModule>()
|
||||
return {
|
||||
...actual,
|
||||
getMacDaemonTccAttributionHealth: getMacDaemonTccAttributionHealthMock
|
||||
}
|
||||
})
|
||||
|
||||
// verify-15b: a WSL host's pane (orcad on Linux, bash, no OSC 7) woke in the project root after `cd deep`.
|
||||
describe('a native shell slept and woken', () => {
|
||||
let dir: string
|
||||
let socketPath: string
|
||||
let tokenPath: string
|
||||
let server: DaemonServer
|
||||
let adapter: DaemonPtyAdapter
|
||||
let spawnedCwds: (string | undefined)[]
|
||||
let lastSubprocess: ReturnType<typeof createMockSubprocess>
|
||||
|
||||
beforeEach(async () => {
|
||||
spawnedCwds = []
|
||||
const harness = await startDaemonAdapterHarness((opts) => {
|
||||
spawnedCwds.push(opts.cwd)
|
||||
// A real pid, so the daemon's cwd lookup reads this process's folder, as for a live shell.
|
||||
lastSubprocess = Object.assign(createMockSubprocess(), { pid: process.pid })
|
||||
return lastSubprocess
|
||||
})
|
||||
dir = harness.dir
|
||||
socketPath = harness.socketPath
|
||||
tokenPath = harness.tokenPath
|
||||
server = harness.server
|
||||
adapter = harness.adapter
|
||||
getMacDaemonSystemResolverHealthMock.mockResolvedValue('unknown')
|
||||
getMacDaemonTccAttributionHealthMock.mockResolvedValue('unknown')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
resolveProcessCwdSpy.mockClear()
|
||||
adapter?.dispose()
|
||||
await server?.shutdown()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('wakes in the folder the shell was in at Sleep, not where it was spawned', async () => {
|
||||
const historyDir = join(dir, 'history')
|
||||
const sessionId = 'wt-1::/project@@sleep1'
|
||||
const shellFolder = realpathSync(process.cwd())
|
||||
const history = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
|
||||
|
||||
await history.spawn({ cols: 80, rows: 24, cwd: dir, sessionId })
|
||||
lastSubprocess._simulateData('user@host:~/project$ cd deep\r\nuser@host:~/project/deep$ ')
|
||||
await history.shutdown(sessionId, { immediate: true, keepHistory: true })
|
||||
|
||||
const saved = JSON.parse(
|
||||
readFileSync(join(historyDir, getHistorySessionDirName(sessionId), 'checkpoint.json'), 'utf8')
|
||||
)
|
||||
expect(realpathSync(saved.cwd)).toBe(shellFolder)
|
||||
await history.spawn({ cols: 80, rows: 24, cwd: dir, sessionId })
|
||||
expect(realpathSync(spawnedCwds.at(-1)!)).toBe(shellFolder)
|
||||
history.dispose()
|
||||
})
|
||||
|
||||
// The process lookup execFile()s lsof, which Windows lacks; Sleep there keeps only the OSC 7 cwd.
|
||||
it('on Windows, Sleep without OSC 7 never looks up the process', async () => {
|
||||
const realPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true })
|
||||
try {
|
||||
const historyDir = join(dir, 'history')
|
||||
const sessionId = 'wt-1::/project@@sleep-win'
|
||||
const history = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
|
||||
|
||||
await history.spawn({ cols: 80, rows: 24, cwd: dir, sessionId })
|
||||
lastSubprocess._simulateData('PS C:\\project> cd deep\r\nPS C:\\project\\deep> ')
|
||||
await history.shutdown(sessionId, { immediate: true, keepHistory: true })
|
||||
|
||||
const saved = JSON.parse(
|
||||
readFileSync(
|
||||
join(historyDir, getHistorySessionDirName(sessionId), 'checkpoint.json'),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
expect(resolveProcessCwdSpy).not.toHaveBeenCalled()
|
||||
expect(saved.cwd).toBe(dir)
|
||||
history.dispose()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { value: realPlatform, configurable: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function sleepAndWake(sessionId: string, deadlineMs?: number) {
|
||||
const historyDir = join(dir, 'history')
|
||||
const history = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir })
|
||||
await history.spawn({ cols: 80, rows: 24, cwd: dir, sessionId })
|
||||
lastSubprocess._simulateData('user@host:~/project$ ')
|
||||
await history.shutdown(sessionId, { immediate: true, keepHistory: true, deadlineMs })
|
||||
const saved = JSON.parse(
|
||||
readFileSync(join(historyDir, getHistorySessionDirName(sessionId), 'checkpoint.json'), 'utf8')
|
||||
)
|
||||
await history.spawn({ cols: 80, rows: 24, cwd: dir, sessionId })
|
||||
history.dispose()
|
||||
return { savedCwd: saved.cwd, wokeCwd: spawnedCwds.at(-1) }
|
||||
}
|
||||
|
||||
it('wakes in the spawn folder when the shell folder was deleted', async () => {
|
||||
resolveProcessCwdSpy.mockResolvedValueOnce(`${dir}/deep (deleted)`)
|
||||
const { savedCwd, wokeCwd } = await sleepAndWake('wt-1::/project@@sleep-deleted')
|
||||
expect(resolveProcessCwdSpy).toHaveBeenCalled()
|
||||
expect(savedCwd).toBe(dir)
|
||||
expect(wokeCwd).toBe(dir)
|
||||
})
|
||||
|
||||
it('wakes in the spawn folder when the shell folder is unreadable', async () => {
|
||||
resolveProcessCwdSpy.mockResolvedValueOnce('')
|
||||
const { savedCwd, wokeCwd } = await sleepAndWake('wt-1::/project@@sleep-unreadable')
|
||||
expect(resolveProcessCwdSpy).toHaveBeenCalled()
|
||||
expect(savedCwd).toBe(dir)
|
||||
expect(wokeCwd).toBe(dir)
|
||||
})
|
||||
|
||||
it('gives a hung folder read a quarter of the Sleep deadline, then checkpoints', async () => {
|
||||
// Freeze only the clock, so the read's budget is exact while socket timers stay real.
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
const requestSpy = vi.spyOn(DaemonClient.prototype, 'request')
|
||||
try {
|
||||
const now = Date.now()
|
||||
resolveProcessCwdSpy.mockImplementationOnce(() => new Promise<string>(() => {}))
|
||||
const { savedCwd } = await sleepAndWake('wt-1::/project@@sleep-hung', now + 2_000)
|
||||
expect(resolveProcessCwdSpy).toHaveBeenCalled()
|
||||
const cwdTimeouts = requestSpy.mock.calls
|
||||
.filter(([type]) => type === 'getCwd')
|
||||
.map((c) => c[2])
|
||||
expect(cwdTimeouts).toEqual([500])
|
||||
expect(savedCwd).toBe(dir)
|
||||
} finally {
|
||||
requestSpy.mockRestore()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { GET_SIZE_PROTOCOL_VERSION } from './daemon-protocol-version'
|
||||
import { readDaemonAppliedPtySize, type DaemonAppliedPtySize } from './daemon-pty-applied-size'
|
||||
import { FinalCheckpointWaitExpiredError } from './daemon-pty-lifecycle-errors'
|
||||
import { DaemonPtySessionInput } from './daemon-pty-session-input'
|
||||
import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline'
|
||||
import { remainingDaemonRequestTimeoutMs, teardownReadBudgetMs } from './daemon-request-deadline'
|
||||
import type { ColdRestoreInfo } from './history-reader'
|
||||
import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd'
|
||||
import { readDaemonWslSleepCwd } from './daemon-wsl-sleep-cwd'
|
||||
@@ -15,6 +15,8 @@ import type { PtySpawnResult } from '../providers/pty-provider-contract'
|
||||
export const LIVENESS_PROBE_TIMEOUT_MS = 2_000
|
||||
|
||||
const MAX_TOMBSTONES = 1000
|
||||
// A busy daemon must not eat the final checkpoint's share of the Sleep deadline.
|
||||
const LIVE_SLEEP_CWD_BUDGET_MS = 1_500
|
||||
|
||||
export abstract class DaemonPtySessionControl extends DaemonPtySessionInput {
|
||||
protected abstract adoptListedSessionForSleep(
|
||||
@@ -137,7 +139,7 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput {
|
||||
},
|
||||
opts.deadlineMs
|
||||
)
|
||||
: null
|
||||
: await this.readLiveSleepCwd(id, opts.deadlineMs)
|
||||
const committed = await this.runExclusiveCheckpoint(
|
||||
async () => {
|
||||
// Set inside: a caller past its deadline leaves this checkpoint running without it.
|
||||
@@ -278,6 +280,29 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionInput {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A native shell's folder at Sleep (OSC 7, else its process's cwd). Bash prints no OSC 7, so
|
||||
* without this the checkpoint keeps the spawn folder and Wake opens there (e.g. on a Linux host).
|
||||
*/
|
||||
private async readLiveSleepCwd(id: string, deadlineMs?: number): Promise<string | null> {
|
||||
// Windows has no process-cwd lookup (it would spawn a missing lsof); the snapshot's OSC 7 cwd stands.
|
||||
const budget = teardownReadBudgetMs(LIVE_SLEEP_CWD_BUDGET_MS, deadlineMs)
|
||||
if (process.platform === 'win32' || budget <= 0 || !this.activeSessionIds.has(id)) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const { cwd } = await this.client.request<{ cwd: string | null }>(
|
||||
'getCwd',
|
||||
{ sessionId: id },
|
||||
budget
|
||||
)
|
||||
// A removed folder reads back from /proc as `<path> (deleted)`, which no shell can return to.
|
||||
return cwd && !cwd.endsWith(' (deleted)') ? cwd : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async getInitialCwd(id: string): Promise<string> {
|
||||
return this.initialCwds.get(id) ?? ''
|
||||
}
|
||||
|
||||
@@ -4,6 +4,13 @@ export function remainingDaemonRequestTimeoutMs(
|
||||
return deadlineMs === undefined ? undefined : Math.max(1, deadlineMs - Date.now())
|
||||
}
|
||||
|
||||
// A read ahead of teardown gets at most a quarter of what remains, leaving the rest for checkpoint and kill.
|
||||
export function teardownReadBudgetMs(capMs: number, deadlineMs: number | undefined): number {
|
||||
return Math.floor(
|
||||
Math.min(capMs, deadlineMs === undefined ? capMs : (deadlineMs - Date.now()) * 0.25)
|
||||
)
|
||||
}
|
||||
|
||||
// The caller's wait is bounded without cancelling durable work, which keeps running and committing.
|
||||
export async function awaitDaemonWorkWithinCallerDeadline(
|
||||
work: Promise<void>,
|
||||
|
||||
@@ -4,22 +4,17 @@ import {
|
||||
readWslGuestPaneProcesses,
|
||||
readWslGuestPaneShellCwd
|
||||
} from '../wsl/wsl-guest-pane-processes'
|
||||
import { teardownReadBudgetMs } from './daemon-request-deadline'
|
||||
|
||||
// Reserve teardown time for the final checkpoint and kill when a distro does not answer.
|
||||
const READ_BUDGET_MS = 3_000
|
||||
const READ_SHARE_OF_DEADLINE = 0.25
|
||||
|
||||
/** The shell's folder in the bridge's UNC spelling, which wake reads; else null. */
|
||||
export async function readDaemonWslSleepCwd(
|
||||
pane: { paneKey: string; wslDistro: string; live: boolean },
|
||||
deadlineMs?: number
|
||||
): Promise<string | null> {
|
||||
const budget = Math.floor(
|
||||
Math.min(
|
||||
READ_BUDGET_MS,
|
||||
deadlineMs === undefined ? READ_BUDGET_MS : (deadlineMs - Date.now()) * READ_SHARE_OF_DEADLINE
|
||||
)
|
||||
)
|
||||
const budget = teardownReadBudgetMs(READ_BUDGET_MS, deadlineMs)
|
||||
if (process.platform !== 'win32' || budget <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// verify-15b: a WSL host pane's wake joined the new prompt onto the restored one.
|
||||
import { Terminal } from '@xterm/headless'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { PtySpawnResult } from '../../../providers/pty-provider-contract'
|
||||
import type { OrcaRuntimeService } from '../../../runtime/orca-runtime'
|
||||
import { seedHeadlessTerminalFromSpawnResult } from './terminal-spawn-restore'
|
||||
|
||||
async function rowsAfter(seed: string, liveOutput: string, rows: number): Promise<string[]> {
|
||||
const terminal = new Terminal({ cols: 40, rows, scrollback: 100, allowProposedApi: true })
|
||||
await new Promise<void>((resolve) => terminal.write(seed + liveOutput, resolve))
|
||||
const lines: string[] = []
|
||||
for (let i = 0; i < terminal.buffer.active.length; i++) {
|
||||
lines.push(terminal.buffer.active.getLine(i)?.translateToString(true).trimEnd() ?? '')
|
||||
}
|
||||
terminal.dispose()
|
||||
return lines.filter((line) => line !== '')
|
||||
}
|
||||
|
||||
describe('seeding the host copy from a cold restore', () => {
|
||||
it('parks the cursor below the restored history, so the new shell’s prompt has its own row', async () => {
|
||||
const seedHeadlessTerminal = vi.fn()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the seed reads only seedHeadlessTerminal from the runtime.
|
||||
const runtime = { seedHeadlessTerminal } as unknown as OrcaRuntimeService
|
||||
const result: PtySpawnResult = {
|
||||
id: 'host-pty',
|
||||
coldRestore: {
|
||||
scrollback: 'user@host:~/p8$ cd deep\r\nuser@host:~/p8/deep$ ',
|
||||
cwd: '/home/user/p8/deep',
|
||||
cols: 40,
|
||||
rows: 6
|
||||
}
|
||||
}
|
||||
|
||||
seedHeadlessTerminalFromSpawnResult(runtime, result, null)
|
||||
|
||||
const [, seed] = seedHeadlessTerminal.mock.calls[0]!
|
||||
expect(await rowsAfter(seed, 'user@host:~/p8/deep$ ', 6)).toEqual([
|
||||
'user@host:~/p8$ cd deep',
|
||||
'user@host:~/p8/deep$',
|
||||
'user@host:~/p8/deep$'
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import type { PtySpawnResult } from '../../../providers/pty-provider-contract'
|
||||
import type { OrcaRuntimeService } from '../../../runtime/orca-runtime'
|
||||
import { pendingByPaneKey, rendererSerializerReadiness } from './serializer-state'
|
||||
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
|
||||
import { buildFreshShellViewportBlankingSequence } from '../../../../shared/terminal-fresh-shell-viewport'
|
||||
|
||||
export function seedHeadlessTerminalFromSpawnResult(
|
||||
runtime: OrcaRuntimeService | undefined,
|
||||
@@ -36,7 +37,12 @@ export function seedHeadlessTerminalFromSpawnResult(
|
||||
typeof result.coldRestore.cols === 'number' && typeof result.coldRestore.rows === 'number'
|
||||
? { cols: result.coldRestore.cols, rows: result.coldRestore.rows }
|
||||
: undefined
|
||||
runtime.seedHeadlessTerminal(result.id, result.coldRestore.scrollback, size, {
|
||||
// A fresh shell follows the restored history: park the cursor below it, as the window does,
|
||||
// or a view painted from this copy (a paired host's desktop, a phone) joins the new prompt.
|
||||
const seed = `${result.coldRestore.scrollback}${buildFreshShellViewportBlankingSequence(
|
||||
size?.rows ?? 24
|
||||
)}`
|
||||
runtime.seedHeadlessTerminal(result.id, seed, size, {
|
||||
cwd: result.coldRestore.cwd,
|
||||
oscLinks: result.coldRestore.oscLinks,
|
||||
preferProviderIfExisting: true
|
||||
|
||||
@@ -20,6 +20,7 @@ import { registerPtyHandlers } from './register-handlers'
|
||||
import { clearProviderPtyState } from './provider/state-cleanup'
|
||||
import { getPtyIdForPaneKey } from './pane/key-state'
|
||||
import { setLocalPtyProvider } from './provider/registry'
|
||||
import { buildFreshShellViewportBlankingSequence } from '../../../shared/terminal-fresh-shell-viewport'
|
||||
|
||||
vi.mock('electron', () => import('../pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
|
||||
vi.mock('fs', () => import('../pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
|
||||
@@ -110,10 +111,11 @@ describe('registerPtyHandlers', () => {
|
||||
|
||||
await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })
|
||||
|
||||
// Restored history, then the fresh shell's viewport below it (verify-15b `$ $`).
|
||||
expect(runtime.seedHeadlessTerminal).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'pty-cold-restore',
|
||||
'restored history\r\n',
|
||||
`restored history\r\n${buildFreshShellViewportBlankingSequence(43)}`,
|
||||
{ cols: 132, rows: 43 },
|
||||
{ cwd: '/projects/restored', oscLinks, preferProviderIfExisting: true }
|
||||
)
|
||||
@@ -123,7 +125,7 @@ describe('registerPtyHandlers', () => {
|
||||
expect(runtime.seedHeadlessTerminal).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'pty-legacy-cold-restore',
|
||||
'legacy history\r\n',
|
||||
`legacy history\r\n${buildFreshShellViewportBlankingSequence(24)}`,
|
||||
undefined,
|
||||
{ cwd: '/projects/legacy', oscLinks: undefined, preferProviderIfExisting: true }
|
||||
)
|
||||
|
||||
@@ -66,8 +66,11 @@ describe.each(['ipc', 'runtime'])('%s restored scrollback', (controller) => {
|
||||
runtime.onPtyData(binding.ptyId, 'live output\r\n', Date.now())
|
||||
gate.finish.resolve()
|
||||
await pending
|
||||
const snapshot = await runtime.serializeMainTerminalBuffer(binding.ptyId)
|
||||
expect(snapshot?.data).toContain('restored history')
|
||||
// Cold restore parks restored history above a blank viewport, so read it from scrollback.
|
||||
const snapshot = await runtime.serializeMainTerminalBuffer(binding.ptyId, {
|
||||
scrollbackRows: 1_000
|
||||
})
|
||||
expect(snapshot?.data.split('restored history')).toHaveLength(2)
|
||||
expect(snapshot?.data).toContain('live output')
|
||||
expect(snapshot?.data.indexOf('restored history')).toBeLessThan(
|
||||
snapshot?.data.indexOf('live output') ?? -1
|
||||
|
||||
Reference in New Issue
Block a user