mirror of
https://github.com/stablyai/orca.git
synced 2026-10-01 16:02:03 +00:00
fix(terminal): stop a hidden pane's unmeasured 80x24 from overwriting a live PTY's size on reattach
A pane that mounts while display:none (app relaunch or update with the floating terminal panel closed, a non-active floating tab, any background tab) cannot fit its container, so it reattaches with xterm's default 80x24. Main wrote those placeholder dims into `ptySizes` unconditionally, both before and after the attach. A daemon attach never resizes the live session, so the real PTY stayed at its wide grid while main's hidden headless model was created (or reflowed after renderer hydration) at 80 columns. Every byte the agent emitted while hidden was parsed 80 wide; reveal restored that image into the pane: rows clamped at column 80 with CHA fill, the status bar interleaved into response text, and for alt-screen TUIs the whole screen stuck in an 80x24 corner until a real resize forced a repaint. Scrollback damage was permanent. Fix, main-side only: - Pre-attach: seed `ptySizes` only for a genuinely fresh session id, or a measured request with nothing cached. A hidden reattach writes nothing. - Commit: on reattach, record the provider's proven grid (`attachedGrid`, set only by the local provider whose attach really resizes), then the reply's `snapshotCols/Rows` (the daemon emulator's grid), then the size main already held, and only then the request. - Reflow an already-created model to that grid after the seed block, so bytes that arrived before the reply no longer leave an 80x24 model. Both the ipc and runtime spawn paths take the same authority module. Renderer and wire formats are unchanged; `PtySpawnResult` is main-internal. Reproduced deterministically: close the floating panel with Claude Code streaming at 211x57, kill only the Electron main process so the daemon survives, relaunch. Main's cache read 80x24 against an applied 211x57 and the reveal snapshot was 80 columns wide; replaying the recorded bytes through an 80-column emulator reproduced the field screenshot. Relaunch with the panel open, and a fresh spawn, keep the wide grid.
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
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('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 only 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('leaves a fresh spawn model alone: its emulator already starts at the request', () => {
|
||||
const reflow = vi.fn()
|
||||
commitAttachedPtySize({
|
||||
result: { id: 'pty-commit' },
|
||||
requested: REQUESTED,
|
||||
cachedBeforeAttach: CACHED,
|
||||
reflowHeadlessTerminalToPtyGrid: reflow
|
||||
})
|
||||
expect(ptySizes.get('pty-commit')).toEqual(REQUESTED)
|
||||
expect(reflow).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
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.isFinite(cols) &&
|
||||
Number.isFinite(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<PtySpawnResult, 'isReattach' | 'attachedGrid' | 'snapshotCols' | 'snapshotRows'>
|
||||
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. Why only reattach: live bytes can lazily create
|
||||
* the model at the 80x24 default before the attach reply reveals the session's real grid, and a
|
||||
* seed skips an existing model entirely; a fresh spawn's model already starts at the request. */
|
||||
export function reflowReattachedHeadlessTerminal(args: {
|
||||
result: Pick<PtySpawnResult, 'id' | 'isReattach'>
|
||||
committedSize: PtyGrid
|
||||
reflowHeadlessTerminalToPtyGrid: HeadlessReflow
|
||||
}): void {
|
||||
if (args.result.isReattach === true) {
|
||||
args.reflowHeadlessTerminalToPtyGrid?.(
|
||||
args.result.id,
|
||||
args.committedSize.cols,
|
||||
args.committedSize.rows
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Record the settled grid, then reflow for a reattach. 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)
|
||||
reflowReattachedHeadlessTerminal({ ...args, committedSize })
|
||||
return committedSize
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -24,10 +24,12 @@ import {
|
||||
} from '../pane/launch-authority'
|
||||
import type { PtyIpcSpawnState } from './spawn-state'
|
||||
import { persistPtyIpcSpawnCommit } from './spawn-commit-persist'
|
||||
import { reflowReattachedHeadlessTerminal } from '../delivery/attached-pty-size'
|
||||
|
||||
export async function commitPtyIpcSpawn(ctx: PtyIpcSpawnState): Promise<PtySpawnResult> {
|
||||
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<PtySpawn
|
||||
ctx.deps.runtime.seedHeadlessTerminal(ctx.result.id, ctx.result.replay)
|
||||
}
|
||||
}
|
||||
// Why after the seed: a seed skips an existing model, and live bytes may have lazily created
|
||||
// one at the 80x24 default before the attach reply revealed the session's real grid.
|
||||
reflowReattachedHeadlessTerminal({
|
||||
result: ctx.result,
|
||||
committedSize,
|
||||
reflowHeadlessTerminalToPtyGrid: ctx.deps.runtime?.reflowHeadlessTerminalToPtyGrid?.bind(
|
||||
ctx.deps.runtime
|
||||
)
|
||||
})
|
||||
if (
|
||||
typeof args.worktreeId === 'string' &&
|
||||
args.worktreeId.length > 0 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
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<typeof makeRuntime>): 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<typeof makeRuntime>
|
||||
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).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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 })
|
||||
})
|
||||
})
|
||||
@@ -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 { commitAttachedPtySize } from '../delivery/attached-pty-size'
|
||||
import {
|
||||
shouldSkipCodexHomeEnvForWindowsShell,
|
||||
recordCodexPaneAccountForSpawn,
|
||||
@@ -125,7 +126,14 @@ export async function commitRuntimePtySpawn(ctx: RuntimePtySpawnState) {
|
||||
if (!ctx.hostSessionBinding) {
|
||||
persistSshLease()
|
||||
}
|
||||
ptySizes.set(ctx.result.id, { cols: args.cols, rows: args.rows })
|
||||
commitAttachedPtySize({
|
||||
result: ctx.result,
|
||||
requested: { cols: args.cols, rows: args.rows },
|
||||
cachedBeforeAttach: ctx.sessionSizeBeforeAttach,
|
||||
reflowHeadlessTerminalToPtyGrid: ctx.deps.runtime?.reflowHeadlessTerminalToPtyGrid?.bind(
|
||||
ctx.deps.runtime
|
||||
)
|
||||
})
|
||||
if (ctx.effectiveSessionAppId !== undefined && ctx.effectiveSessionAppId !== ctx.result.id) {
|
||||
ptySizes.delete(ctx.effectiveSessionAppId)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 } } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user