diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index dc692a7d7..fb9208caf 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -11366,6 +11366,7 @@ describe("chat event replay buffer (resumable chat streams)", () => { describe("terminal byte-offset streaming, history paging, and resize ownership", () => { // 5000 ASCII bytes so byte offsets equal string indices in assertions. const TRANSCRIPT_CONTENT = "0123456789".repeat(500); + const SCREEN_CSI = "\x1b[?1049h\x1b[Hhello"; beforeEach(() => { publishMock.mockReset(); @@ -11411,6 +11412,11 @@ describe("terminal byte-offset streaming, history paging, and resize ownership", const restoreDesktopSizeBySessionId = vi.fn().mockReturnValue(true); const hasLivePty = vi.fn().mockReturnValue(true); const writeBySessionId = vi.fn().mockReturnValue(true); + const readScreenSnapshot = vi.fn((id: string) => ( + id === "session-1" + ? { cols: 80, rows: 24, bufferType: "alternate" as const, serialized: SCREEN_CSI } + : null + )); let sessionAvailable = true; const base = createHostArgs(projectRoot, []); const host = createSyncHostService({ @@ -11444,6 +11450,7 @@ describe("terminal byte-offset streaming, history paging, and resize ownership", resizeBySessionId, restoreDesktopSizeBySessionId, hasLivePty, + readScreenSnapshot, enrichSessions: (rows: unknown[]) => rows, }, } as unknown as Parameters[0]); @@ -11457,6 +11464,7 @@ describe("terminal byte-offset streaming, history paging, and resize ownership", resizeBySessionId, restoreDesktopSizeBySessionId, hasLivePty, + readScreenSnapshot, setSessionAvailable: (available: boolean) => { sessionAvailable = available; }, }; } @@ -11517,6 +11525,7 @@ describe("terminal byte-offset streaming, history paging, and resize ownership", startOffset: 4_988, endOffset: 5_000, }); + expect((delta.payload as { screen?: unknown }).screen).toBeUndefined(); expect(readTranscriptSnapshot).toHaveBeenCalledWith({ sessionId: "session-1", maxBytes: 32_000, @@ -11537,6 +11546,12 @@ describe("terminal byte-offset streaming, history paging, and resize ownership", transcript: TRANSCRIPT_CONTENT.slice(5_000 - 1_024), startOffset: 5_000 - 1_024, endOffset: 5_000, + screen: { + cols: 80, + rows: 24, + bufferType: "alternate", + serialized: SCREEN_CSI, + }, }); expect((full.payload as { delta?: boolean }).delta).toBeUndefined(); expect(readTranscriptTail).not.toHaveBeenCalled(); @@ -11580,6 +11595,151 @@ describe("terminal byte-offset streaming, history paging, and resize ownership", } }); + it("omits an oversized current-screen serialize instead of slicing CSI", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const { host, readScreenSnapshot } = createTerminalHost(projectRoot); + readScreenSnapshot.mockReturnValue({ + cols: 80, + rows: 24, + bufferType: "alternate", + serialized: "x".repeat(256_001), + }); + let client: Awaited> | null = null; + try { + client = await connectTerminalPeer( + await host.waitUntilListening(), + host.getBootstrapToken(), + "ios-terminal-screen-cap", + ); + client.ws.send(encodeSyncEnvelope({ + type: "terminal_subscribe", + requestId: "sub-screen-cap", + payload: { sessionId: "session-1", maxBytes: 32_000 }, + })); + const snapshot = await nextResponse(client.envelopes, "terminal_snapshot", "sub-screen-cap"); + expect((snapshot.payload as { screen?: unknown }).screen).toBeUndefined(); + expect((snapshot.payload as { transcript: string }).transcript).toBe(TRANSCRIPT_CONTENT); + } finally { + try { client?.ws.close(); } catch { /* ignore */ } + await host.dispose(); + cleanup(); + } + }); + + it("keeps the sync socket open when snapshot catch-up overflows unreconstructable events", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const { host, readTranscriptSnapshot } = createTerminalHost(projectRoot); + let resolveFirstSnapshot!: (snapshot: { data: string; startOffset: number; endOffset: number }) => void; + readTranscriptSnapshot.mockImplementationOnce(() => new Promise<{ + data: string; + startOffset: number; + endOffset: number; + }>((resolve) => { + resolveFirstSnapshot = resolve; + })); + let client: Awaited> | null = null; + try { + client = await connectTerminalPeer( + await host.waitUntilListening(), + host.getBootstrapToken(), + "ios-terminal-catchup-overflow", + ); + client.ws.send(encodeSyncEnvelope({ + type: "terminal_subscribe", + requestId: "overflow-untracked", + payload: { sessionId: "session-1", maxBytes: 32_000 }, + })); + await waitForValue( + () => readTranscriptSnapshot.mock.calls.length > 0 ? true : null, + "overflow terminal snapshot capture", + ); + for (let i = 0; i < 257; i += 1) { + host.handlePtyData({ + sessionId: "session-1", + ptyId: "pty-1", + data: "x", + offset: null, + }); + } + resolveFirstSnapshot({ data: TRANSCRIPT_CONTENT, startOffset: 0, endOffset: 5_000 }); + + const snapshot = await nextResponse(client.envelopes, "terminal_snapshot", "overflow-untracked"); + expect(snapshot.payload).toMatchObject({ + sessionId: "session-1", + transcript: TRANSCRIPT_CONTENT, + screen: { serialized: SCREEN_CSI }, + }); + expect(client.ws.readyState).toBe(WebSocket.OPEN); + expect(client.closeEvents).toEqual([]); + } finally { + try { client?.ws.close(); } catch { /* ignore */ } + await host.dispose(); + cleanup(); + } + }); + + it("sends the last captured transcript when capture attempts exhaust without ever failing", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const { host, readTranscriptSnapshot } = createTerminalHost(projectRoot); + const captures: Array<(snapshot: { data: string; startOffset: number; endOffset: number }) => void> = []; + readTranscriptSnapshot.mockImplementation(() => new Promise<{ + data: string; + startOffset: number; + endOffset: number; + }>((resolve) => { + captures.push(resolve); + })); + let client: Awaited> | null = null; + try { + client = await connectTerminalPeer( + await host.waitUntilListening(), + host.getBootstrapToken(), + "ios-terminal-capture-exhausted", + ); + client.ws.send(encodeSyncEnvelope({ + type: "terminal_subscribe", + requestId: "exhausted-recapture", + payload: { sessionId: "session-1", maxBytes: 32_000 }, + })); + await waitForValue( + () => captures.length > 0 ? true : null, + "first exhausted-recapture snapshot capture", + ); + host.handlePtyData({ + sessionId: "session-1", + ptyId: "pty-1", + data: "x", + offset: 6_000, + }); + captures[0]!({ data: "CAPTURE-1", startOffset: 0, endOffset: 5_000 }); + for (let attempt = 1; attempt < 4; attempt += 1) { + await waitForValue( + () => captures.length > attempt ? true : null, + `exhausted-recapture snapshot capture ${attempt + 1}`, + ); + captures[attempt]!({ + data: `CAPTURE-${attempt + 1}`, + startOffset: 0, + endOffset: 5_000, + }); + } + + const snapshot = await nextResponse(client.envelopes, "terminal_snapshot", "exhausted-recapture"); + expect(readTranscriptSnapshot).toHaveBeenCalledTimes(4); + expect(snapshot.payload).toMatchObject({ + sessionId: "session-1", + transcript: "CAPTURE-4", + screen: { serialized: SCREEN_CSI }, + }); + expect(client.ws.readyState).toBe(WebSocket.OPEN); + expect(client.closeEvents).toEqual([]); + } finally { + try { client?.ws.close(); } catch { /* ignore */ } + await host.dispose(); + cleanup(); + } + }); + it("replaces with a tail snapshot when sinceOffset already equals the transcript end", async () => { const { projectRoot, cleanup } = createTempProjectRoot(); const { host, readTranscriptTail, readTranscriptSnapshot, readTranscriptRange } = createTerminalHost(projectRoot); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 6ddd0b8a2..230939fec 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -99,6 +99,7 @@ import type { SyncTerminalInputAckPayload, SyncTerminalInputPayload, SyncTerminalSnapshotPayload, + SyncTerminalScreenSnapshot, } from "../../../../desktop/src/shared/types"; import { SYNC_APPLICATION_COMPRESSION_THRESHOLD_BYTES, @@ -486,6 +487,7 @@ const MAX_TERMINAL_HISTORY_PAGE_BYTES = 524_288; const MAX_PENDING_TERMINAL_SNAPSHOT_EVENTS = 256; const MAX_PENDING_TERMINAL_SNAPSHOT_BYTES = 2_000_000; const MAX_TERMINAL_SNAPSHOT_CAPTURE_ATTEMPTS = 4; +const MAX_TERMINAL_SCREEN_SERIALIZED_CHARS = 256_000; const PEER_BACKPRESSURE_BYTES = 4 * 1024 * 1024; const REQUIRED_SEND_MAX_BUFFERED_BYTES = 16 * 1024 * 1024; const SEND_AND_WAIT_TIMEOUT_MS = 15_000; @@ -2999,7 +3001,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { && peer.ws.readyState === WebSocket.OPEN; } - function isCurrentTerminalSnapshotBarrier( + function isActiveTerminalSnapshotBarrier( peer: PeerState, sessionId: string, barrier: PendingTerminalSnapshotBarrier, @@ -3007,8 +3009,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { ): boolean { const currentBarrier = peer.pendingTerminalSnapshots.get(sessionId); return isPeerLifecycleCurrent(peer, lifecycleGeneration) - && currentBarrier === barrier - && !barrier.failed; + && currentBarrier === barrier; } function clearTerminalSnapshotBarrier( @@ -3052,6 +3053,29 @@ export function createSyncHostService(args: SyncHostServiceArgs) { barrier.queuedBytes = queuedBytes; } + function readTerminalScreenSnapshot(sessionId: string): SyncTerminalScreenSnapshot | null { + const screen = args.ptyService.readScreenSnapshot?.(sessionId) ?? null; + if (!screen?.serialized) return null; + if (screen.serialized.length > MAX_TERMINAL_SCREEN_SERIALIZED_CHARS) return null; + if (!Number.isFinite(screen.cols) || !Number.isFinite(screen.rows)) return null; + if (screen.cols < 1 || screen.rows < 1) return null; + return { + cols: Math.floor(screen.cols), + rows: Math.floor(screen.rows), + bufferType: screen.bufferType === "alternate" ? "alternate" : "normal", + serialized: screen.serialized, + }; + } + + function withTerminalScreen( + sessionId: string, + snapshot: SyncTerminalSnapshotPayload, + ): SyncTerminalSnapshotPayload { + if (snapshot.delta === true) return snapshot; + const screen = readTerminalScreenSnapshot(sessionId); + return screen ? { ...snapshot, screen } : snapshot; + } + function failTerminalSnapshotBarrier( peer: PeerState, sessionId: string, @@ -3068,11 +3092,9 @@ export function createSyncHostService(args: SyncHostServiceArgs) { queuedBytes: barrier.queuedBytes, peerDeviceId: peer.metadata?.deviceId ?? peer.pairedDeviceId ?? null, }); - try { - peer.ws.close(4001, "Terminal snapshot catch-up failed"); - } catch { - // The failed barrier still prevents an out-of-order or lossy flush. - } + // Do not close the controller socket. A hot Claude TUI can overflow the + // catch-up queue on LAN; tearing down sync blanks every other surface on + // that phone. The subscribe loop sends the last captured snapshot instead. } function enqueueTerminalSnapshotEvent( @@ -7719,9 +7741,41 @@ export function createSyncHostService(args: SyncHostServiceArgs) { : null; let forceReplacement = false; let barrierCompleted = false; + let lastCapturedTranscript: { + data: string; + startOffset: number | null; + endOffset: number | null; + } | null = null; + const sendReplacingSnapshot = ( + session: ReturnType, + transcriptSnapshot: { + data: string; + startOffset: number | null; + endOffset: number | null; + } | null, + ): boolean => { + const snapshot = withTerminalScreen(sessionId, { + sessionId, + transcript: transcriptSnapshot?.data ?? "", + status: session?.status ?? null, + runtimeState: session?.runtimeState ?? null, + lastOutputPreview: session?.lastOutputPreview ?? null, + capturedAt: nowIso(), + startOffset: transcriptSnapshot?.startOffset ?? null, + endOffset: transcriptSnapshot?.endOffset ?? null, + live: args.ptyService.hasLivePty(sessionId), + }); + return sendRequired(peer, "terminal_snapshot", snapshot, envelope.requestId); + }; try { while (barrier.captureAttempt < MAX_TERMINAL_SNAPSHOT_CAPTURE_ATTEMPTS) { - if (!isCurrentTerminalSnapshotBarrier(peer, sessionId, barrier, lifecycleGeneration)) break; + if (!isActiveTerminalSnapshotBarrier(peer, sessionId, barrier, lifecycleGeneration)) break; + if (barrier.failed) { + if (sendReplacingSnapshot(args.sessionService.get(sessionId), lastCapturedTranscript)) { + barrierCompleted = true; + } + break; + } barrier.captureAttempt += 1; const session = args.sessionService.get(sessionId); const transcriptSnapshot = session @@ -7735,7 +7789,14 @@ export function createSyncHostService(args: SyncHostServiceArgs) { "Sync operation aborted.", ) : null; - if (!isCurrentTerminalSnapshotBarrier(peer, sessionId, barrier, lifecycleGeneration)) break; + if (transcriptSnapshot) lastCapturedTranscript = transcriptSnapshot; + if (!isActiveTerminalSnapshotBarrier(peer, sessionId, barrier, lifecycleGeneration)) break; + if (barrier.failed) { + if (sendReplacingSnapshot(session, lastCapturedTranscript)) { + barrierCompleted = true; + } + break; + } const flush = planTerminalSnapshotFlush( barrier, @@ -7770,7 +7831,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { startsAtUtf8Boundary && snapshotBytes.length === transcriptSnapshot.endOffset - transcriptSnapshot.startOffset ) { - snapshot = { + snapshot = withTerminalScreen(sessionId, { sessionId, transcript: snapshotBytes.subarray(byteStart).toString("utf8"), status: session?.status ?? null, @@ -7781,10 +7842,10 @@ export function createSyncHostService(args: SyncHostServiceArgs) { endOffset: transcriptSnapshot.endOffset, delta: true, live: args.ptyService.hasLivePty(sessionId), - }; + }); } } - snapshot ??= { + snapshot ??= withTerminalScreen(sessionId, { sessionId, transcript: transcriptSnapshot?.data ?? "", status: session?.status ?? null, @@ -7794,7 +7855,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { startOffset: transcriptSnapshot?.startOffset ?? null, endOffset: transcriptSnapshot?.endOffset ?? null, live: args.ptyService.hasLivePty(sessionId), - }; + }); if (!sendRequired(peer, "terminal_snapshot", snapshot, envelope.requestId)) break; barrierCompleted = true; for (const event of flush.events) { @@ -7810,10 +7871,13 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } if ( !barrierCompleted - && isCurrentTerminalSnapshotBarrier(peer, sessionId, barrier, lifecycleGeneration) - && barrier.captureAttempt >= MAX_TERMINAL_SNAPSHOT_CAPTURE_ATTEMPTS + && isActiveTerminalSnapshotBarrier(peer, sessionId, barrier, lifecycleGeneration) + && (barrier.failed || barrier.captureAttempt >= MAX_TERMINAL_SNAPSHOT_CAPTURE_ATTEMPTS) ) { failTerminalSnapshotBarrier(peer, sessionId, barrier, "capture_did_not_reach_stable_offset"); + if (sendReplacingSnapshot(args.sessionService.get(sessionId), lastCapturedTranscript)) { + barrierCompleted = true; + } } } catch (error) { if (peer.pendingTerminalSnapshots.get(sessionId) === barrier) { diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index e63b38274..4db019860 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -7700,6 +7700,108 @@ describe("ptyService", () => { expect(service.restoreDesktopSizeBySessionId(sessionId)).toBe(true); expect(mockPty.resize).toHaveBeenLastCalledWith(80, 24); }); + + it("ignores desktop fit-resizes while a mobile viewport owns the PTY", async () => { + const { service, mockPty } = createHarness(); + const { ptyId, sessionId } = await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24 }); + + expect(service.resizeBySessionId(sessionId, 60, 20, { source: "mobile" })).toBe(true); + vi.mocked(mockPty.resize).mockClear(); + + service.resize({ ptyId, cols: 375, rows: 53 }); + expect(mockPty.resize).not.toHaveBeenCalled(); + + expect(service.restoreDesktopSizeBySessionId(sessionId)).toBe(true); + expect(mockPty.resize).toHaveBeenLastCalledWith(375, 53); + }); + + it("ignores resizeTerminal while a mobile viewport owns the PTY", async () => { + const { service, mockPty } = createHarness(); + const { ptyId, sessionId } = await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24 }); + + expect(service.resizeBySessionId(sessionId, 60, 20, { source: "mobile" })).toBe(true); + vi.mocked(mockPty.resize).mockClear(); + + expect(service.resizeTerminal({ ptyId, cols: 200, rows: 40 })).toEqual({ + ok: true, + cols: 200, + rows: 40, + }); + expect(mockPty.resize).not.toHaveBeenCalled(); + + expect(service.restoreDesktopSizeBySessionId(sessionId)).toBe(true); + expect(mockPty.resize).toHaveBeenLastCalledWith(200, 40); + }); + + it("returns null from readScreenSnapshot for an unknown session", async () => { + const { service } = createHarness(); + expect(service.readScreenSnapshot("missing")).toBeNull(); + expect(service.readScreenSnapshot("")).toBeNull(); + }); + + it("hydrates stored screens from scrollback-0 CSI instead of the full snapshot serialize", async () => { + const { service } = createHarness(); + const snapshotPath = path.join( + "/tmp/test-project", + ".ade", + "cache", + "terminal-snapshots", + "ended-session.json", + ); + mocks.fileContents.set(snapshotPath, JSON.stringify({ + version: 1, + terminalId: "ended-session", + cols: 80, + rows: 24, + capturedAt: "2026-08-14T00:00:00.000Z", + status: "exited", + runtimeState: "exited", + bufferType: "alternate", + cursorX: 0, + cursorY: 0, + baseY: 0, + viewportY: 0, + serialized: "SCROLLBACK-CSI".repeat(20_000), + screenSerialized: "\x1b[?1049hCURRENT", + visibleRows: [], + })); + + expect(service.readScreenSnapshot("ended-session")).toEqual({ + cols: 80, + rows: 24, + bufferType: "alternate", + serialized: "\x1b[?1049hCURRENT", + }); + }); + + it("omits stored screen when the snapshot has no scrollback-0 serialize", async () => { + const { service } = createHarness(); + const snapshotPath = path.join( + "/tmp/test-project", + ".ade", + "cache", + "terminal-snapshots", + "legacy-session.json", + ); + mocks.fileContents.set(snapshotPath, JSON.stringify({ + version: 1, + terminalId: "legacy-session", + cols: 80, + rows: 24, + capturedAt: "2026-08-14T00:00:00.000Z", + status: "exited", + runtimeState: "exited", + bufferType: "alternate", + cursorX: 0, + cursorY: 0, + baseY: 0, + viewportY: 0, + serialized: "SCROLLBACK-CSI", + visibleRows: [], + })); + + expect(service.readScreenSnapshot("legacy-session")).toBeNull(); + }); }); describe("ensureResumeTargets", () => { diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index ce8be7db8..7ec94c8e8 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -82,6 +82,7 @@ import type { ChatTerminalWriteArgs, ChatTerminalPreviewArgs, ChatTerminalPreviewResult, + TerminalScreenSnapshot, TerminalSerializedSnapshot, TerminalSnapshotCell, TerminalSnapshotRow, @@ -720,6 +721,11 @@ type PtyEntry = { /** Last size set by a non-mobile caller, restored when a phone detaches. */ lastDesktopCols: number | null; lastDesktopRows: number | null; + /** + * A subscribed mobile/web viewport currently owns the live PTY size. + * Desktop fit-resizes remember lastDesktop* but must not fight the phone. + */ + mobileViewportActive: boolean; pendingDataChunks: string[]; pendingDataChars: number; pendingDataTimer: ReturnType | null; @@ -2217,6 +2223,7 @@ export function createPtyService({ baseY: buffer.baseY, viewportY: buffer.viewportY, serialized: mirror.serializeAddon.serialize({ scrollback: TERMINAL_SNAPSHOT_SCROLLBACK }), + screenSerialized: mirror.serializeAddon.serialize({ scrollback: 0 }), visibleRows: visibleRowsFromTerminal(mirror.terminal), }; }; @@ -2319,6 +2326,64 @@ export function createPtyService({ } }; + const screenSnapshotBufferType = (raw: unknown): "normal" | "alternate" => ( + raw === "alternate" ? "alternate" : "normal" + ); + + const liveScreenSnapshot = (entry: PtyEntry): TerminalScreenSnapshot | null => { + const mirror = entry.terminalSnapshot; + // Disk snapshot writes may be disabled (ENOSPC) while the live headless + // xterm is still current. Mobile hydrate needs that live serialize. + if (!mirror || !entry.tracked) return null; + try { + const serialized = mirror.serializeAddon.serialize({ scrollback: 0 }); + if (typeof serialized !== "string" || serialized.length === 0) return null; + return { + cols: mirror.terminal.cols, + rows: mirror.terminal.rows, + bufferType: screenSnapshotBufferType(mirror.terminal.buffer.active.type), + serialized, + }; + } catch (err) { + const now = Date.now(); + if (now - mirror.lastErrorAt > 10_000) { + mirror.lastErrorAt = now; + logger.warn("pty.terminal_screen_snapshot_failed", { sessionId: entry.sessionId, err: String(err) }); + } + return null; + } + }; + + const storedScreenSnapshot = (sessionId: string): TerminalScreenSnapshot | null => { + const stored = readStoredTerminalSnapshot(sessionId); + if (!stored?.screenSerialized) return null; + return { + cols: stored.cols, + rows: stored.rows, + bufferType: screenSnapshotBufferType(stored.bufferType), + serialized: stored.screenSerialized, + }; + }; + + const rememberDesktopSize = (entry: PtyEntry, cols: number, rows: number): void => { + entry.lastDesktopCols = cols; + entry.lastDesktopRows = rows; + }; + + const applyLivePtyResize = (entry: PtyEntry, cols: number, rows: number): void => { + if (entry.lastResizeCols === cols && entry.lastResizeRows === rows) return; + entry.pty.resize(cols, rows); + entry.lastResizeCols = cols; + entry.lastResizeRows = rows; + resizeTerminalSnapshot(entry, cols, rows); + }; + + const applyDesktopDrivenResize = (entry: PtyEntry, cols: number, rows: number): void => { + rememberDesktopSize(entry, cols, rows); + if (entry.mobileViewportActive) return; + applyLivePtyResize(entry, cols, rows); + }; + const isTitleGenerationEnabled = (): boolean => { const si = getSessionIntelligence(); return si?.titles?.enabled ?? true; @@ -5873,6 +5938,7 @@ export function createPtyService({ lastResizeRows: null, lastDesktopCols: cols, lastDesktopRows: rows, + mobileViewportActive: false, pendingDataChunks: [], pendingDataChars: 0, pendingDataTimer: null, @@ -7042,14 +7108,8 @@ export function createPtyService({ if (!live) throw new Error("No running terminal matched the requested resize target."); const [liveId, entry] = live; const safe = clampDims(args.cols, args.rows); - if (entry.lastResizeCols === safe.cols && entry.lastResizeRows === safe.rows) { - return { ok: true, cols: safe.cols, rows: safe.rows }; - } try { - entry.pty.resize(safe.cols, safe.rows); - entry.lastResizeCols = safe.cols; - entry.lastResizeRows = safe.rows; - resizeTerminalSnapshot(entry, safe.cols, safe.rows); + applyDesktopDrivenResize(entry, safe.cols, safe.rows); return { ok: true, cols: safe.cols, rows: safe.rows }; } catch (err) { logger.warn("pty.terminal_resize_failed", { ptyId: liveId, err: String(err) }); @@ -7091,15 +7151,11 @@ export function createPtyService({ const safe = clampDims(cols, rows); // The ptyId-based path is only driven by the desktop renderer: remember // its size (even when the resize itself dedupes) so a mobile-driven - // resize can be undone when the phone detaches. - entry.lastDesktopCols = safe.cols; - entry.lastDesktopRows = safe.rows; - if (entry.lastResizeCols === safe.cols && entry.lastResizeRows === safe.rows) return; + // resize can be undone when the phone detaches. While a phone is + // subscribed, do not apply the live resize — last writer used to win + // and a focused desktop pane kept ultrawide TUIs off the phone screen. try { - entry.pty.resize(safe.cols, safe.rows); - entry.lastResizeCols = safe.cols; - entry.lastResizeRows = safe.rows; - resizeTerminalSnapshot(entry, safe.cols, safe.rows); + applyDesktopDrivenResize(entry, safe.cols, safe.rows); } catch (err) { logger.warn("pty.resize_failed", { ptyId, err: String(err) }); } @@ -7148,16 +7204,18 @@ export function createPtyService({ const safe = clampDims(cols, rows); // A mobile viewport must never become the desktop-preferred size — it // is restored from lastDesktop* when the phone detaches. - if (opts?.source !== "mobile") { - entry.lastDesktopCols = safe.cols; - entry.lastDesktopRows = safe.rows; + if (opts?.source === "mobile") { + entry.mobileViewportActive = true; + try { + applyLivePtyResize(entry, safe.cols, safe.rows); + return true; + } catch (err) { + logger.warn("pty.resize_by_session_failed", { sessionId, err: String(err) }); + return false; + } } - if (entry.lastResizeCols === safe.cols && entry.lastResizeRows === safe.rows) return true; try { - entry.pty.resize(safe.cols, safe.rows); - entry.lastResizeCols = safe.cols; - entry.lastResizeRows = safe.rows; - resizeTerminalSnapshot(entry, safe.cols, safe.rows); + applyDesktopDrivenResize(entry, safe.cols, safe.rows); return true; } catch (err) { logger.warn("pty.resize_by_session_failed", { sessionId, err: String(err) }); @@ -7179,12 +7237,10 @@ export function createPtyService({ const cols = entry.lastDesktopCols; const rows = entry.lastDesktopRows; if (cols == null || rows == null) return false; + entry.mobileViewportActive = false; if (entry.lastResizeCols === cols && entry.lastResizeRows === rows) return false; try { - entry.pty.resize(cols, rows); - entry.lastResizeCols = cols; - entry.lastResizeRows = rows; - resizeTerminalSnapshot(entry, cols, rows); + applyLivePtyResize(entry, cols, rows); return true; } catch (err) { logger.warn("pty.restore_desktop_size_failed", { sessionId, err: String(err) }); @@ -7360,6 +7416,22 @@ export function createPtyService({ }); }, + /** + * Current-screen CSI for mobile/web replacing hydrates. Prefers the live + * headless xterm (already in alt-screen when the PTY is) and falls back to + * the on-disk snapshot for ended sessions. Never includes visibleRows. + */ + readScreenSnapshot(sessionId: string): TerminalScreenSnapshot | null { + const trimmed = typeof sessionId === "string" ? sessionId.trim() : ""; + if (!trimmed) return null; + const live = liveEntryBySessionId(trimmed)?.[1] ?? null; + if (live) { + const liveScreen = liveScreenSnapshot(live); + if (liveScreen) return liveScreen; + } + return storedScreenSnapshot(trimmed); + }, + /** * Read an exact logical byte range of a session transcript (mobile history * paging / delta resume). After rollover, logical byte zero is no longer diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx index 6003c34de..f01934820 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx @@ -103,6 +103,8 @@ type CachedRuntime = { hydrateRetryTimer: ReturnType | null; /** Full-snapshot bytes held until the first successful fit; see `replaceRuntimeTerminalData`. */ pendingReplaceData: string | null; + /** True when pendingReplaceData is SerializeAddon CSI, not a transcript tail. */ + pendingReplaceIsScreen: boolean; replaceFitRetryTimer: ReturnType | null; replaceFitRetryAttempts: number; hydrationBackfillTimer: ReturnType | null; @@ -1145,6 +1147,7 @@ function clearRuntimeHydrationTimers(runtime: CachedRuntime): void { runtime.replaceFitRetryTimer = null; } runtime.pendingReplaceData = null; + runtime.pendingReplaceIsScreen = false; runtime.replaceFitRetryAttempts = 0; } @@ -1910,7 +1913,7 @@ function shouldDeliverPtyEvent(runtime: CachedRuntime, projectRoot: string | und return !(projectRoot && runtime.projectRoot && projectRoot !== runtime.projectRoot); } -function replaceRuntimeTerminalData(runtime: CachedRuntime, data: string) { +function replaceRuntimeTerminalData(runtime: CachedRuntime, data: string, opts?: { screen?: boolean }) { // Recovery snapshots are authoritative. Invalidate every pending hydration // read before clearing xterm so an older preview/transcript promise cannot // replay stale output over the recovered state. @@ -1931,6 +1934,7 @@ function replaceRuntimeTerminalData(runtime: CachedRuntime, data: string) { void takePendingTerminalOffsetAnchor(runtime.sessionId); runtime.pendingReplaceData = data; + runtime.pendingReplaceIsScreen = opts?.screen === true; runtime.replaceFitRetryAttempts = 0; applyPendingReplaceWhenFitted(runtime); } @@ -2007,7 +2011,9 @@ function applyPendingReplaceWhenFitted( } const data = runtime.pendingReplaceData; + const isScreen = runtime.pendingReplaceIsScreen; runtime.pendingReplaceData = null; + runtime.pendingReplaceIsScreen = false; runtime.replaceFitRetryAttempts = 0; // A live-subscribe snapshot is the SAME raw transcript the preview path @@ -2045,6 +2051,11 @@ function applyPendingReplaceWhenFitted( }; if (data) { + if (isScreen) { + runtime.lastHydrationNormalized = false; + writeReplace(data); + return; + } void normalizeTranscriptToGrid(trimToLikelyTerminalFrameBoundary(data), { maxRows: hydrationGridMaxRows(runtime), }) @@ -2078,7 +2089,7 @@ function handleRuntimePtyData(runtime: CachedRuntime, ev: PtyDataEvent) { } if (ev.replace === true) { - replaceRuntimeTerminalData(runtime, ev.data); + replaceRuntimeTerminalData(runtime, ev.data, { screen: ev.screen === true }); return; } @@ -2672,6 +2683,7 @@ function rehydrateAfterFit(runtime: CachedRuntime): void { // web client, is the ONLY content the session ever gets — losing it to a // transcript re-read leaves a blank pane. const queuedReplace = runtime.pendingReplaceData; + const queuedReplaceIsScreen = runtime.pendingReplaceIsScreen; clearRuntimeHydrationTimers(runtime); try { runtime.term.reset(); @@ -2702,6 +2714,7 @@ function rehydrateAfterFit(runtime: CachedRuntime): void { runtime.hydrationStarted = true; runtime.hydrationCompleted = true; runtime.pendingReplaceData = queuedReplace; + runtime.pendingReplaceIsScreen = queuedReplaceIsScreen; runtime.replaceFitRetryAttempts = 0; applyPendingReplaceWhenFitted(runtime); return; @@ -3001,6 +3014,7 @@ function createRuntime(args: { hydrateTimer: null, hydrateRetryTimer: null, pendingReplaceData: null, + pendingReplaceIsScreen: false, replaceFitRetryTimer: null, replaceFitRetryAttempts: 0, hydrationBackfillTimer: null, diff --git a/apps/desktop/src/renderer/webclient/adapter/sessionsPty.ts b/apps/desktop/src/renderer/webclient/adapter/sessionsPty.ts index 1480efa46..789117188 100644 --- a/apps/desktop/src/renderer/webclient/adapter/sessionsPty.ts +++ b/apps/desktop/src/renderer/webclient/adapter/sessionsPty.ts @@ -67,9 +67,12 @@ export function createSessionsPtyNamespaces(infra: AdapterInfra): SessionsPtyNam events.emit("ptyData", { ptyId: resolvedPtyId, sessionId: payload.sessionId, - data: payload.transcript, + data: payload.delta === true + ? payload.transcript + : (payload.screen?.serialized || payload.transcript), offset: payload.endOffset, ...(payload.delta === true ? {} : { replace: true }), + ...(payload.delta !== true && payload.screen?.serialized ? { screen: true } : {}), }); }, data: (payload) => { diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index b8aed2277..133c9e804 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -414,6 +414,12 @@ export type PtyDataEvent = { * transcript writes disabled, or the transcript byte cap was reached). */ offset?: number | null; + /** + * True when `data` is SerializeAddon current-screen CSI (including alt-screen + * enter). Replacing hydrates must write it verbatim — transcript-grid + * normalization strips 1049h and can leave the pane blank. + */ + screen?: boolean; }; export type PtyExitEvent = { @@ -538,9 +544,28 @@ export type TerminalSerializedSnapshot = { baseY: number; viewportY: number; serialized: string; + /** + * Current-screen CSI (`serialize({ scrollback: 0 })`). Distinct from + * `serialized`, which includes normal-buffer scrollback and can exceed the + * 256k mobile hydrate cap. Optional so older on-disk snapshots still parse. + */ + screenSerialized?: string; visibleRows: TerminalSnapshotRow[]; }; +/** + * Current-screen paint for mobile/web hydrate. Unlike the on-disk serialized + * snapshot this omits `visibleRows` (a 375×53 cell grid is >1 MiB) and is the + * CSI SerializeAddon output of the live headless xterm, including alt-screen + * enter when the PTY is on the alternate buffer. + */ +export type TerminalScreenSnapshot = { + cols: number; + rows: number; + bufferType: "normal" | "alternate"; + serialized: string; +}; + export type ChatTerminalPreviewArgs = { terminalId?: string | null; chatSessionId?: string | null; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 9ae621a60..7f4cb06e6 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -1379,6 +1379,13 @@ export type SyncTerminalUnsubscribePayload = { sessionId: string; }; +export type SyncTerminalScreenSnapshot = { + cols: number; + rows: number; + bufferType: "normal" | "alternate"; + serialized: string; +}; + export type SyncTerminalSnapshotPayload = { sessionId: string; transcript: string; @@ -1398,6 +1405,13 @@ export type SyncTerminalSnapshotPayload = { * surface a resume affordance instead of silently accepting keystrokes. */ live?: boolean; + /** + * Current-screen CSI for replacing hydrates. Alt-screen TUIs (Claude Code) + * cannot be reconstructed from a transcript tail — the DECSET is long gone + * and the tail is incremental CUP at the desktop width. Older clients ignore + * this field and keep replaying `transcript`. Omitted on delta resumes. + */ + screen?: SyncTerminalScreenSnapshot | null; }; export type SyncTerminalDataPayload = { diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index fa888c618..9c2234a1b 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -4718,6 +4718,13 @@ struct IntegrationProposal: Codable, Identifiable, Equatable { var resolutionState: IntegrationResolutionState? } +struct TerminalScreenSnapshot: Codable, Equatable { + var cols: Int + var rows: Int + var bufferType: String? + var serialized: String +} + struct TerminalSnapshot: Codable, Equatable { var sessionId: String var transcript: String @@ -4736,6 +4743,20 @@ struct TerminalSnapshot: Codable, Equatable { /// restart orphaned a "running" session — typing would go nowhere. Absent /// on older hosts. var live: Bool? + /// Current-screen CSI for replacing hydrates. Alt-screen TUIs cannot be + /// reconstructed from a transcript tail. Absent on older hosts and on + /// delta resumes. + var screen: TerminalScreenSnapshot? + + var hasScreenPaint: Bool { + guard let serialized = screen?.serialized else { return false } + return !serialized.isEmpty + } + + var replacingHydrateText: String { + if delta == true { return transcript } + return hasScreenPaint ? (screen?.serialized ?? transcript) : transcript + } } /// Response payload for `terminal_history`: transcript bytes diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 3d9f56172..b5856697f 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -3361,8 +3361,10 @@ enum SyncChatCommandScope: Equatable { enum TerminalStreamEvent { /// Snapshot payload from `terminal_subscribe`. `replacing == false` means a /// delta resume (bytes from the requested `sinceOffset` to the end) that - /// must be appended, not re-rendered from scratch. - case hydrate(text: String, replacing: Bool, startOffset: Int?, endOffset: Int?) + /// must be appended, not re-rendered from scratch. `paintFromScreen` means + /// `text` is SerializeAddon CSI, not the transcript window — do not page + /// history through it. + case hydrate(text: String, replacing: Bool, startOffset: Int?, endOffset: Int?, paintFromScreen: Bool) case chunk(text: String, endOffset: Int?) case exit(code: Int?) case inputFailure(message: String) @@ -10545,17 +10547,20 @@ final class SyncService: ObservableObject { text: snapshot.transcript, replacing: false, startOffset: snapshot.startOffset, - endOffset: snapshot.endOffset + endOffset: snapshot.endOffset, + paintFromScreen: false )) } terminalBufferUpdatedAt[sessionId] = Date() } else { + let hydrateText = snapshot.replacingHydrateText updateTerminalBuffer(sessionId: sessionId, transcript: snapshot.transcript, immediate: true) terminalStreamHandlers[sessionId]?(.hydrate( - text: snapshot.transcript, + text: hydrateText, replacing: true, startOffset: snapshot.startOffset, - endOffset: snapshot.endOffset + endOffset: snapshot.endOffset, + paintFromScreen: snapshot.hasScreenPaint )) } if snapshot.live == false { diff --git a/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift b/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift index 9cc9592be..0f43cf073 100644 --- a/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift +++ b/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift @@ -106,6 +106,8 @@ final class TerminalSessionController: NSObject, ObservableObject { @Published private(set) var isResuming = false @Published private(set) var resumeError: String? @Published private(set) var isSubscribed = false + @Published private(set) var hasPainted = false + @Published private(set) var subscribeError: String? @Published private(set) var ctrlArmed = false @Published private(set) var bellPulse = 0 /// Bumped when typed input is dropped because the host is unreachable. @@ -118,6 +120,7 @@ final class TerminalSessionController: NSObject, ObservableObject { private static let maxFontSize: CGFloat = 20 private static let transcriptByteCap = 4 * 1024 * 1024 private static let scrollbackLines = 10_000 + private static let maxQueuedEvents = 256 private(set) var sessionId = "" private weak var syncService: SyncService? @@ -175,13 +178,28 @@ final class TerminalSessionController: NSObject, ObservableObject { let resumeOffset = transcriptEndOffset Task { @MainActor [weak self] in guard let self else { return } - do { - try await syncService.subscribeTerminalStream(sessionId: sessionId, sinceOffset: resumeOffset) - self.isSubscribed = true - self.startLegacyPollingIfNeeded() - } catch { - self.isSubscribed = false - } + await self.subscribeStream(sinceOffset: resumeOffset) + } + } + + func retrySubscribe() { + subscribeError = nil + Task { @MainActor [weak self] in + guard let self else { return } + await self.subscribeStream(sinceOffset: self.transcriptEndOffset) + } + } + + private func subscribeStream(sinceOffset: Int?) async { + guard let syncService, !sessionId.isEmpty else { return } + do { + try await syncService.subscribeTerminalStream(sessionId: sessionId, sinceOffset: sinceOffset) + isSubscribed = true + subscribeError = nil + startLegacyPollingIfNeeded() + } catch { + isSubscribed = false + subscribeError = (error as NSError).localizedDescription } } @@ -344,30 +362,69 @@ final class TerminalSessionController: NSObject, ObservableObject { private func handleStreamEvent(_ event: TerminalStreamEvent) { guard readyToFeed else { - queuedEvents.append(event) + enqueueUntilReady(event) return } applyStreamEvent(event) } + private func enqueueUntilReady(_ event: TerminalStreamEvent) { + if case .hydrate(_, true, _, _, _) = event { + queuedEvents.removeAll { pending in + if case .exit = pending { return false } + return true + } + } + queuedEvents.append(event) + guard queuedEvents.count > Self.maxQueuedEvents else { return } + var hydrate: TerminalStreamEvent? + var exitEvent: TerminalStreamEvent? + var rest: [TerminalStreamEvent] = [] + for pending in queuedEvents { + switch pending { + case .hydrate(_, true, _, _, _): + hydrate = pending + case .exit: + exitEvent = pending + default: + rest.append(pending) + } + } + let reserved = (hydrate == nil ? 0 : 1) + (exitEvent == nil ? 0 : 1) + let keep = max(0, Self.maxQueuedEvents - reserved) + var next = Array(rest.suffix(keep)) + if let hydrate { next.insert(hydrate, at: 0) } + if let exitEvent { next.append(exitEvent) } + queuedEvents = next + } + private func applyStreamEvent(_ event: TerminalStreamEvent) { switch event { - case .hydrate(let text, let replacing, let startOffset, let endOffset): + case .hydrate(let text, let replacing, let startOffset, let endOffset, let paintFromScreen): inputStatusMessage = nil isSubscribed = true + subscribeError = nil noteHostOffsetCapability(endOffset != nil) if replacing { let bytes = Data(text.utf8) // Legacy polling refetches the same tail every cycle; rebuilding on an // unchanged tail would flicker the screen every poll. - if endOffset == nil, !bytes.isEmpty, transcript == bytes || (transcript.count > bytes.count && transcript.suffix(bytes.count) == bytes) { + if !paintFromScreen, endOffset == nil, !bytes.isEmpty, transcript == bytes || (transcript.count > bytes.count && transcript.suffix(bytes.count) == bytes) { return } - transcript = bytes transcriptStartOffset = startOffset transcriptEndOffset = endOffset - historyAtStart = startOffset == 0 - rebuildTerminal() + if paintFromScreen { + // CSI is not a transcript window. Paging older log bytes in front of + // it would corrupt the alt-screen paint. + transcript = Data() + historyAtStart = true + } else { + transcript = bytes + historyAtStart = startOffset == 0 + } + rebuildTerminal(feed: bytes) + if !bytes.isEmpty { hasPainted = true } } else { appendBytes(Data(text.utf8), endOffset: endOffset, countsAsLive: false) } @@ -401,6 +458,7 @@ final class TerminalSessionController: NSObject, ObservableObject { transcript.append(bytes) enforceTranscriptCap() terminalView?.feed(byteArray: [UInt8](bytes)[...]) + if !bytes.isEmpty { hasPainted = true } if countsAsLive, !isPinnedToBottom { liveChunksWhileScrolledUp += 1 } @@ -418,12 +476,13 @@ final class TerminalSessionController: NSObject, ObservableObject { historyAtStart = false } - private func rebuildTerminal() { + private func rebuildTerminal(feed bytes: Data? = nil) { guard let view = terminalView else { return } view.holdScrollOnOutput = false view.getTerminal().resetToInitialState() - if !transcript.isEmpty { - view.feed(byteArray: [UInt8](transcript)[...]) + let payload = bytes ?? transcript + if !payload.isEmpty { + view.feed(byteArray: [UInt8](payload)[...]) } isPinnedToBottom = true liveChunksWhileScrolledUp = 0 diff --git a/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift b/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift index b89b1638c..625d890bf 100644 --- a/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift +++ b/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift @@ -70,6 +70,9 @@ struct TerminalSessionScreen: View { VStack(spacing: 0) { topBar SwiftTermSessionView(controller: controller) + .overlay { + terminalLoadOverlay + } .overlay(alignment: .top) { if controller.isLoadingHistory { TerminalHistoryShimmer() @@ -85,6 +88,37 @@ struct TerminalSessionScreen: View { } } + @ViewBuilder + private var terminalLoadOverlay: some View { + if controller.hasPainted || controller.hasExited { + EmptyView() + } else if let subscribeError = controller.subscribeError { + VStack(spacing: 12) { + Text("Couldn’t load this terminal") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text(subscribeError) + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + Button("Retry") { + controller.retrySubscribe() + } + .buttonStyle(.borderedProminent) + .tint(ADEColor.accent) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(0.72)) + } else if !controller.isSubscribed { + ProgressView() + .controlSize(.regular) + .tint(.white) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black.opacity(0.35)) + } + } + private func handleAppear() { controller.activate(syncService: syncService, sessionId: session.id, sessionStatus: session.status) pasteboardHasStrings = controller.pasteboardHasStrings diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index fbbeb3983..325a3197e 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -24900,11 +24900,25 @@ final class TerminalSessionInputStatusTests: XCTestCase { text: "Mac% ", replacing: true, startOffset: 0, - endOffset: 5 + endOffset: 5, + paintFromScreen: false )) XCTAssertNil(controller.inputStatusMessage) } + + func testScreenHydrateMarksPainted() { + let controller = TerminalSessionController() + controller.handleStreamEventForTesting(.hydrate( + text: "\u{1B}[?1049h\u{1B}[Hclaude", + replacing: true, + startOffset: 33_554_432, + endOffset: 48_000_000, + paintFromScreen: true + )) + XCTAssertTrue(controller.hasPainted) + XCTAssertNil(controller.inputStatusMessage) + } } // MARK: - Linear pane diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 0c1babb2c..20aa69977 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -56,8 +56,12 @@ and in tests. `readTranscriptSnapshot` for authoritative logical-offset hydration, and `readTranscriptRange({ sessionId, startOffset, endOffset })` for mobile scrollback/delta resume across rollover, + `readScreenSnapshot(sessionId)` for current-screen SerializeAddon CSI + (alt-screen TUIs cannot be rebuilt from a log tail), offset-stamped PTY data batches, desktop-size restore after - mobile-driven resizes, agent CLI input protocol (bracketed paste, + mobile-driven resizes (desktop fit-resizes remember lastDesktop* but + skip live `pty.resize` while a subscribed phone/web viewport owns the + size), agent CLI input protocol (bracketed paste, chunked writes, provider-specific submit delays), process tree termination (`terminatePtyProcessTree` walks descendant PIDs via `pgrep` and escalates to `SIGKILL` after a grace timer), live session @@ -515,7 +519,8 @@ Shared types and IPC: `terminal_exit`, `terminal_input`, `terminal_input_ack`, `terminal_resize`) for iOS/web Work surfaces, including logical transcript offsets, `sinceOffset` delta resume, authoritative full snapshots, - input-id dedupe/ack metadata, `live` backing-PTY status, and + optional `screen` current-screen CSI on replacing hydrates, input-id + dedupe/ack metadata, `live` backing-PTY status, and pull-to-load-older history pages, plus the mobile CLI launcher payload (`SyncCliLaunchProvider`, `SyncStartCliSessionArgs`, @@ -528,12 +533,17 @@ Shared types and IPC: `adapter/sessionsPty.ts` — hosted-web terminal watermark/recovery state and the `window.ade` PTY bridge. Duplicate/overlapping live ranges are dropped or UTF-8-trimmed, one gap resubscribe requests the missing suffix, and an - authoritative full snapshot is surfaced as `PtyDataEvent.replace`. + authoritative full snapshot is surfaced as `PtyDataEvent.replace`. Replacing + hydrates prefer `screen.serialized` and stamp `screen: true` so TerminalView + writes CSI verbatim instead of running transcript-grid normalization. - `apps/ade-cli/src/services/sync/syncHostService.ts` — remote terminal subscription barrier and input boundary. It installs the barrier before reading a logical transcript snapshot, queues concurrent data/exit events within 256 events / 2 MB, trims snapshot overlap, and recaptures up to four - times rather than exposing a gap. Its terminal-input ledger deduplicates + times rather than exposing a gap. Catch-up overflow fails the barrier and + still answers `terminal_snapshot` (with current-screen CSI when available); + it does not close the controller websocket. Its terminal-input ledger + deduplicates stable input ids before PTY write and acknowledges success/duplicate/failure to ACK-capable web/iOS clients. - `apps/desktop/src/shared/ipc.ts` — channels `ade.sessions.*`, @@ -1390,7 +1400,10 @@ iOS Work surfaces: surface for CLI sessions. It subscribes with `sinceOffset`, applies offset-stamped `terminal_data`, recovers gaps with a guarded delta/full resubscribe, pages older retained transcript bytes via `terminal_history`, - sends ordered `terminal_input` through `SyncTerminalInputQueue`, reports viewport + paints replacing hydrates from `screen.serialized` when present (Claude + Code alt-screen cannot be rebuilt from the 512 KB log tail), shows a + loading/error overlay until the first paint, sends ordered `terminal_input` + through `SyncTerminalInputQueue`, reports viewport changes as `terminal_resize`, and unsubscribes on disappear. - `apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift` — terminal artifact/output views and inline preview cards; the older @@ -1913,6 +1926,17 @@ runtime and agent chat runtime both layer the same identity envs - Chat sessions backed by the Claude/Codex SDK still insert a `terminal_sessions` row but they are not attached to a PTY. Guard UI code with `isChatToolType(toolType)` before calling PTY-only APIs. +- **Mobile/web replacing hydrates cannot replay a transcript tail for an + alt-screen TUI.** Desktop keeps a headless xterm and hydrates from + SerializeAddon current-screen CSI. A days-long Claude Code session's last + 512 KB is CUP/EL at the desktop width with the DECSET long gone, so iOS + used to paint black. `terminal_snapshot.screen` carries that serialize + (`scrollback: 0`, omit if over 256k chars — never slice CSI). Deltas stay + transcript-only. Clients that do not know the field keep the old tail + replay. While a phone/web peer is subscribed, desktop fit-resizes remember + lastDesktop* but must not call live `pty.resize`; restore on last + unsubscribe. Catch-up overflow must not `ws.close(4001)` — that blanked + every other iOS surface on the same socket. - `reconcileStaleRunningSessions` accepts `excludeToolTypes` but desktop and brain startup no longer exclude chat tool types — stale `running` chat rows are swept to `detached` like any other orphaned diff --git a/docs/features/terminals-and-sessions/pty-and-sessions.md b/docs/features/terminals-and-sessions/pty-and-sessions.md index 4e703977c..f0f2842aa 100644 --- a/docs/features/terminals-and-sessions/pty-and-sessions.md +++ b/docs/features/terminals-and-sessions/pty-and-sessions.md @@ -337,11 +337,14 @@ derive logical offsets from the current file size. Resize ownership: the ptyId-based `resize(...)` path (desktop renderer) records `lastDesktopCols/Rows` on the entry; -`resizeBySessionId(..., { source: "mobile" })` does not. -`restoreDesktopSizeBySessionId(sessionId)` puts the PTY back to the -recorded desktop size — the sync host calls it when the last -subscribed phone detaches, so a phone-fitted 45-column reflow doesn't -linger on desktop. +`resizeBySessionId(..., { source: "mobile" })` does not, and sets +`mobileViewportActive` so a focused desktop pane cannot fight the phone +with live `pty.resize`. `restoreDesktopSizeBySessionId(sessionId)` +clears that flag and puts the PTY back to the recorded desktop size — +the sync host calls it when the last subscribed phone detaches, so a +phone-fitted 45-column reflow doesn't linger on desktop. +`readScreenSnapshot(sessionId)` returns live SerializeAddon current-screen +CSI (scrollback 0, no visibleRows) for mobile/web replacing hydrates. `updatePreviewThrottled` uses `derivePreviewFromChunk` to track the last non-empty line, capped at 220 chars. Preview is flushed to