diff --git a/src/main/ipc/pty/delivery/attached-pty-size.test.ts b/src/main/ipc/pty/delivery/attached-pty-size.test.ts new file mode 100644 index 00000000000..90ff37c744d --- /dev/null +++ b/src/main/ipc/pty/delivery/attached-pty-size.test.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + commitAttachedPtySize, + resolveCommittedPtySize, + shouldSeedPreAttachPtySize +} from './attached-pty-size' +import { ptySizes } from './visibility-state' + +const REQUESTED = { cols: 80, rows: 24 } +const CACHED = { cols: 180, rows: 50 } +const LIVE = { cols: 211, rows: 57 } + +describe('shouldSeedPreAttachPtySize', () => { + it('seeds a fresh session id even when the pane never measured itself', () => { + expect( + shouldSeedPreAttachPtySize({ + isFreshSessionId: true, + hasCachedSize: true, + requestIsUnmeasured: true + }) + ).toBe(true) + }) + + it('never overwrites a size main already holds for the session', () => { + expect( + shouldSeedPreAttachPtySize({ + isFreshSessionId: false, + hasCachedSize: true, + requestIsUnmeasured: false + }) + ).toBe(false) + }) + + it('refuses an unmeasured request on an attach even with nothing cached', () => { + expect( + shouldSeedPreAttachPtySize({ + isFreshSessionId: false, + hasCachedSize: false, + requestIsUnmeasured: true + }) + ).toBe(false) + }) + + it('seeds a measured attach request when main holds nothing better', () => { + expect( + shouldSeedPreAttachPtySize({ + isFreshSessionId: false, + hasCachedSize: false, + requestIsUnmeasured: false + }) + ).toBe(true) + }) +}) + +describe('resolveCommittedPtySize', () => { + it('records the requested grid for a fresh spawn, ignoring any stale cache', () => { + expect( + resolveCommittedPtySize({ + result: {}, + requested: REQUESTED, + cachedBeforeAttach: CACHED + }) + ).toEqual(REQUESTED) + }) + + it('prefers a grid the provider applied on attach', () => { + expect( + resolveCommittedPtySize({ + result: { + isReattach: true, + attachedGrid: { cols: 100, rows: 30 }, + snapshotCols: LIVE.cols, + snapshotRows: LIVE.rows + }, + requested: REQUESTED, + cachedBeforeAttach: CACHED + }) + ).toEqual({ cols: 100, rows: 30 }) + }) + + it('falls back to the reattach snapshot grid', () => { + expect( + resolveCommittedPtySize({ + result: { isReattach: true, snapshotCols: LIVE.cols, snapshotRows: LIVE.rows }, + requested: REQUESTED, + cachedBeforeAttach: CACHED + }) + ).toEqual(LIVE) + }) + + it('falls back to the size main held when the provider proves nothing', () => { + expect( + resolveCommittedPtySize({ + result: { isReattach: true }, + requested: REQUESTED, + cachedBeforeAttach: CACHED + }) + ).toEqual(CACHED) + }) + + it('rejects a non-integer provider grid as unproven', () => { + expect( + resolveCommittedPtySize({ + result: { isReattach: true, snapshotCols: 120.5, snapshotRows: 40 }, + requested: REQUESTED, + cachedBeforeAttach: CACHED + }) + ).toEqual(CACHED) + }) + + it('takes the request only when nothing better exists', () => { + expect( + resolveCommittedPtySize({ + result: { isReattach: true }, + requested: REQUESTED, + cachedBeforeAttach: undefined + }) + ).toEqual(REQUESTED) + }) + + it('rejects a non-positive provider grid rather than publishing a zero-width model', () => { + expect( + resolveCommittedPtySize({ + result: { isReattach: true, snapshotCols: 0, snapshotRows: 0 }, + requested: REQUESTED, + cachedBeforeAttach: CACHED + }) + ).toEqual(CACHED) + }) +}) + +describe('commitAttachedPtySize', () => { + afterEach(() => { + ptySizes.delete('pty-commit') + }) + + it('records the resolved grid and reflows the model onto it for a reattach', () => { + const reflow = vi.fn() + const committed = commitAttachedPtySize({ + result: { + id: 'pty-commit', + isReattach: true, + snapshotCols: LIVE.cols, + snapshotRows: LIVE.rows + }, + requested: REQUESTED, + cachedBeforeAttach: undefined, + reflowHeadlessTerminalToPtyGrid: reflow + }) + expect(committed).toEqual(LIVE) + expect(ptySizes.get('pty-commit')).toEqual(LIVE) + expect(reflow).toHaveBeenCalledWith('pty-commit', LIVE.cols, LIVE.rows) + }) + + it('reflows a fresh spawn onto the request too: bytes can create the model before the reply', () => { + const reflow = vi.fn() + commitAttachedPtySize({ + result: { id: 'pty-commit' }, + requested: REQUESTED, + cachedBeforeAttach: CACHED, + reflowHeadlessTerminalToPtyGrid: reflow + }) + expect(ptySizes.get('pty-commit')).toEqual(REQUESTED) + expect(reflow).toHaveBeenCalledWith('pty-commit', REQUESTED.cols, REQUESTED.rows) + }) +}) diff --git a/src/main/ipc/pty/delivery/attached-pty-size.ts b/src/main/ipc/pty/delivery/attached-pty-size.ts new file mode 100644 index 00000000000..d8b14267cb8 --- /dev/null +++ b/src/main/ipc/pty/delivery/attached-pty-size.ts @@ -0,0 +1,81 @@ +import type { PtySpawnResult } from '../../../providers/types' +import { ptySizes } from './visibility-state' + +export type PtyGrid = { cols: number; rows: number } + +function positiveGrid(cols: unknown, rows: unknown): PtyGrid | undefined { + return typeof cols === 'number' && + typeof rows === 'number' && + Number.isInteger(cols) && + Number.isInteger(rows) && + cols > 0 && + rows > 0 + ? { cols, rows } + : undefined +} + +/** Pre-attach seed for `ptySizes`. Daemon PTYs can emit before spawn() resolves, so a genuinely + * fresh session must record its geometry now or early bytes parse at xterm's 80x24 default. + * An attach must not seed: a pane that mounted while hidden reports xterm's unmeasured default, + * and the live PTY's real grid is either already cached or arrives with the attach result. */ +export function shouldSeedPreAttachPtySize(args: { + isFreshSessionId: boolean + hasCachedSize: boolean + requestIsUnmeasured: boolean +}): boolean { + return args.isFreshSessionId || (!args.hasCachedSize && !args.requestIsUnmeasured) +} + +/** Grid to record for a settled spawn. Daemon and relay attach never resize the session they hand + * back, so on a reattach the requested grid describes the pane, not the live process — take the + * provider's proven grid, then the size main already held, before trusting the request. */ +export function resolveCommittedPtySize(args: { + result: Pick + requested: PtyGrid + cachedBeforeAttach: PtyGrid | undefined +}): PtyGrid { + if (args.result.isReattach !== true) { + return args.requested + } + return ( + positiveGrid(args.result.attachedGrid?.cols, args.result.attachedGrid?.rows) ?? + positiveGrid(args.result.snapshotCols, args.result.snapshotRows) ?? + positiveGrid(args.cachedBeforeAttach?.cols, args.cachedBeforeAttach?.rows) ?? + args.requested + ) +} + +type HeadlessReflow = ((ptyId: string, cols: number, rows: number) => void) | undefined + +/** Reflow main's model onto the committed grid, whatever the spawn was. Why unconditional: live + * bytes can lazily create the model at the 80x24 default before the reply arrives, a seed skips an + * existing model, and the pre-attach seed is now withheld for unmeasured attaches, so a session the + * daemon re-created instead of attaching would otherwise keep the default forever. */ +export function reflowHeadlessTerminalToCommittedGrid(args: { + result: Pick + committedSize: PtyGrid + reflowHeadlessTerminalToPtyGrid: HeadlessReflow +}): void { + args.reflowHeadlessTerminalToPtyGrid?.( + args.result.id, + args.committedSize.cols, + args.committedSize.rows + ) +} + +/** Record the settled grid, then reflow the model onto it. Callers that seed the model between the + * two steps (ipc spawn commit) call the halves separately. */ +export function commitAttachedPtySize(args: { + result: Pick< + PtySpawnResult, + 'id' | 'isReattach' | 'attachedGrid' | 'snapshotCols' | 'snapshotRows' + > + requested: PtyGrid + cachedBeforeAttach: PtyGrid | undefined + reflowHeadlessTerminalToPtyGrid: HeadlessReflow +}): PtyGrid { + const committedSize = resolveCommittedPtySize(args) + ptySizes.set(args.result.id, committedSize) + reflowHeadlessTerminalToCommittedGrid({ ...args, committedSize }) + return committedSize +} diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index 540ada9397d..d9bee3e6934 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -11,12 +11,14 @@ import { } from '../pane/serializer-state' import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' import { ptySizes } from '../delivery/visibility-state' +import { resolveCommittedPtySize, type PtyGrid } from '../delivery/attached-pty-size' import { clearProviderPtyState } from '../provider/state-cleanup' import type { PtyIpcSpawnState } from './spawn-state' export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ rendererPreSignaled: boolean rendererAlreadyRegistered: boolean + committedSize: PtyGrid }> { const args = ctx.args try { @@ -89,7 +91,12 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ ctx.agentTeamsLeaderHandle = null } } - ptySizes.set(ctx.result.id, { cols: args.cols, rows: args.rows }) + const committedSize = resolveCommittedPtySize({ + result: ctx.result, + requested: { cols: args.cols, rows: args.rows }, + cachedBeforeAttach: ctx.sessionSizeBeforeAttach + }) + ptySizes.set(ctx.result.id, committedSize) if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) { ptySizes.delete(ctx.effectiveSessionAppId) } @@ -157,5 +164,5 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise<{ pendingPtyIdBySerializerGeneration.set(pending.gen, ctx.result.id) } } - return { rendererPreSignaled, rendererAlreadyRegistered } + return { rendererPreSignaled, rendererAlreadyRegistered, committedSize } } diff --git a/src/main/ipc/pty/ipc/spawn-commit.ts b/src/main/ipc/pty/ipc/spawn-commit.ts index f02eb215c87..90b700f24b2 100644 --- a/src/main/ipc/pty/ipc/spawn-commit.ts +++ b/src/main/ipc/pty/ipc/spawn-commit.ts @@ -24,10 +24,12 @@ import { } from '../pane/launch-authority' import type { PtyIpcSpawnState } from './spawn-state' import { persistPtyIpcSpawnCommit } from './spawn-commit-persist' +import { reflowHeadlessTerminalToCommittedGrid } from '../delivery/attached-pty-size' export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { const args = ctx.args - const { rendererPreSignaled, rendererAlreadyRegistered } = await persistPtyIpcSpawnCommit(ctx) + const { rendererPreSignaled, rendererAlreadyRegistered, committedSize } = + await persistPtyIpcSpawnCommit(ctx) // Why: seed the headless emulator before registerPty so concurrent live PTY data lands on top of the seed, not replacing it (mobile keeps the daemon-restored scrollback). // Skip when the renderer will be authoritative — its xterm buffer is richer than the daemon snapshot. @@ -71,6 +73,15 @@ export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise 0 && diff --git a/src/main/ipc/pty/ipc/spawn-options.ts b/src/main/ipc/pty/ipc/spawn-options.ts index ed75b989f6d..78747e6a462 100644 --- a/src/main/ipc/pty/ipc/spawn-options.ts +++ b/src/main/ipc/pty/ipc/spawn-options.ts @@ -17,6 +17,7 @@ import { pendingRuntimePaneCreatesByOwnerKey } from '../pane/spawn-reservation' import { ptySizes } from '../delivery/visibility-state' +import { shouldSeedPreAttachPtySize } from '../delivery/attached-pty-size' import { getStartupTerminalColorQueryReplyColors } from '../../terminal-startup-color-query-replies' import type { PtyIpcSpawnState } from './spawn-state' @@ -97,7 +98,14 @@ export async function buildPtyIpcSpawnOptions( ctx.effectiveSessionAppId !== undefined ? ptySizes.has(ctx.effectiveSessionAppId) : false ctx.sessionSizeBeforeAttach = ctx.effectiveSessionAppId !== undefined ? ptySizes.get(ctx.effectiveSessionAppId) : undefined - if (ctx.effectiveSessionId !== undefined) { + if ( + ctx.effectiveSessionId !== undefined && + shouldSeedPreAttachPtySize({ + isFreshSessionId: ctx.isMintedSessionId, + hasCachedSize: ctx.hadSessionSizeBeforeAttach, + requestIsUnmeasured: args.initiallyHidden === true + }) + ) { // Why: daemon PTYs can emit before spawn() resolves; set real geometry now or early bytes default to 80x24 and wrap TUIs. ptySizes.set(ctx.effectiveSessionAppId ?? ctx.effectiveSessionId, { cols: args.cols, diff --git a/src/main/ipc/pty/ipc/spawn-reattach-size-cache.test.ts b/src/main/ipc/pty/ipc/spawn-reattach-size-cache.test.ts new file mode 100644 index 00000000000..53b361bc8fe --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-reattach-size-cache.test.ts @@ -0,0 +1,136 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { PtySpawnResult } from '../../../providers/types' +import { ptySizes } from '../delivery/visibility-state' +import { buildPtyIpcSpawnOptions } from './spawn-options' +import { commitPtyIpcSpawn } from './spawn-commit' +import { createPtyIpcSpawnState, type PtyIpcSpawnState } from './spawn-state' +import type { PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' + +const SESSION_ID = 'orca-pty-session-1' +/** What a pane that mounted while `display:none` reports: xterm's unmeasured default. */ +const HIDDEN_PANE_REQUEST = { cols: 80, rows: 24 } +/** The grid the surviving daemon session is actually running at. */ +const LIVE_GRID = { cols: 211, rows: 57 } + +function makeRuntime() { + return { + seedHeadlessTerminal: vi.fn(), + reflowHeadlessTerminalToPtyGrid: vi.fn(), + registerPty: vi.fn(), + cancelPendingPtyRegistration: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + seedTerminalRestoreTail: vi.fn(), + registerPreAllocatedHandleForPty: vi.fn() + } +} + +function makeCtx(args: PtySpawnIpcArgs, runtime: ReturnType): PtyIpcSpawnState { + const deps = { + transitionSpawnHiddenRendererPtyDeliveryState: vi.fn(), + syncPtyBackgroundedDelivery: vi.fn(), + sendPtySpawnedToRenderer: vi.fn(), + runtime + } as unknown as PtySpawnIpcDeps + const ctx = createPtyIpcSpawnState(deps, args) + ctx.env = {} + ctx.isDaemonHostSpawn = true + ctx.effectiveSessionId = SESSION_ID + ctx.effectiveSessionAppId = SESSION_ID + // Mirrors spawn-preflight: a caller-supplied sessionId is an attach, never a fresh mint. + ctx.isMintedSessionId = args.sessionId === undefined + return ctx +} + +async function runSpawn( + args: PtySpawnIpcArgs, + result: PtySpawnResult +): Promise<{ + runtime: ReturnType + preAttachSize: { cols: number; rows: number } | undefined +}> { + const runtime = makeRuntime() + const ctx = makeCtx(args, runtime) + await buildPtyIpcSpawnOptions(ctx) + const preAttachSize = ptySizes.get(SESSION_ID) + ctx.result = result + await commitPtyIpcSpawn(ctx) + return { runtime, preAttachSize } +} + +describe('spawn size cache on reattach', () => { + afterEach(() => { + ptySizes.delete(SESSION_ID) + vi.restoreAllMocks() + }) + + it('records the reattached session real grid, not a hidden pane placeholder', async () => { + const { runtime, preAttachSize } = await runSpawn( + { ...HIDDEN_PANE_REQUEST, sessionId: SESSION_ID, initiallyHidden: true }, + { + id: SESSION_ID, + isReattach: true, + snapshotCols: LIVE_GRID.cols, + snapshotRows: LIVE_GRID.rows + } + ) + + // Pre-attach: an unmeasured request must not be published as the live PTY's size. + expect(preAttachSize).toBeUndefined() + expect(ptySizes.get(SESSION_ID)).toEqual(LIVE_GRID) + expect(runtime.reflowHeadlessTerminalToPtyGrid).toHaveBeenCalledWith( + SESSION_ID, + LIVE_GRID.cols, + LIVE_GRID.rows + ) + }) + + it('records the requested grid for a genuinely fresh spawn', async () => { + const { runtime, preAttachSize } = await runSpawn({ cols: 120, rows: 40 }, { id: SESSION_ID }) + + expect(preAttachSize).toEqual({ cols: 120, rows: 40 }) + expect(ptySizes.get(SESSION_ID)).toEqual({ cols: 120, rows: 40 }) + expect(runtime.reflowHeadlessTerminalToPtyGrid).toHaveBeenCalledWith(SESSION_ID, 120, 40) + }) + + // Why: the pre-attach seed is withheld for an unmeasured attach, and a daemon that restarted + // re-creates the session instead of attaching, so only the commit can size the model. + it('reflows a hidden attach the daemon answered with a fresh session onto the request', async () => { + const { runtime, preAttachSize } = await runSpawn( + { cols: 100, rows: 30, sessionId: SESSION_ID, initiallyHidden: true }, + { id: SESSION_ID } + ) + + expect(preAttachSize).toBeUndefined() + expect(ptySizes.get(SESSION_ID)).toEqual({ cols: 100, rows: 30 }) + expect(runtime.reflowHeadlessTerminalToPtyGrid).toHaveBeenCalledWith(SESSION_ID, 100, 30) + }) + + it('keeps the size main already held when the reattach carries no snapshot grid', async () => { + ptySizes.set(SESSION_ID, { cols: 180, rows: 50 }) + + const { preAttachSize } = await runSpawn( + { ...HIDDEN_PANE_REQUEST, sessionId: SESSION_ID, initiallyHidden: true }, + { id: SESSION_ID, isReattach: true } + ) + + expect(preAttachSize).toEqual({ cols: 180, rows: 50 }) + expect(ptySizes.get(SESSION_ID)).toEqual({ cols: 180, rows: 50 }) + }) + + it('prefers the grid the provider applied on attach over every other source', async () => { + ptySizes.set(SESSION_ID, { cols: 180, rows: 50 }) + + await runSpawn( + { ...HIDDEN_PANE_REQUEST, sessionId: SESSION_ID, initiallyHidden: true }, + { + id: SESSION_ID, + isReattach: true, + attachedGrid: { cols: 100, rows: 30 }, + snapshotCols: LIVE_GRID.cols, + snapshotRows: LIVE_GRID.rows + } + ) + + expect(ptySizes.get(SESSION_ID)).toEqual({ cols: 100, rows: 30 }) + }) +}) diff --git a/src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts b/src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts new file mode 100644 index 00000000000..84d87b75005 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-commit-pty-size.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ptySizes } from '../delivery/visibility-state' +import { commitRuntimePtySpawn } from './spawn-commit' +import { createRuntimePtySpawnState, type RuntimePtySpawnArgs } from './spawn-state' +import type { PtyRuntimeControllerDeps } from './controller-deps' + +const PTY_ID = 'orca-pty-adopted' +const LIVE_GRID = { cols: 211, rows: 57 } + +function makeRuntime() { + return { + registerPreAllocatedHandleForPty: vi.fn(), + registerPty: vi.fn(), + reflowHeadlessTerminalToPtyGrid: vi.fn(), + seedHeadlessTerminal: vi.fn(), + noteTerminalSpawnCommand: vi.fn() + } +} + +describe('runtime spawn commit: adopted agent-session claim', () => { + afterEach(() => { + ptySizes.delete(PTY_ID) + }) + + function makeAdoptedCtx(result: Record) { + const runtime = makeRuntime() + const deps = { runtime, store: undefined, options: {} } as unknown as PtyRuntimeControllerDeps + const args = { cols: 120, rows: 40, worktreeId: 'wt-1' } as unknown as RuntimePtySpawnArgs + const ctx = createRuntimePtySpawnState(deps, args) + ctx.result = { + id: PTY_ID, + ...result, + agentSessionEnsure: { + disposition: 'adopted', + owner: { + claim: { kind: 'terminal' }, + generation: 'g1', + phase: 'live', + ptyId: PTY_ID, + surface: { worktreeId: 'wt-1', tabId: 'tab-1', leafId: 'leaf-1', terminalHandle: 'h1' } + } + } + } as unknown as typeof ctx.result + return { runtime, ctx } + } + + it('commits the live grid from the adoption reply before the early return', async () => { + const { runtime, ctx } = makeAdoptedCtx({ + isReattach: true, + snapshotCols: LIVE_GRID.cols, + snapshotRows: LIVE_GRID.rows + }) + + await commitRuntimePtySpawn(ctx) + + expect(ptySizes.get(PTY_ID)).toEqual(LIVE_GRID) + expect(runtime.reflowHeadlessTerminalToPtyGrid).toHaveBeenCalledWith( + PTY_ID, + LIVE_GRID.cols, + LIVE_GRID.rows + ) + }) + + // Why: the SSH relay's adopted reply carries neither isReattach nor snapshot dims; the + // adoption itself proves a live owner, so main keeps what it held rather than the request. + it('treats an adoption without a reattach flag as an attach and keeps the held size', async () => { + ptySizes.set(PTY_ID, LIVE_GRID) + const { runtime, ctx } = makeAdoptedCtx({}) + ctx.sessionSizeBeforeAttach = LIVE_GRID + + await commitRuntimePtySpawn(ctx) + + expect(ptySizes.get(PTY_ID)).toEqual(LIVE_GRID) + expect(runtime.reflowHeadlessTerminalToPtyGrid).toHaveBeenCalledWith( + PTY_ID, + LIVE_GRID.cols, + LIVE_GRID.rows + ) + }) +}) diff --git a/src/main/ipc/pty/runtime/spawn-commit-pty-size.ts b/src/main/ipc/pty/runtime/spawn-commit-pty-size.ts new file mode 100644 index 00000000000..9b73f093761 --- /dev/null +++ b/src/main/ipc/pty/runtime/spawn-commit-pty-size.ts @@ -0,0 +1,18 @@ +import { commitAttachedPtySize } from '../delivery/attached-pty-size' +import type { RuntimePtySpawnState } from './spawn-state' + +/** Record the settled grid for a runtime-path spawn; `result` is passed explicitly because the + * adopted-claim branch commits before it returns early. */ +export function commitRuntimePtySize( + ctx: RuntimePtySpawnState, + result: RuntimePtySpawnState['result'] +): void { + commitAttachedPtySize({ + result, + requested: { cols: ctx.args.cols, rows: ctx.args.rows }, + cachedBeforeAttach: ctx.sessionSizeBeforeAttach, + reflowHeadlessTerminalToPtyGrid: ctx.deps.runtime?.reflowHeadlessTerminalToPtyGrid?.bind( + ctx.deps.runtime + ) + }) +} diff --git a/src/main/ipc/pty/runtime/spawn-commit.ts b/src/main/ipc/pty/runtime/spawn-commit.ts index 23592604bea..7f8a9e38267 100644 --- a/src/main/ipc/pty/runtime/spawn-commit.ts +++ b/src/main/ipc/pty/runtime/spawn-commit.ts @@ -1,6 +1,7 @@ import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { ptyOwnership, ptyIncarnationById, deletePtyOwnership } from '../provider/ownership-state' import { ptySizes } from '../delivery/visibility-state' +import { commitRuntimePtySize } from './spawn-commit-pty-size' import { shouldSkipCodexHomeEnvForWindowsShell, recordCodexPaneAccountForSpawn, @@ -57,6 +58,9 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { }) } if (ctx.result.agentSessionEnsure?.disposition === 'adopted') { + // Why: an adoption is an attach to a live owner by definition, but the SSH relay's adopted + // reply omits isReattach; derive it once so the size commit and the reservation agree. + const adoptedResult = { ...ctx.result, isReattach: true } const owner = ctx.result.agentSessionEnsure.owner ptyOwnership.set(ctx.result.id, args.connectionId ?? ptyOwnership.get(ctx.result.id) ?? null) ctx.deps.runtime?.registerPreAllocatedHandleForPty(ctx.result.id, owner.surface.terminalHandle) @@ -86,13 +90,17 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { ...(ctx.env ? { launchEnv: ctx.env } : {}) }) } + // Why: this branch returns before the normal commit site; without this the cache keeps + // whatever the caller requested. + commitRuntimePtySize(ctx, adoptedResult) // Why: the adopted branch returns before the normal settle site, so the // reservation must be resolved here or every later spawn for this pane // awaits a promise that never settles. - resolvePaneSpawnReservation(ctx.paneSpawnReservationKey, ctx.paneSpawnReservation, { - ...ctx.result, - isReattach: true - }) + resolvePaneSpawnReservation( + ctx.paneSpawnReservationKey, + ctx.paneSpawnReservation, + adoptedResult + ) return { id: ctx.result.id, ...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}), @@ -125,7 +133,7 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) { if (!ctx.hostSessionBinding) { persistSshLease() } - ptySizes.set(ctx.result.id, { cols: args.cols, rows: args.rows }) + commitRuntimePtySize(ctx, ctx.result) if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) { ptySizes.delete(ctx.effectiveSessionAppId) } diff --git a/src/main/ipc/pty/runtime/spawn-options.ts b/src/main/ipc/pty/runtime/spawn-options.ts index fcba7770a39..2e81bdeafdb 100644 --- a/src/main/ipc/pty/runtime/spawn-options.ts +++ b/src/main/ipc/pty/runtime/spawn-options.ts @@ -3,6 +3,7 @@ import { LocalPtyProvider } from '../../../providers/local-pty-provider' import { makePaneKey, isTerminalLeafId } from '../../../../shared/stable-pane-id' import { isValidTerminalTabId } from '../../../../shared/terminal-tab-id' import { ptySizes } from '../delivery/visibility-state' +import { shouldSeedPreAttachPtySize } from '../delivery/attached-pty-size' import { CODEX_HOME_ENV_KEYS } from '../host-env/codex-home' import { mergePtyEnvDeletions, @@ -110,7 +111,17 @@ export async function buildRuntimePtySpawnOptions( ctx.effectiveSessionAppId !== undefined ? ptySizes.get(ctx.effectiveSessionAppId) : undefined if (ctx.sessionId !== undefined) { ctx.spawnOptions.sessionId = ctx.sessionId - ptySizes.set(ctx.effectiveSessionAppId ?? ctx.sessionId, { cols: args.cols, rows: args.rows }) + if ( + shouldSeedPreAttachPtySize({ + isFreshSessionId: ctx.isNewDaemonSession, + hasCachedSize: ctx.hadSessionSizeBeforeAttach, + // Why false: runtime callers (CLI, headless serve) have no hidden pane to report, so a + // cached size is the only source that can outrank their requested grid here. + requestIsUnmeasured: false + }) + ) { + ptySizes.set(ctx.effectiveSessionAppId ?? ctx.sessionId, { cols: args.cols, rows: args.rows }) + } } ctx.materializedPaneKey = ctx.hostSessionBinding ? makePaneKey(ctx.hostSessionBinding.tabId, ctx.hostSessionBinding.leafId) diff --git a/src/main/providers/local-pty-provider-spawn-session.test.ts b/src/main/providers/local-pty-provider-spawn-session.test.ts index bb224009117..7513d9c72cb 100644 --- a/src/main/providers/local-pty-provider-spawn-session.test.ts +++ b/src/main/providers/local-pty-provider-spawn-session.test.ts @@ -173,7 +173,10 @@ describe('LocalPtyProvider', () => { expect(second).toEqual({ id: 'serve-session-1', pid: 12345, - isReattach: true + isReattach: true, + // Why published: this attach really moved the PTY, unlike daemon/relay attach, so main + // must record 120x40 rather than preserving the size it held for the session. + attachedGrid: { cols: 120, rows: 40 } }) expect(mockProc.resize).toHaveBeenCalledWith(120, 40) expect(spawnMock).not.toHaveBeenCalled() diff --git a/src/main/providers/local-pty-spawn-state.ts b/src/main/providers/local-pty-spawn-state.ts index 283f8b40c62..d41f858cf98 100644 --- a/src/main/providers/local-pty-spawn-state.ts +++ b/src/main/providers/local-pty-spawn-state.ts @@ -51,8 +51,10 @@ export function reattachLocalPty(id: string, cols: number, rows: number): PtySpa if (!existing) { return null } + let resized = false try { existing.resize(cols, rows) + resized = true } catch { /* Existing PTY may reject resize during teardown; still return the live handle. */ } @@ -60,6 +62,8 @@ export function reattachLocalPty(id: string, cols: number, rows: number): PtySpa id, pid: existing.pid, ...(ptyWslDistroById.has(id) ? { wslDistro: ptyWslDistroById.get(id) ?? null } : {}), - isReattach: true + isReattach: true, + // Why: unlike daemon/relay attach, this one really moved the live PTY to the caller's grid. + ...(resized ? { attachedGrid: { cols, rows } } : {}) } } diff --git a/src/main/providers/pty-spawn-result.ts b/src/main/providers/pty-spawn-result.ts index 43e9665de45..56de0b89115 100644 --- a/src/main/providers/pty-spawn-result.ts +++ b/src/main/providers/pty-spawn-result.ts @@ -57,6 +57,10 @@ export type PtySpawnResult = { snapshotTerminalOwner?: TerminalOwner /** True when the spawn reattached to an existing daemon session. */ isReattach?: boolean + /** Grid the PTY is proven to be at once this spawn settled. Only providers whose attach + * applies the requested size set it; daemon/relay attach leave the live grid alone, so main + * must not read the requested dims back as a measurement (see `resolveCommittedPtySize`). */ + attachedGrid?: { cols: number; rows: number } /** Last OSC title tracked by the daemon session the snapshot came from. * Seeds main's terminal title records after a relaunch; never replayed * into a terminal. */ diff --git a/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts b/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts index 59fe493118a..da5d824ef62 100644 --- a/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts +++ b/src/main/runtime/orca-runtime-create-pty-headless-terminal-state.ts @@ -163,6 +163,17 @@ export class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends OrcaRuntimeWi }) } + /** Public: reflow an already-created model onto a grid the PROVIDER proved — a reattach learns + * the live session's real size only from its spawn reply, after live bytes may have lazily + * created the model at the 80x24 default. Not onExternalPtyResize: nothing measured a pane + * here, so the renderer-geometry baselines behind mobile take-back must stay untouched. */ + reflowHeadlessTerminalToPtyGrid(ptyId: string, cols: number, rows: number): void { + if (cols <= 0 || rows <= 0) { + return + } + this.resizeHeadlessTerminal(ptyId, cols, rows) + } + // Public: desktop-initiated clears (ipc/pty.ts) must also drop this mobile // mirror or a resubscribing mobile client resurrects the cleared scrollback. async clearHeadlessTerminalBuffer(ptyId: string): Promise { diff --git a/src/main/runtime/orca-runtime-tests/reattach-headless-grid.spec.ts b/src/main/runtime/orca-runtime-tests/reattach-headless-grid.spec.ts new file mode 100644 index 00000000000..43ba687c4bf --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/reattach-headless-grid.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest' +import { createRuntime, syncSinglePty } from '../orca-runtime-test-fixtures.spec' + +describe('headless model grid after a reattach', () => { + it('reflows a model that live bytes created at the 80x24 default onto the PTY grid', async () => { + const runtime = createRuntime() + // No controller size: mirrors a reattach whose real grid main only learns from the reply. + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => null + }) + syncSinglePty(runtime, 'pty-1') + + runtime.onPtyData('pty-1', 'user@host % claude\r\n', 100) + await expect( + runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 100 }) + ).resolves.toMatchObject({ cols: 80, rows: 24 }) + + runtime.reflowHeadlessTerminalToPtyGrid('pty-1', 211, 57) + + await expect( + runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 100 }) + ).resolves.toMatchObject({ cols: 211, rows: 57, source: 'headless' }) + }) + + it('never creates a model for a PTY that has none', async () => { + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => null + }) + syncSinglePty(runtime, 'pty-1') + + runtime.reflowHeadlessTerminalToPtyGrid('pty-1', 211, 57) + runtime.onPtyData('pty-1', 'hello\r\n', 100) + + // Why it matters: commit reflows every reattach, and pre-creating here would defeat the + // renderer-authority gate that deliberately leaves the model unseeded. + await expect( + runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 100 }) + ).resolves.toMatchObject({ cols: 80, rows: 24 }) + }) +}) diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 9d223233ef2..05739f4fe39 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -34,6 +34,7 @@ await import('./orca-runtime-tests/terminal-side-effect-facts-part-03.spec') await import('./orca-runtime-tests/decorative-title-fact-throttle.spec') await import('./orca-runtime-tests/headless-snapshots.spec') await import('./orca-runtime-tests/headless-snapshots-part-02.spec') +await import('./orca-runtime-tests/reattach-headless-grid.spec') await import('./orca-runtime-tests/agent-status-and-waits.spec') await import('./orca-runtime-tests/agent-status-and-waits-part-02.spec') await import('./orca-runtime-tests/agent-status-and-waits-part-03.spec')