diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 39ec0bd4989..a877df4270d 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -23,7 +23,7 @@ import { type TerminalViewAttributeResponder } from './terminal-view-attribute-responder' import { installDeviceAttributesResponder } from './startup-device-attributes-responder' -import type { TerminalSnapshot, TerminalModes } from './types' +import type { TerminalSnapshot } from './types' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' import type { TerminalCursorContext } from '../../shared/terminal-composer-draft' import { readTerminalCursorLineContext } from '../../shared/terminal-cursor-line-context' @@ -241,7 +241,7 @@ export class HeadlessEmulator { } getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot { - const modes = this.getModes() + const modes = readTerminalModes(this.terminal) // Absolute cursor restore avoids wrap-pending drift; carry the saved register for DECRC. const serializedAnsi = serializeWithAbsoluteCursor( this.serializer, @@ -345,6 +345,11 @@ export class HeadlessEmulator { this.restoredOscLinks = links?.slice() ?? [] } + /** Growing keeps every retained row; shrinking drops the oldest. */ + setScrollback(rows: number): void { + this.terminal.options.scrollback = rows + } + clearScrollback(): void { this.restoredOscLinks = [] this.terminal.clear() @@ -354,8 +359,4 @@ export class HeadlessEmulator { this.disposed = true this.terminal.dispose() } - - private getModes(): TerminalModes { - return readTerminalModes(this.terminal) - } } diff --git a/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts b/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts index 64d37a5ccf1..ce4e5f95a47 100644 --- a/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts +++ b/src/main/runtime/terminal-subscriber-driven-daemon-attach.test.ts @@ -13,6 +13,7 @@ import { encodeTerminalStreamFrame, encodeTerminalStreamJson } from '../../shared/terminal-stream-protocol' +import { DAEMON_SESSION_SCROLLBACK_ROWS } from '../daemon/daemon-session-scrollback-window' const WORKTREE_ID = 'repo-1::/tmp/wt' const PTY_ID = `${WORKTREE_ID}@@1a2b3c4d` @@ -24,7 +25,7 @@ type RuntimeInternals = { state?: { connected?: boolean; connectionId?: string | null } ) => unknown issuePtyHandle: (pty: unknown) => string - headlessTerminals: Map + headlessTerminals: Map }> refreshPtyWorktreeRecordsWithControllerInventory: ( worktrees: [], targetWorktreeId: string | null @@ -263,6 +264,19 @@ function snapshotText(harness: ReturnType): string { .join('') } +function numberedLines(from: number, to: number): string { + return Array.from( + { length: to - from + 1 }, + (_, i) => `L${String(from + i).padStart(4, '0')}` + ).join('\r\n') +} + +async function retainedLines(runtime: OrcaRuntimeService, ptyId: string): Promise { + await internals(runtime).headlessTerminals.get(ptyId)?.writeChain + const snapshot = await runtime.serializeMainTerminalBuffer(ptyId, { scrollbackRows: 10_000 }) + return `${snapshot?.scrollbackAnsi ?? ''}${snapshot?.data ?? ''}`.match(/L\d{4}/g) ?? [] +} + describe('subscriber-driven daemon attach (never-activated tab)', () => { it('attaches on first desktop multiplex subscribe and streams bytes (snapshot-null daemon)', async () => { const { runtime, model, handle, mountSpy } = setupNeverAttachedDaemonSession({ @@ -431,6 +445,43 @@ describe('subscriber-driven daemon attach (never-activated tab)', () => { expect(read.tail.join('\n')).toContain('worker still printing') }) + it('keeps unviewed survivors to the daemon window and grows a viewed one from it', async () => { + const runtime = new OrcaRuntimeService() + const model = createDaemonProviderModel({ snapshotCapable: false }) + model.bind(runtime) + runtime.setPtyController(model.controller as never) + const ptyIds = Array.from({ length: 10 }, (_, i) => `${WORKTREE_ID}@@0000000${i}`) + const records = ptyIds.map((ptyId) => { + model.sessions.set(ptyId, { cols: 137, rows: 41, attached: false, screen: '' }) + return internals(runtime).recordPtyWorktree(ptyId, WORKTREE_ID, { connected: true }) + }) + await internals(runtime).refreshPtyWorktreeRecordsWithControllerInventory([], null) + await vi.waitFor(() => expect(model.attachCalls).toHaveLength(10)) + + for (const ptyId of ptyIds) { + expect(model.emitData(ptyId, numberedLines(1, 3000))).toBe(true) + } + const retained = await Promise.all(ptyIds.map((ptyId) => retainedLines(runtime, ptyId))) + const windowRows = DAEMON_SESSION_SCROLLBACK_ROWS + 41 + expect(retained.map((lines) => lines.length)).toEqual(ptyIds.map(() => windowRows)) + expect(retained[0].at(-1)).toBe('L3000') + + // The first view paints exactly the rows the mirror kept, then the mirror grows past the window. + const harness = startMultiplex(runtime) + await vi.waitFor(() => expect(harness.handlers.has(0)).toBe(true)) + sendSubscribe(harness, 1, internals(runtime).issuePtyHandle(records[0])) + await waitForSubscribed(harness, 1) + expect(snapshotText(harness).match(/L\d{4}/g)).toEqual(retained[0]) + model.emitData(ptyIds[0], `\r\n${numberedLines(3001, 6000)}`) + model.emitData(ptyIds[1], `\r\n${numberedLines(3001, 6000)}`) + await vi.waitFor(() => expect(outputText(harness)).toContain('L6000')) + expect(await retainedLines(runtime, ptyIds[0])).toEqual([ + ...retained[0], + ...numberedLines(3001, 6000).split('\r\n') + ]) + expect(await retainedLines(runtime, ptyIds[1])).toHaveLength(windowRows) + }) + it('does not subscriber-attach or provider-read a session this app already spawned', async () => { const { runtime, model, handle } = setupNeverAttachedDaemonSession({ snapshotCapable: true, diff --git a/src/main/runtime/terminal/layout/terminal-view-presence.ts b/src/main/runtime/terminal/layout/terminal-view-presence.ts index 9fcc36a402c..306b6197720 100644 --- a/src/main/runtime/terminal/layout/terminal-view-presence.ts +++ b/src/main/runtime/terminal/layout/terminal-view-presence.ts @@ -41,6 +41,10 @@ export abstract class OrcaRuntimeWithNotifyRemoteTerminalViewPresenceChanged ext ): Promise protected notifyRemoteTerminalViewPresenceChanged(ptyId: string): void { + // A viewed survivor's mirror grows to full depth from the rows it kept. + if (this.hasTerminalViewer(ptyId) && this.unviewedSurvivorMirrorPtys.delete(ptyId)) { + this.headlessTerminals.get(ptyId)?.emulator.setScrollback(this.headlessTerminalScrollbackRows) + } try { this.onRemoteTerminalViewPresenceChanged?.(ptyId) } catch (err) { diff --git a/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts b/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts index 0588bfa131b..c46c1d4bbdd 100644 --- a/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts +++ b/src/main/runtime/terminal/lifecycle/pty-controller-inventory-refresh.ts @@ -190,6 +190,12 @@ export abstract class OrcaRuntimeWithRefreshPtyWorktreeRecordsWithControllerInve this.restoredOrchestrationAuthorityByPtyId.delete(session.id) } pty.controllerTitle = session.title?.trim() || null + if ( + !this.hasTerminalViewer(session.id) && + this.mainDaemonAttachments.isKnownUnattachedLocal(session.id) + ) { + this.unviewedSurvivorMirrorPtys.add(session.id) + } this.mainDaemonAttachments.reconcile(session.id) } // Do not serialize relay probes or let one throw abort inventory cleanup. diff --git a/src/main/runtime/terminal/lifecycle/pty-exit-lifecycle.ts b/src/main/runtime/terminal/lifecycle/pty-exit-lifecycle.ts index 6ddbcb3fe6f..07776938833 100644 --- a/src/main/runtime/terminal/lifecycle/pty-exit-lifecycle.ts +++ b/src/main/runtime/terminal/lifecycle/pty-exit-lifecycle.ts @@ -162,6 +162,7 @@ export abstract class OrcaRuntimeWithOnPtyExit extends OrcaRuntimeWithOnClientDi this.mobileSubscribers.delete(ptyId) this.terminalViewSubscribers.clearSubscribers(ptyId) this.mainDaemonAttachments.forget(ptyId) + this.unviewedSurvivorMirrorPtys.delete(ptyId) this.mobileDisplayModes.delete(ptyId) this.resizeListeners.delete(ptyId) this.lastRendererSizes.delete(ptyId) diff --git a/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts b/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts index 1ebd96ff7c5..fec9eeba504 100644 --- a/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts +++ b/src/main/runtime/terminal/lifecycle/terminal-view-resources.ts @@ -53,6 +53,9 @@ export abstract class OrcaRuntimeWithFitOverrideListeners extends OrcaRuntimeWit protected providerSnapshotPreferredPtys = new Set() + // Survivors main attached with no view hold the daemon's live window, not a full-depth copy. + protected unviewedSurvivorMirrorPtys = new Set() + protected providerModeTrackersByPtyId = new Map() protected providerModeSnapshotScansByPtyId = new Map< diff --git a/src/main/runtime/terminal/output/headless-terminal-state.ts b/src/main/runtime/terminal/output/headless-terminal-state.ts index b23570f56f7..3726f89ad3b 100644 --- a/src/main/runtime/terminal/output/headless-terminal-state.ts +++ b/src/main/runtime/terminal/output/headless-terminal-state.ts @@ -1,6 +1,7 @@ import { OrcaRuntimeWithMaybeHydrateHeadlessFromRenderer } from './headless-terminal-hydration' import type { RuntimeHeadlessTerminal } from '../../runtime-terminal-state-records' import { HeadlessEmulator } from '../../../daemon/headless-emulator' +import { DAEMON_SESSION_SCROLLBACK_ROWS } from '../../../daemon/daemon-session-scrollback-window' import { shouldForwardHeadlessTerminalQueryReply } from '../../headless-terminal-query-reply-policy' import { isNativeWindowsConptyPty } from '../../terminal-model-query-authority' import { getTerminalViewAttributes } from '../../terminal-view-attribute-store' @@ -41,7 +42,9 @@ export abstract class OrcaRuntimeWithCreatePtyHeadlessTerminalState extends Orca const emulator = new HeadlessEmulator({ cols: dims.cols, rows: dims.rows, - scrollback: this.headlessTerminalScrollbackRows, + scrollback: this.unviewedSurvivorMirrorPtys.has(ptyId) + ? Math.min(DAEMON_SESSION_SCROLLBACK_ROWS, this.headlessTerminalScrollbackRows) + : this.headlessTerminalScrollbackRows, pathFlavor, remotePosixFileUriAuthority: !!this.ptysById.get(ptyId)?.connectionId && pathFlavor !== 'win32',