From 3c29bf7c9dcd63b950fc3c0555d9aa2eede1b75a Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 10 Jun 2026 16:59:48 -0400 Subject: [PATCH 01/10] iOS CLI sessions: native SwiftTerm terminal + offset-based PTY streaming Mirror the desktop remote-connection pattern on the phone: a real terminal emulator on-device fed by raw PTY byte push, replacing the custom UITextView ANSI parser, snapshot polling backstop, and text-box-and-send composer. Host (shared ptyService + ade-cli sync host): - terminal_data now carries the transcript end byte offset; phones detect gaps and resume exactly via terminal_subscribe{sinceOffset} delta snapshots instead of refetching tails - new terminal_history request pages older transcript bytes on demand (readTranscriptRange with newline/ESC/UTF-8 boundary scan) - mobile resizes are non-authoritative: last desktop size is restored when the final subscribed phone detaches - brain bootstrap now bridges live PTY data/exit into the sync host (previously only the Electron desktop did; brain-paired phones never received live terminal_data push at all) iOS: - SwiftTerm 1.13.0 via SPM; full-bleed TerminalSessionScreen with slim status bar, tap-to-focus keyboard passthrough (16ms input batching), latching-Ctrl key bar, auto-paging scrollback with Live pill, pinch font sizing, paste chip, bell haptics, offset-aware stream store with sinceOffset reconnect back-fill Docs updated (ios-companion, pty-and-processes). Co-Authored-By: Claude Fable 5 --- apps/ade-cli/src/bootstrap.ts | 18 +- .../src/services/sync/syncHostService.test.ts | 335 ++++++++++++ .../src/services/sync/syncHostService.ts | 147 ++++- .../src/main/services/pty/ptyService.test.ts | 189 +++++++ .../src/main/services/pty/ptyService.ts | 133 ++++- .../services/sync/syncHostService.test.ts | 2 +- apps/desktop/src/shared/types/sessions.ts | 6 + apps/desktop/src/shared/types/sync.ts | 40 ++ apps/ios/ADE.xcodeproj/project.pbxproj | 37 +- .../xcshareddata/swiftpm/Package.resolved | 24 + apps/ios/ADE/Models/RemoteModels.swift | 17 + apps/ios/ADE/Services/SyncService.swift | 177 +++++- .../ADE/Views/Work/SwiftTermSessionView.swift | 514 ++++++++++++++++++ .../Views/Work/TerminalSessionScreen.swift | 375 +++++++++++++ .../Work/WorkSessionDestinationView.swift | 24 +- .../sync-and-multi-device/ios-companion.md | 7 +- .../pty-and-processes.md | 31 +- 17 files changed, 2041 insertions(+), 35 deletions(-) create mode 100644 apps/ios/ADE.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 apps/ios/ADE/Views/Work/SwiftTermSessionView.swift create mode 100644 apps/ios/ADE/Views/Work/TerminalSessionScreen.swift diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 8bf4d7b50..05d24873d 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -761,6 +761,11 @@ export async function createAdeRuntime(args: { const ptyBackend = process.env.ADE_DISABLE_SUPERVISED_PTY_HOST === "1" ? null : createSupervisedPtyLoader({ logger }); + // The sync runtime is created after ptyService (it takes ptyService as a + // dependency), so live PTY forwarding binds late through this ref — same + // pattern as desktop main. Without this bridge, paired phones only ever + // receive terminal snapshots, never live terminal_data push. + let syncServiceForPtyEvents: ReturnType | null = null; const ptyService = createPtyService({ projectRoot, transcriptsDir: paths.transcriptsDir, @@ -768,8 +773,16 @@ export async function createAdeRuntime(args: { sessionService, processRegistry, logger, - broadcastData: (event) => pushEvent("pty", { type: "pty_data", event }), - broadcastExit: (event) => pushEvent("pty", { type: "pty_exit", event }), + broadcastData: (event) => { + pushEvent("pty", { type: "pty_data", event }); + const { projectRoot: _projectRoot, ...syncEvent } = event; + syncServiceForPtyEvents?.handlePtyData(syncEvent); + }, + broadcastExit: (event) => { + pushEvent("pty", { type: "pty_exit", event }); + const { projectRoot: _projectRoot, ...syncEvent } = event; + syncServiceForPtyEvents?.handlePtyExit(syncEvent); + }, onSessionEnded: (event) => { void sessionDeltaService.computeSessionDelta(event.sessionId).catch((error) => { logger.warn("runtime.session_delta_compute_failed", { @@ -1301,6 +1314,7 @@ export async function createAdeRuntime(args: { getModelPickerStore: () => getSharedModelPickerStore(db), onStatusChanged: (snapshot) => pushEvent("runtime", { type: "sync-status", snapshot }), }); + syncServiceForPtyEvents = syncService; } if (syncService) { diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index 6d6f04107..2081a1e18 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -94,6 +94,11 @@ describe("resolveSyncHostInboundProjectScope", () => { projectId: "project-1", usedSingleProjectFallback: true, }); + expect(resolveSyncHostInboundProjectScope("terminal_history", null, "project-1")).toEqual({ + ok: true, + projectId: "project-1", + usedSingleProjectFallback: true, + }); }); it("accepts matching project-scoped envelopes", () => { @@ -912,3 +917,333 @@ describe("chat event replay buffer (resumable chat streams)", () => { expect(plan.mode).toBe("replay"); }); }); + +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); + + beforeEach(() => { + publishMock.mockReset(); + spawnMock.mockReset(); + bonjourDestroyMock.mockReset(); + bonjourConstructorMock.mockReset(); + spawnMock.mockImplementation(() => ({ kill: vi.fn(), once: vi.fn(), unref: vi.fn() })); + }); + + function createTerminalHost(projectRoot: string) { + const transcriptPath = path.join(projectRoot, "transcripts", "session-1.log"); + fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); + fs.writeFileSync(transcriptPath, TRANSCRIPT_CONTENT); + const session = { + id: "session-1", + laneId: "lane-1", + transcriptPath, + status: "running", + runtimeState: "running", + lastOutputPreview: "preview", + }; + const readTranscriptTail = vi.fn(async () => "tail-snapshot"); + const readTranscriptRange = vi.fn(async (args: { sessionId: string; startOffset: number; endOffset: number }) => ({ + data: TRANSCRIPT_CONTENT.slice(args.startOffset, args.endOffset), + startOffset: args.startOffset, + endOffset: args.endOffset, + })); + const resizeBySessionId = vi.fn().mockReturnValue(true); + const restoreDesktopSizeBySessionId = vi.fn().mockReturnValue(true); + const base = createHostArgs(projectRoot, []); + const host = createSyncHostService({ + ...base, + projectId: "project-1", + db: { + sync: { + getSiteId: () => "site-host-terminal", + getDbVersion: () => 0, + exportChangesSince: () => [], + applyChanges: () => ({ appliedCount: 0 }), + discardUnpublishedChangesForTables: () => {}, + }, + }, + deviceRegistryService: { + ...base.deviceRegistryService, + upsertPeerMetadata: vi.fn(), + }, + sessionService: { + list: () => [session], + get: (id: string) => (id === "session-1" ? session : null), + readTranscriptTail: async () => "", + }, + ptyService: { + create: vi.fn(), + readTranscriptTail, + readTranscriptRange, + writeBySessionId: vi.fn().mockReturnValue(true), + resizeBySessionId, + restoreDesktopSizeBySessionId, + enrichSessions: (rows: unknown[]) => rows, + }, + } as unknown as Parameters[0]); + return { host, readTranscriptTail, readTranscriptRange, resizeBySessionId, restoreDesktopSizeBySessionId }; + } + + async function connectTerminalPeer(port: number, token: string, deviceId: string) { + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + const tracked = trackClientEnvelopes(ws); + await new Promise((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", reject); + }); + ws.send(encodeSyncEnvelope({ + type: "hello", + payload: { + peer: { + deviceId, + deviceName: deviceId, + platform: "iOS", + deviceType: "phone", + siteId: `${deviceId}-site`, + dbVersion: 0, + }, + auth: { kind: "bootstrap", token }, + }, + })); + await waitForValue( + () => tracked.envelopes.find((envelope) => envelope.type === "hello_ok"), + `hello_ok for ${deviceId}`, + ); + return { ws, ...tracked }; + } + + function nextResponse(envelopes: ParsedSyncEnvelope[], type: string, requestId: string) { + return waitForValue( + () => envelopes.find((envelope) => envelope.type === type && envelope.requestId === requestId), + `${type} response ${requestId}`, + ); + } + + it("answers terminal_subscribe with a delta when sinceOffset fits the budget, else a tail snapshot with offsets", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const { host, readTranscriptTail, readTranscriptRange } = createTerminalHost(projectRoot); + let client: Awaited> | null = null; + try { + const port = await host.waitUntilListening(); + client = await connectTerminalPeer(port, host.getBootstrapToken(), "ios-terminal-1"); + + client.ws.send(encodeSyncEnvelope({ + type: "terminal_subscribe", + requestId: "sub-delta", + payload: { sessionId: "session-1", maxBytes: 32_000, sinceOffset: 4_988 }, + })); + const delta = await nextResponse(client.envelopes, "terminal_snapshot", "sub-delta"); + expect(delta.payload).toMatchObject({ + sessionId: "session-1", + transcript: TRANSCRIPT_CONTENT.slice(4_988), + delta: true, + startOffset: 4_988, + endOffset: 5_000, + }); + expect(readTranscriptRange).toHaveBeenCalledWith({ + sessionId: "session-1", + startOffset: 4_988, + endOffset: 5_000, + }); + expect(readTranscriptTail).not.toHaveBeenCalled(); + + // Gap larger than the budget → full tail snapshot (delta omitted). + client.ws.send(encodeSyncEnvelope({ + type: "terminal_subscribe", + requestId: "sub-full", + payload: { sessionId: "session-1", maxBytes: 1_024, sinceOffset: 0 }, + })); + const full = await nextResponse(client.envelopes, "terminal_snapshot", "sub-full"); + expect(full.payload).toMatchObject({ + sessionId: "session-1", + transcript: "tail-snapshot", + startOffset: 5_000 - Buffer.byteLength("tail-snapshot", "utf8"), + endOffset: 5_000, + }); + expect((full.payload as { delta?: boolean }).delta).toBeUndefined(); + expect(readTranscriptTail).toHaveBeenCalledWith({ + sessionId: "session-1", + maxBytes: 1_024, + raw: true, + alignToLineBoundary: true, + }); + + // sinceOffset beyond the transcript end (host restarted with a fresh + // file, client watermark stale) → full snapshot, not a delta. + client.ws.send(encodeSyncEnvelope({ + type: "terminal_subscribe", + requestId: "sub-stale", + payload: { sessionId: "session-1", maxBytes: 32_000, sinceOffset: 9_999 }, + })); + const stale = await nextResponse(client.envelopes, "terminal_snapshot", "sub-stale"); + expect((stale.payload as { delta?: boolean }).delta).toBeUndefined(); + expect((stale.payload as { transcript: string }).transcript).toBe("tail-snapshot"); + } finally { + try { + client?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); + + it("serves terminal_history pages to subscribed peers and refuses unsubscribed ones", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const { host, readTranscriptRange } = createTerminalHost(projectRoot); + let client: Awaited> | null = null; + try { + const port = await host.waitUntilListening(); + client = await connectTerminalPeer(port, host.getBootstrapToken(), "ios-terminal-2"); + + // Not subscribed yet: same access gate as terminal_input. + client.ws.send(encodeSyncEnvelope({ + type: "terminal_history", + requestId: "hist-refused", + payload: { sessionId: "session-1", beforeOffset: 4_000 }, + })); + const refused = await nextResponse(client.envelopes, "terminal_history", "hist-refused"); + expect(refused.payload).toEqual({ + sessionId: "session-1", + data: "", + startOffset: 4_000, + endOffset: 4_000, + atStart: false, + }); + expect(readTranscriptRange).not.toHaveBeenCalled(); + + client.ws.send(encodeSyncEnvelope({ + type: "terminal_subscribe", + requestId: "sub-1", + payload: { sessionId: "session-1", maxBytes: 32_000 }, + })); + await nextResponse(client.envelopes, "terminal_snapshot", "sub-1"); + + client.ws.send(encodeSyncEnvelope({ + type: "terminal_history", + requestId: "hist-1", + payload: { sessionId: "session-1", beforeOffset: 5_000, maxBytes: 4_096 }, + })); + const page = await nextResponse(client.envelopes, "terminal_history", "hist-1"); + expect(readTranscriptRange).toHaveBeenCalledWith({ + sessionId: "session-1", + startOffset: 5_000 - 4_096, + endOffset: 5_000, + alignStartToSafeBoundary: true, + }); + expect(page.payload).toEqual({ + sessionId: "session-1", + data: TRANSCRIPT_CONTENT.slice(904), + startOffset: 904, + endOffset: 5_000, + atStart: false, + }); + + // beforeOffset inside the first page → page starts at 0 and atStart=true. + client.ws.send(encodeSyncEnvelope({ + type: "terminal_history", + requestId: "hist-first", + payload: { sessionId: "session-1", beforeOffset: 800, maxBytes: 4_096 }, + })); + const firstPage = await nextResponse(client.envelopes, "terminal_history", "hist-first"); + expect(firstPage.payload).toEqual({ + sessionId: "session-1", + data: TRANSCRIPT_CONTENT.slice(0, 800), + startOffset: 0, + endOffset: 800, + atStart: true, + }); + + // beforeOffset past EOF clamps to the flushed transcript size. + client.ws.send(encodeSyncEnvelope({ + type: "terminal_history", + requestId: "hist-clamped", + payload: { sessionId: "session-1", beforeOffset: 999_999, maxBytes: 4_096 }, + })); + const clamped = await nextResponse(client.envelopes, "terminal_history", "hist-clamped"); + expect(clamped.payload).toMatchObject({ startOffset: 904, endOffset: 5_000, atStart: false }); + } finally { + try { + client?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); + + it("restores the desktop terminal size only after the last subscribed peer detaches", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const { host, resizeBySessionId, restoreDesktopSizeBySessionId } = createTerminalHost(projectRoot); + let clientA: Awaited> | null = null; + let clientB: Awaited> | null = null; + try { + const port = await host.waitUntilListening(); + clientA = await connectTerminalPeer(port, host.getBootstrapToken(), "ios-terminal-a"); + clientB = await connectTerminalPeer(port, host.getBootstrapToken(), "ios-terminal-b"); + + clientA.ws.send(encodeSyncEnvelope({ + type: "terminal_subscribe", + requestId: "sub-a", + payload: { sessionId: "session-1", maxBytes: 32_000 }, + })); + await nextResponse(clientA.envelopes, "terminal_snapshot", "sub-a"); + clientB.ws.send(encodeSyncEnvelope({ + type: "terminal_subscribe", + requestId: "sub-b", + payload: { sessionId: "session-1", maxBytes: 32_000 }, + })); + await nextResponse(clientB.envelopes, "terminal_snapshot", "sub-b"); + + clientA.ws.send(encodeSyncEnvelope({ + type: "terminal_resize", + payload: { sessionId: "session-1", cols: 61.7, rows: 21.2 }, + })); + await waitForValue( + () => (resizeBySessionId.mock.calls.length > 0 ? resizeBySessionId.mock.calls[0] : null), + "mobile resize forwarded", + ); + expect(resizeBySessionId).toHaveBeenCalledWith("session-1", 61, 21, { source: "mobile" }); + + // Peer A detaches while peer B still watches: no restore. The follow-up + // history request (refused because A just unsubscribed) fences ordering. + clientA.ws.send(encodeSyncEnvelope({ + type: "terminal_unsubscribe", + payload: { sessionId: "session-1" }, + })); + clientA.ws.send(encodeSyncEnvelope({ + type: "terminal_history", + requestId: "fence-a", + payload: { sessionId: "session-1", beforeOffset: 100 }, + })); + const fence = await nextResponse(clientA.envelopes, "terminal_history", "fence-a"); + expect((fence.payload as { atStart: boolean }).atStart).toBe(false); + expect(restoreDesktopSizeBySessionId).not.toHaveBeenCalled(); + + // Last watcher disconnects → snap back to the desktop size. + clientB.ws.close(); + await waitForValue( + () => (restoreDesktopSizeBySessionId.mock.calls.length > 0 ? restoreDesktopSizeBySessionId.mock.calls[0] : null), + "desktop size restore after last peer detached", + ); + expect(restoreDesktopSizeBySessionId).toHaveBeenCalledTimes(1); + expect(restoreDesktopSizeBySessionId).toHaveBeenCalledWith("session-1"); + } finally { + try { + clientA?.ws.close(); + } catch { + // ignore + } + try { + clientB?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); +}); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 5ecb1635f..b15be9715 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -47,6 +47,7 @@ import type { SyncProjectSwitchResultPayload, SyncRemoteCommandDescriptor, SyncTailnetDiscoveryStatus, + SyncTerminalHistoryResponsePayload, SyncTerminalSnapshotPayload, } from "../../../../desktop/src/shared/types"; import { parseAgentChatTranscript } from "../../../../desktop/src/shared/chatTranscript"; @@ -146,6 +147,9 @@ const DEFAULT_BRAIN_STATUS_INTERVAL_MS = 5_000; const NATIVE_LAN_DISCOVERY_RECOVERY_DELAY_MS = 1_000; const NATIVE_LAN_DISCOVERY_FALLBACK_MS = 30_000; const DEFAULT_TERMINAL_SNAPSHOT_BYTES = 220_000; +const DEFAULT_TERMINAL_HISTORY_PAGE_BYTES = 262_144; +const MIN_TERMINAL_HISTORY_PAGE_BYTES = 4_096; +const MAX_TERMINAL_HISTORY_PAGE_BYTES = 524_288; const PEER_BACKPRESSURE_BYTES = 4 * 1024 * 1024; const MOBILE_COMMAND_RESULT_CACHE_TTL_MS = 30 * 60 * 1000; const MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES = 512; @@ -512,6 +516,22 @@ export function syncHeartbeatMissLimitForPeerMetadata(metadata: Pick([ "changeset_batch", "changeset_ack", @@ -520,6 +540,7 @@ const SYNC_HOST_PROJECT_SCOPED_INBOUND_ENVELOPE_TYPES = new Set= 0 + && sinceOffset <= transcriptSize + && transcriptSize - sinceOffset <= maxBytes + ) { + const range = await args.ptyService.readTranscriptRange({ + sessionId, + startOffset: sinceOffset, + endOffset: transcriptSize, + }); + if (range) { + sendRequired(peer, "terminal_snapshot", { + sessionId, + transcript: range.data, + status: session?.status ?? null, + runtimeState: session?.runtimeState ?? null, + lastOutputPreview: session?.lastOutputPreview ?? null, + capturedAt: nowIso(), + startOffset: range.startOffset, + endOffset: range.endOffset, + delta: true, + } satisfies SyncTerminalSnapshotPayload, envelope.requestId); + break; + } + } const transcript = session ? await args.ptyService.readTranscriptTail({ sessionId, - maxBytes: Math.max(1_024, Math.min(2_000_000, Math.floor(payload?.maxBytes ?? DEFAULT_TERMINAL_SNAPSHOT_BYTES))), + maxBytes, raw: true, alignToLineBoundary: true, }) : ""; + // The tail read merges still-buffered live output, so its byte length + // can exceed what the WriteStream has flushed to disk; clamp the + // derived start to 0 and let the phone's gap detection self-heal. const snapshot: SyncTerminalSnapshotPayload = { sessionId, transcript, @@ -3468,6 +3550,10 @@ export function createSyncHostService(args: SyncHostServiceArgs) { runtimeState: session?.runtimeState ?? null, lastOutputPreview: session?.lastOutputPreview ?? null, capturedAt: nowIso(), + startOffset: transcriptSize != null + ? Math.max(0, transcriptSize - Buffer.byteLength(transcript, "utf8")) + : null, + endOffset: transcriptSize, }; sendRequired(peer, "terminal_snapshot", snapshot, envelope.requestId); break; @@ -3477,7 +3563,59 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const sessionId = toOptionalString(payload?.sessionId); if (sessionId) { peer.subscribedSessionIds.delete(sessionId); + restoreDesktopTerminalSizeIfUnwatched(sessionId); + } + break; + } + case "terminal_history": { + // Pull-to-load-older paging over the transcript file. Reuses the + // terminal_input access gate: only a peer with a live subscribe for + // the session may read its history. + const payload = envelope.payload as { sessionId?: string; beforeOffset?: number; maxBytes?: number } | null; + const sessionId = toOptionalString(payload?.sessionId); + if (!sessionId) break; + const beforeOffset = typeof payload?.beforeOffset === "number" && Number.isFinite(payload.beforeOffset) + ? Math.max(0, Math.floor(payload.beforeOffset)) + : 0; + const refused: SyncTerminalHistoryResponsePayload = { + sessionId, + data: "", + startOffset: beforeOffset, + endOffset: beforeOffset, + atStart: false, + }; + const session = args.sessionService.get(sessionId); + if (!peer.subscribedSessionIds.has(sessionId) || !session) { + args.logger.warn("sync.terminal_history_unsubscribed_session", { sessionId }); + sendRequired(peer, "terminal_history", refused, envelope.requestId); + break; } + const pageBytes = Math.max( + MIN_TERMINAL_HISTORY_PAGE_BYTES, + Math.min( + MAX_TERMINAL_HISTORY_PAGE_BYTES, + Math.floor(typeof payload?.maxBytes === "number" ? payload.maxBytes : DEFAULT_TERMINAL_HISTORY_PAGE_BYTES), + ), + ); + const transcriptSize = transcriptFileSizeOrNull(session.transcriptPath); + const endOffset = Math.min(beforeOffset, transcriptSize ?? 0); + const range = await args.ptyService.readTranscriptRange({ + sessionId, + startOffset: Math.max(0, endOffset - pageBytes), + endOffset, + alignStartToSafeBoundary: true, + }); + if (!range) { + sendRequired(peer, "terminal_history", refused, envelope.requestId); + break; + } + sendRequired(peer, "terminal_history", { + sessionId, + data: range.data, + startOffset: range.startOffset, + endOffset: range.endOffset, + atStart: range.startOffset === 0, + } satisfies SyncTerminalHistoryResponsePayload, envelope.requestId); break; } case "terminal_input": { @@ -3510,7 +3648,9 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const rows = typeof payload?.rows === "number" ? Math.floor(payload.rows) : null; if (!sessionId || cols == null || rows == null) break; if (!peer.subscribedSessionIds.has(sessionId)) break; - args.ptyService.resizeBySessionId(sessionId, cols, rows); + // Tagged as mobile so the phone's viewport never becomes the + // desktop-preferred size — it is restored when the phone detaches. + args.ptyService.resizeBySessionId(sessionId, cols, rows, { source: "mobile" }); break; } case "chat_subscribe": { @@ -3974,6 +4114,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { ptyId: event.ptyId, data: event.data, at: nowIso(), + offset: event.offset ?? null, }; for (const peer of peers) { if (!peer.authenticated || !peer.subscribedSessionIds.has(event.sessionId) || peer.ws.readyState !== WebSocket.OPEN) continue; diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 2cb1c1cc6..55ae73bb2 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -4043,6 +4043,7 @@ describe("ptyService", () => { sessionId, projectRoot: "/tmp/test-project", data: "hello world", + offset: Buffer.byteLength("hello world", "utf8"), }); } finally { vi.useRealTimers(); @@ -4063,6 +4064,7 @@ describe("ptyService", () => { sessionId, projectRoot: "/tmp/test-project", data: "hello world", + offset: Buffer.byteLength("hello world", "utf8"), }); expect(broadcastExit).toHaveBeenCalledWith({ ptyId, @@ -4210,6 +4212,193 @@ describe("ptyService", () => { }); }); + describe("PTY data offsets (mobile byte-offset streaming)", () => { + it("attaches the transcript end offset across batched flushes", async () => { + vi.useFakeTimers(); + try { + const { service, mockPty, broadcastData } = createHarness(); + const { ptyId, sessionId } = await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24 }); + mockPty._emitter.emit("data", "hello "); + mockPty._emitter.emit("data", "wörld"); + await vi.advanceTimersByTimeAsync(50); + expect(broadcastData).toHaveBeenLastCalledWith({ + ptyId, + sessionId, + projectRoot: "/tmp/test-project", + data: "hello wörld", + offset: Buffer.byteLength("hello wörld", "utf8"), + }); + + mockPty._emitter.emit("data", "again"); + await vi.advanceTimersByTimeAsync(50); + expect(broadcastData).toHaveBeenLastCalledWith(expect.objectContaining({ + data: "again", + offset: Buffer.byteLength("hello wörld", "utf8") + Buffer.byteLength("again", "utf8"), + })); + } finally { + vi.useRealTimers(); + } + }); + + it("emits null offsets once the transcript byte cap is reached", async () => { + vi.useFakeTimers(); + try { + const MAX_TRANSCRIPT_BYTES = 16 * 1024 * 1024; + // Pre-existing transcript 5 bytes under the cap: the next chunk + // overflows it, so the transcript stops mirroring the stream. + mocks.fileStats.set("/tmp/transcripts/cap-session.log", { size: MAX_TRANSCRIPT_BYTES - 5 }); + const { service, mockPty, broadcastData } = createHarness(); + await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24, sessionId: "cap-session" }); + mockPty._emitter.emit("data", "0123456789"); + await vi.advanceTimersByTimeAsync(50); + expect(broadcastData).toHaveBeenLastCalledWith(expect.objectContaining({ + data: "0123456789", + offset: null, + })); + } finally { + vi.useRealTimers(); + } + }); + + it("emits null offsets for untracked sessions", async () => { + vi.useFakeTimers(); + try { + const { service, mockPty, broadcastData } = createHarness(); + await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24, tracked: false }); + mockPty._emitter.emit("data", "untracked output"); + await vi.advanceTimersByTimeAsync(50); + expect(broadcastData).toHaveBeenLastCalledWith(expect.objectContaining({ + data: "untracked output", + offset: null, + })); + } finally { + vi.useRealTimers(); + } + }); + }); + + describe("readTranscriptRange", () => { + async function createSessionWithTranscript(content: string, sessionId = "range-session") { + const harness = createHarness(); + await harness.service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24, sessionId }); + const transcriptPath = `/tmp/transcripts/${sessionId}.log`; + mocks.fileContents.set(transcriptPath, content); + mocks.fileStats.set(transcriptPath, { size: Buffer.byteLength(content, "utf8") }); + return harness; + } + + it("reads an exact byte range from the start of the transcript", async () => { + const content = "line1\nline2\nline3\n"; + const { service } = await createSessionWithTranscript(content); + const range = await service.readTranscriptRange({ + sessionId: "range-session", + startOffset: 0, + endOffset: Buffer.byteLength(content, "utf8"), + }); + expect(range).toEqual({ data: content, startOffset: 0, endOffset: 18 }); + }); + + it("scans a non-zero page start forward past the next newline", async () => { + const { service } = await createSessionWithTranscript("abcdef\nghijkl\n"); + const range = await service.readTranscriptRange({ + sessionId: "range-session", + startOffset: 2, + endOffset: 14, + alignStartToSafeBoundary: true, + }); + expect(range).toEqual({ data: "ghijkl\n", startOffset: 7, endOffset: 14 }); + }); + + it("treats an ESC byte as a safe page start", async () => { + const { service } = await createSessionWithTranscript("abc\u001b[31mred"); + const range = await service.readTranscriptRange({ + sessionId: "range-session", + startOffset: 1, + endOffset: 11, + alignStartToSafeBoundary: true, + }); + expect(range).toEqual({ data: "\u001b[31mred", startOffset: 3, endOffset: 11 }); + }); + + it("never starts a page on a UTF-8 continuation byte, even without boundary alignment", async () => { + // "héllo" = 68 C3 A9 6C 6C 6F; offset 2 lands on the é continuation byte. + const { service } = await createSessionWithTranscript("héllo"); + const range = await service.readTranscriptRange({ + sessionId: "range-session", + startOffset: 2, + endOffset: 6, + }); + expect(range).toEqual({ data: "llo", startOffset: 3, endOffset: 6 }); + }); + + it("clamps the requested range to the flushed file size", async () => { + const { service } = await createSessionWithTranscript("line1\nline2\nline3\n"); + const range = await service.readTranscriptRange({ + sessionId: "range-session", + startOffset: 12, + endOffset: 999_999, + }); + expect(range).toEqual({ data: "line3\n", startOffset: 12, endOffset: 18 }); + + const pastEof = await service.readTranscriptRange({ + sessionId: "range-session", + startOffset: 50, + endOffset: 999_999, + }); + expect(pastEof).toEqual({ data: "", startOffset: 18, endOffset: 18 }); + }); + + it("returns an empty result for a zero-length range and null for unknown sessions", async () => { + const { service } = await createSessionWithTranscript("line1\n"); + expect(await service.readTranscriptRange({ + sessionId: "range-session", + startOffset: 5, + endOffset: 5, + })).toEqual({ data: "", startOffset: 5, endOffset: 5 }); + expect(await service.readTranscriptRange({ + sessionId: "missing-session", + startOffset: 0, + endOffset: 10, + })).toBeNull(); + }); + }); + + describe("mobile resize ownership", () => { + it("restores the desktop renderer size after a mobile resize", async () => { + const { service, mockPty } = createHarness(); + const { ptyId, sessionId } = await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24 }); + + service.resize({ ptyId, cols: 100, rows: 40 }); + expect(service.resizeBySessionId(sessionId, 60, 20, { source: "mobile" })).toBe(true); + expect(mockPty.resize).toHaveBeenLastCalledWith(60, 20); + + expect(service.restoreDesktopSizeBySessionId(sessionId)).toBe(true); + expect(mockPty.resize).toHaveBeenLastCalledWith(100, 40); + // Already back at the desktop size: nothing to restore. + expect(service.restoreDesktopSizeBySessionId(sessionId)).toBe(false); + }); + + it("records sizes from resizeBySessionId unless the source is mobile", async () => { + const { service, mockPty } = createHarness(); + const { sessionId } = await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24 }); + + expect(service.resizeBySessionId(sessionId, 90, 30)).toBe(true); + expect(service.resizeBySessionId(sessionId, 61, 21, { source: "mobile" })).toBe(true); + expect(service.restoreDesktopSizeBySessionId(sessionId)).toBe(true); + expect(mockPty.resize).toHaveBeenLastCalledWith(90, 30); + }); + + it("does not restore when no desktop size was ever recorded", async () => { + const { service, mockPty } = createHarness(); + const { sessionId } = await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24 }); + + expect(service.resizeBySessionId(sessionId, 61, 21, { source: "mobile" })).toBe(true); + const resizeCalls = (mockPty.resize as ReturnType).mock.calls.length; + expect(service.restoreDesktopSizeBySessionId(sessionId)).toBe(false); + expect((mockPty.resize as ReturnType).mock.calls.length).toBe(resizeCalls); + }); + }); + describe("ensureResumeTargets", () => { it("backfills Codex storage resume targets during session-list hydration", async () => { // The session-list path is how older sessions (whose transcripts no diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 4e5543959..c5be76720 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -606,6 +606,9 @@ type PtyEntry = { cleanupPaths: string[]; lastResizeCols: number | null; lastResizeRows: number | null; + /** Last size set by a non-mobile caller, restored when a phone detaches. */ + lastDesktopCols: number | null; + lastDesktopRows: number | null; pendingDataChunks: string[]; pendingDataChars: number; pendingDataTimer: ReturnType | null; @@ -817,6 +820,21 @@ function mergeTranscriptTailWithLiveOutput(transcriptTail: string, liveOutputTai return tailString(`${transcriptTail}${liveOutputTail.slice(overlap)}`, maxChars); } +/** + * Forward-scan to the first safe place a transcript page may start: the byte + * after a newline, or an ESC (0x1B) opening a fresh escape sequence — so a + * page never begins mid-escape-sequence. Returns 0 (raw start) when the page + * contains neither. + */ +export function scanToTranscriptPageBoundary(page: Buffer): number { + for (let index = 0; index < page.length; index += 1) { + const byte = page[index]; + if (byte === 0x1b) return index; + if (byte === 0x0a) return index + 1; + } + return 0; +} + function runtimeFromStatus(status: TerminalSessionStatus): TerminalRuntimeState { if (status === "running") return "running"; if (status === "disposed") return "killed"; @@ -2892,7 +2910,17 @@ export function createPtyService({ if (!data) return; ptyDataBatchCount += 1; ptyDataMaxBatchChars = Math.max(ptyDataMaxBatchChars, data.length); - emitPtyDataNow(entry, { ...ids, data }); + // writeTranscript runs synchronously in the same onData tick that enqueued + // each chunk, so transcriptBytesWritten here is exactly the transcript end + // offset of this batch. Offsets go null once the transcript stops + // mirroring the stream (byte cap reached, write failure, untracked). + const offset = entry.tracked + && entry.transcriptStream + && !entry.transcriptLimitReached + && !entry.transcriptWriteDisabled + ? entry.transcriptBytesWritten + : null; + emitPtyDataNow(entry, { ...ids, data, offset }); }; const enqueuePtyData = (entry: PtyEntry, event: PtyDataEvent) => { @@ -3809,6 +3837,8 @@ export function createPtyService({ cleanupPaths, lastResizeCols: null, lastResizeRows: null, + lastDesktopCols: null, + lastDesktopRows: null, pendingDataChunks: [], pendingDataChars: 0, pendingDataTimer: null, @@ -4600,6 +4630,11 @@ export function createPtyService({ const entry = ptys.get(ptyId); if (!entry) return; 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; try { entry.pty.resize(safe.cols, safe.rows); @@ -4640,13 +4675,19 @@ export function createPtyService({ * when their visible terminal viewport changes (orientation flip, split * view, font-size change). Returns true on success. */ - resizeBySessionId(sessionId: string, cols: number, rows: number): boolean { + resizeBySessionId(sessionId: string, cols: number, rows: number, opts?: { source?: "desktop" | "mobile" }): boolean { if (!sessionId) return false; const entry = Array.from(ptys.values()).find( (candidate) => candidate.sessionId === sessionId && !candidate.disposed, ); if (!entry) return false; 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 (entry.lastResizeCols === safe.cols && entry.lastResizeRows === safe.rows) return true; try { entry.pty.resize(safe.cols, safe.rows); @@ -4660,6 +4701,34 @@ export function createPtyService({ } }, + /** + * Resize the active PTY for a session back to the last desktop-preferred + * size. Called when the last subscribed mobile peer detaches so a phone's + * viewport does not linger on the desktop terminal. Returns true when a + * restore was performed. + */ + restoreDesktopSizeBySessionId(sessionId: string): boolean { + if (!sessionId) return false; + const entry = Array.from(ptys.values()).find( + (candidate) => candidate.sessionId === sessionId && !candidate.disposed, + ); + if (!entry) return false; + const cols = entry.lastDesktopCols; + const rows = entry.lastDesktopRows; + if (cols == null || rows == null) return 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); + return true; + } catch (err) { + logger.warn("pty.restore_desktop_size_failed", { sessionId, err: String(err) }); + return false; + } + }, + getRuntimeState(sessionId: string, fallbackStatus: TerminalSessionStatus): TerminalRuntimeState { return computeRuntimeState(sessionId, fallbackStatus); }, @@ -4700,6 +4769,66 @@ export function createPtyService({ return args.raw ? merged : stripAnsi(merged); }, + /** + * Read an exact byte range of a session transcript (mobile history + * paging / delta resume). The transcript WriteStream buffers, so disk can + * lag `transcriptBytesWritten` (and any offset derived from it) by a few + * ms — both offsets are clamped to the flushed file size and the achieved + * range is reported back; clients detect the gap and re-request. When + * `alignStartToSafeBoundary` is set, a non-zero start is scanned forward + * to the byte after a `\n` or to an ESC byte so a page never begins + * mid-escape-sequence. A non-zero start always skips UTF-8 continuation + * bytes so decoding never splits a code point. Returns null when the + * session is unknown or has no transcript. + */ + async readTranscriptRange(args: { + sessionId: string; + startOffset: number; + endOffset: number; + alignStartToSafeBoundary?: boolean; + }): Promise<{ data: string; startOffset: number; endOffset: number } | null> { + const sessionId = typeof args.sessionId === "string" ? args.sessionId.trim() : ""; + if (!sessionId) return null; + const session = sessionService.get(sessionId); + const transcriptPath = session?.transcriptPath?.trim(); + if (!transcriptPath) return null; + let fd: number | null = null; + try { + const fileSize = Math.max(0, Number(fs.statSync(transcriptPath).size) || 0); + const end = Math.max(0, Math.min(Math.floor(args.endOffset), fileSize)); + const start = Math.min(Math.max(0, Math.floor(args.startOffset)), end); + if (end <= start) return { data: "", startOffset: end, endOffset: end }; + fd = fs.openSync(transcriptPath, "r"); + const buf = Buffer.alloc(end - start); + const bytesRead = fs.readSync(fd, buf, 0, buf.length, start); + const page = buf.subarray(0, Math.max(0, bytesRead)); + let boundary = 0; + if (start > 0) { + if (args.alignStartToSafeBoundary) { + boundary = scanToTranscriptPageBoundary(page); + } + while (boundary < page.length && (page[boundary]! & 0b1100_0000) === 0b1000_0000) { + boundary += 1; + } + } + return { + data: page.subarray(boundary).toString("utf8"), + startOffset: start + boundary, + endOffset: start + page.length, + }; + } catch { + return null; + } finally { + if (fd !== null) { + try { + fs.closeSync(fd); + } catch { + // Ignore close errors on best-effort transcript reads. + } + } + } + }, + enrichSessions(rows: T[]): T[] { return rows.map((row) => { const live = liveEntryBySessionId(row.id); diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index ca8a72d1c..76aeb21e7 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -1619,7 +1619,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { }, })); await waitFor(() => resizeBySessionId.mock.calls.length === 1); - expect(resizeBySessionId).toHaveBeenCalledWith("session-1", 120, 34); + expect(resizeBySessionId).toHaveBeenCalledWith("session-1", 120, 34, { source: "mobile" }); host.handlePtyData({ ptyId: "pty-1", diff --git a/apps/desktop/src/shared/types/sessions.ts b/apps/desktop/src/shared/types/sessions.ts index d58b547ee..3552a2afa 100644 --- a/apps/desktop/src/shared/types/sessions.ts +++ b/apps/desktop/src/shared/types/sessions.ts @@ -200,6 +200,12 @@ export type PtyDataEvent = { sessionId: string; projectRoot?: string; data: string; + /** + * Transcript end offset (UTF-8 bytes) after this chunk was appended. null + * when the transcript can no longer mirror the stream (untracked session, + * transcript writes disabled, or the transcript byte cap was reached). + */ + offset?: number | null; }; export type PtyExitEvent = { diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 0ba827214..44a3de541 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -434,6 +434,13 @@ export type SyncFileResponsePayload = { export type SyncTerminalSubscribePayload = { sessionId: string; maxBytes?: number; + /** + * Resume marker: transcript byte offset the client has already applied. + * When the host can serve `sinceOffset .. end` within the maxBytes budget + * it replies with a delta snapshot (`delta: true`) the client appends; + * otherwise it falls back to the regular tail snapshot. + */ + sinceOffset?: number; }; export type SyncTerminalUnsubscribePayload = { @@ -447,6 +454,12 @@ export type SyncTerminalSnapshotPayload = { runtimeState: string | null; lastOutputPreview: string | null; capturedAt: string; + /** Transcript byte offset where `transcript` begins. null when unknown. */ + startOffset?: number | null; + /** Transcript byte offset `transcript` covers through. null when unknown. */ + endOffset?: number | null; + /** True when `transcript` only contains bytes from the requested `sinceOffset` (client appends instead of replacing). */ + delta?: boolean; }; export type SyncTerminalDataPayload = { @@ -454,6 +467,31 @@ export type SyncTerminalDataPayload = { ptyId: string; data: string; at: string; + /** + * Transcript end offset (UTF-8 bytes) after this chunk. null/omitted when + * unavailable (untracked session, transcript writes disabled, byte cap). + */ + offset?: number | null; +}; + +// Mobile pull-to-load-older request: return transcript bytes +// [startOffset, endOffset) where endOffset = min(beforeOffset, transcript +// size) and the page is ~maxBytes. The host scans startOffset forward to a +// safe boundary (byte after `\n`, or an ESC byte) so a page never starts +// mid-escape-sequence — unless the page starts at offset 0. +export type SyncTerminalHistoryRequestPayload = { + sessionId: string; + beforeOffset: number; + maxBytes?: number; +}; + +export type SyncTerminalHistoryResponsePayload = { + sessionId: string; + data: string; + startOffset: number; + endOffset: number; + /** True when this page starts at the very beginning of the transcript. */ + atStart: boolean; }; export type SyncTerminalExitPayload = { @@ -1041,6 +1079,7 @@ export type SyncTerminalDataEnvelope = SyncEnvelopeWithPayload<"terminal_data", export type SyncTerminalExitEnvelope = SyncEnvelopeWithPayload<"terminal_exit", SyncTerminalExitPayload>; export type SyncTerminalInputEnvelope = SyncEnvelopeWithPayload<"terminal_input", SyncTerminalInputPayload>; export type SyncTerminalResizeEnvelope = SyncEnvelopeWithPayload<"terminal_resize", SyncTerminalResizePayload>; +export type SyncTerminalHistoryEnvelope = SyncEnvelopeWithPayload<"terminal_history", SyncTerminalHistoryRequestPayload | SyncTerminalHistoryResponsePayload>; export type SyncChatSubscribeEnvelope = SyncEnvelopeWithPayload<"chat_subscribe", SyncChatSubscribePayload | SyncChatSubscribeSnapshotPayload>; export type SyncChatUnsubscribeEnvelope = SyncEnvelopeWithPayload<"chat_unsubscribe", SyncChatUnsubscribePayload>; export type SyncChatEventEnvelope = SyncEnvelopeWithPayload<"chat_event", SyncChatEventPayload>; @@ -1090,6 +1129,7 @@ export type SyncEnvelope = | SyncTerminalExitEnvelope | SyncTerminalInputEnvelope | SyncTerminalResizeEnvelope + | SyncTerminalHistoryEnvelope | SyncChatSubscribeEnvelope | SyncChatUnsubscribeEnvelope | SyncChatEventEnvelope diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index e44aeed69..9eca0f8c4 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 46; + objectVersion = 56; objects = { /* Begin PBXBuildFile section */ @@ -194,6 +194,9 @@ E10000000000000000000045 /* WorkSelectionActionBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000045 /* WorkSelectionActionBar.swift */; }; E10000000000000000000046 /* WorkRootScreen+Selection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000046 /* WorkRootScreen+Selection.swift */; }; E10000000000000000000048 /* WorkTerminalEmulatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000048 /* WorkTerminalEmulatorView.swift */; }; + E10000000000000000000049 /* TerminalSessionScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000049 /* TerminalSessionScreen.swift */; }; + E1000000000000000000004A /* SwiftTermSessionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000004A /* SwiftTermSessionView.swift */; }; + DD20000000000000000000B1 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = DD10000000000000000000B1 /* SwiftTerm */; }; E689F42D41A500BB8CA233E4 /* LanesTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9270CF8A67F3FA79089F39C1 /* LanesTabView.swift */; }; F2A1C9D8456E7B3C1D2E4F90 /* FilesCodeSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6F9B21C0E4A6D8F1B3C5A77 /* FilesCodeSupport.swift */; }; FBEEF09EFB4911FEAC6A7E87 /* RemoteModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 483C5F1818BAE74B19B84617 /* RemoteModels.swift */; }; @@ -297,6 +300,8 @@ D10000000000000000000045 /* WorkSelectionActionBar.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSelectionActionBar.swift; path = ADE/Views/Work/WorkSelectionActionBar.swift; sourceTree = ""; }; D10000000000000000000046 /* WorkRootScreen+Selection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "WorkRootScreen+Selection.swift"; path = "ADE/Views/Work/WorkRootScreen+Selection.swift"; sourceTree = ""; }; D10000000000000000000048 /* WorkTerminalEmulatorView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkTerminalEmulatorView.swift; path = ADE/Views/Work/WorkTerminalEmulatorView.swift; sourceTree = ""; }; + D10000000000000000000049 /* TerminalSessionScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = TerminalSessionScreen.swift; path = ADE/Views/Work/TerminalSessionScreen.swift; sourceTree = ""; }; + D1000000000000000000004A /* SwiftTermSessionView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SwiftTermSessionView.swift; path = ADE/Views/Work/SwiftTermSessionView.swift; sourceTree = ""; }; D1000000000000000000003C /* WorkSessionGrouping.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSessionGrouping.swift; path = ADE/Views/Work/WorkSessionGrouping.swift; sourceTree = ""; }; H10000000000000000000010 /* CtoRootScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoRootScreen.swift; path = ADE/Views/Cto/CtoRootScreen.swift; sourceTree = ""; }; H10000000000000000000011 /* CtoSessionDestinationView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = CtoSessionDestinationView.swift; path = ADE/Views/Cto/CtoSessionDestinationView.swift; sourceTree = ""; }; @@ -416,6 +421,7 @@ buildActionMask = 2147483647; files = ( 60F4CDDB763C0A9F0E650B40 /* Foundation.framework in Frameworks */, + DD20000000000000000000B1 /* SwiftTerm in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -626,6 +632,8 @@ D10000000000000000000046 /* WorkRootScreen+Selection.swift */, D10000000000000000000047 /* ADEInspectable.swift */, D10000000000000000000048 /* WorkTerminalEmulatorView.swift */, + D10000000000000000000049 /* TerminalSessionScreen.swift */, + D1000000000000000000004A /* SwiftTermSessionView.swift */, D1000000000000000000003C /* WorkSessionGrouping.swift */, D1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift */, D1000000000000000000002D /* WorkChatRichCardViews.swift */, @@ -847,6 +855,9 @@ BB4400000000000000000002 /* PBXTargetDependency */, ); name = ADE; + packageProductDependencies = ( + DD10000000000000000000B1 /* SwiftTerm */, + ); productName = ADE; productReference = 27A125DE2C17BA32F9291513 /* ADE.app */; productType = "com.apple.product-type.application"; @@ -958,6 +969,9 @@ Base, ); mainGroup = 564208E3878DD0F0137C7E86; + packageReferences = ( + DD10000000000000000000A1 /* XCRemoteSwiftPackageReference "SwiftTerm" */, + ); productRefGroup = 7FAD2BF35AA6A286BCF68D49 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -1126,6 +1140,8 @@ E10000000000000000000045 /* WorkSelectionActionBar.swift in Sources */, E10000000000000000000046 /* WorkRootScreen+Selection.swift in Sources */, E10000000000000000000048 /* WorkTerminalEmulatorView.swift in Sources */, + E10000000000000000000049 /* TerminalSessionScreen.swift in Sources */, + E1000000000000000000004A /* SwiftTermSessionView.swift in Sources */, E1000000000000000000003C /* WorkSessionGrouping.swift in Sources */, E1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift in Sources */, E1000000000000000000002D /* WorkChatRichCardViews.swift in Sources */, @@ -1607,6 +1623,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + DD10000000000000000000A1 /* XCRemoteSwiftPackageReference "SwiftTerm" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/migueldeicaza/SwiftTerm"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.13.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + DD10000000000000000000B1 /* SwiftTerm */ = { + isa = XCSwiftPackageProductDependency; + package = DD10000000000000000000A1 /* XCRemoteSwiftPackageReference "SwiftTerm" */; + productName = SwiftTerm; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 9CBC925352322D208431EFAA /* Project object */; } diff --git a/apps/ios/ADE.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apps/ios/ADE.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..913cf77b8 --- /dev/null +++ b/apps/ios/ADE.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "4d9d9af82f23f3c708bdd502fed3939413b4f2a95a79ae568364cc92bca1527e", + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, + { + "identity" : "swiftterm", + "kind" : "remoteSourceControl", + "location" : "https://github.com/migueldeicaza/SwiftTerm", + "state" : { + "revision" : "8e7a1e154f470e19c709a00a8768df348ba5fc43", + "version" : "1.13.0" + } + } + ], + "version" : 3 +} diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 2e633cd80..a034c44b5 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -3228,6 +3228,23 @@ struct TerminalSnapshot: Codable, Equatable { var runtimeState: String? var lastOutputPreview: String? var capturedAt: String + /// Transcript byte offsets (UTF-8) covered by `transcript`. Absent on older + /// hosts, which also never set `delta`. + var startOffset: Int? + var endOffset: Int? + /// True when `transcript` only contains bytes from the requested + /// `sinceOffset` to the end — append, don't replace. + var delta: Bool? +} + +/// Response payload for `terminal_history`: transcript bytes +/// [startOffset, endOffset) ending at/before the requested `beforeOffset`. +struct TerminalHistorySlice: Codable, Equatable { + var sessionId: String + var data: String + var startOffset: Int + var endOffset: Int + var atStart: Bool } struct StartCliSessionResult: Codable, Equatable { diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 94b463ab1..094d298c6 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -255,6 +255,10 @@ enum SyncRequestTimeout { } private let syncTerminalSubscriptionMaxBytes = 240_000 +/// Snapshot budget for the full-screen SwiftTerm session view, which renders +/// real scrollback instead of a trimmed preview string. +private let syncTerminalStreamMaxBytes = 512_000 +private let syncTerminalHistoryMaxBytes = 262_144 private let syncChatSubscriptionMaxBytes = 2_000_000 // 512KB, up from 160KB: the old budget silently truncated reasoning-heavy // turns on cellular/Tailscale routes. Chunked envelopes plus off-main decode @@ -1089,6 +1093,7 @@ func syncOutboundEnvelopeProjectId(type: String, activeProjectId: String?) -> St "terminal_unsubscribe", "terminal_input", "terminal_resize", + "terminal_history", "chat_subscribe", "chat_unsubscribe", ] @@ -1101,6 +1106,18 @@ struct SyncSendTestPushResult: Equatable { var message: String } +/// Delivery events for the full-screen terminal. The active screen attaches a +/// handler per session id and receives hydration snapshots, ordered live +/// chunks, and process exit without polling `terminalBuffers`. +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?) + case chunk(text: String, endOffset: Int?) + case exit(code: Int?) +} + @MainActor final class SyncService: ObservableObject { @Published private(set) var connectionState: RemoteConnectionState = .disconnected @@ -1173,6 +1190,12 @@ final class SyncService: ObservableObject { private(set) var terminalBuffers: [String: String] = [:] private(set) var terminalBufferUpdatedAt: [String: Date] = [:] + /// Transcript END byte offset (UTF-8) confirmed per terminal session, fed by + /// `terminal_data.offset` and snapshot `endOffset`. Nil for hosts that do not + /// emit offsets; all gap/dedupe logic disables itself in that case. + private(set) var terminalEndOffsets: [String: Int] = [:] + private var terminalStreamHandlers: [String: (TerminalStreamEvent) -> Void] = [:] + private var terminalGapRecoveryInFlight: Set = [] private(set) var chatEventEnvelopesBySession: [String: [AgentChatEventEnvelope]] = [:] private(set) var chatEventRevisionsBySession: [String: Int] = [:] /// Highest host-assigned `seq` applied per chat session. Sent back as @@ -3742,20 +3765,143 @@ final class SyncService: ObservableObject { try await refreshTerminalSnapshot(sessionId: trimmedSessionId) } - func refreshTerminalSnapshot(sessionId: String, maxBytes: Int = syncTerminalSubscriptionMaxBytes) async throws { + func refreshTerminalSnapshot(sessionId: String, maxBytes: Int = syncTerminalSubscriptionMaxBytes, sinceOffset: Int? = nil) async throws { let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedSessionId.isEmpty else { return } subscribedTerminalSessionIds.insert(trimmedSessionId) let requestId = makeRequestId() + var payload: [String: Any] = [ + "sessionId": trimmedSessionId, + "maxBytes": max(1_024, min(syncTerminalStreamMaxBytes, maxBytes)), + ] + if let sinceOffset, sinceOffset >= 0 { + payload["sinceOffset"] = sinceOffset + } let raw = try await awaitResponse(requestId: requestId) { - self.sendEnvelope(type: "terminal_subscribe", requestId: requestId, payload: [ - "sessionId": trimmedSessionId, - "maxBytes": max(1_024, min(syncTerminalSubscriptionMaxBytes, maxBytes)), - ]) + self.sendEnvelope(type: "terminal_subscribe", requestId: requestId, payload: payload) } let snapshot = try decode(raw, as: TerminalSnapshot.self) guard subscribedTerminalSessionIds.contains(trimmedSessionId) else { return } - updateTerminalBuffer(sessionId: trimmedSessionId, transcript: snapshot.transcript, immediate: true) + applyTerminalSnapshot(snapshot, sessionId: trimmedSessionId) + } + + /// Full-screen terminal subscribe: attaches at the 512K budget and resumes + /// exactly after `sinceOffset` when the caller still holds earlier bytes. + func subscribeTerminalStream(sessionId: String, sinceOffset: Int? = nil) async throws { + try await refreshTerminalSnapshot(sessionId: sessionId, maxBytes: syncTerminalStreamMaxBytes, sinceOffset: sinceOffset) + } + + /// Registers the active full-screen terminal as the live delivery target for + /// `sessionId`. One handler per session; the screen detaches on disappear. + func attachTerminalStream(sessionId: String, handler: @escaping (TerminalStreamEvent) -> Void) { + let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSessionId.isEmpty else { return } + terminalStreamHandlers[trimmedSessionId] = handler + } + + func detachTerminalStream(sessionId: String) { + terminalStreamHandlers.removeValue(forKey: sessionId) + } + + /// Fetches transcript bytes ending at/before `beforeOffset` for on-demand + /// scrollback paging. Requires an active `terminal_subscribe` on the host. + func fetchTerminalHistory(sessionId: String, beforeOffset: Int, maxBytes: Int = syncTerminalHistoryMaxBytes) async throws -> TerminalHistorySlice { + let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSessionId.isEmpty else { + throw NSError(domain: "ADE", code: 6, userInfo: [NSLocalizedDescriptionKey: "Missing terminal session id."]) + } + let requestId = makeRequestId() + // Older hosts never answer terminal_history; let the request time out + // without tearing the socket down. + let raw = try await awaitResponse(requestId: requestId, disconnectOnTimeout: false) { + self.sendEnvelope(type: "terminal_history", requestId: requestId, payload: [ + "sessionId": trimmedSessionId, + "beforeOffset": beforeOffset, + "maxBytes": max(1_024, min(syncTerminalStreamMaxBytes, maxBytes)), + ]) + } + return try decode(raw, as: TerminalHistorySlice.self) + } + + /// Applies a live `terminal_data` chunk with offset-based ordering. `endOffset` + /// is the transcript END byte offset after this chunk (nil on older hosts). + private func handleTerminalDataChunk(sessionId: String, chunk: String, endOffset: Int?) { + var deliverableChunk: String? = chunk + if let endOffset { + if let lastEnd = terminalEndOffsets[sessionId] { + let chunkStart = endOffset - chunk.utf8.count + if endOffset <= lastEnd { + // Replay of bytes a snapshot/delta already covered — drop entirely. + deliverableChunk = nil + } else if chunkStart > lastEnd { + // Missed bytes between lastEnd and this chunk. Drop the chunk and + // let a delta resubscribe (sinceOffset = lastEnd) deliver the full + // contiguous range; feeding it now would render out of order. + deliverableChunk = nil + recoverTerminalGap(sessionId: sessionId, sinceOffset: lastEnd) + } else { + if chunkStart < lastEnd { + // Partial overlap: trim the already-applied UTF-8 prefix. + let overlap = lastEnd - chunkStart + deliverableChunk = String(decoding: Array(chunk.utf8).dropFirst(overlap), as: UTF8.self) + } + terminalEndOffsets[sessionId] = endOffset + } + } else { + terminalEndOffsets[sessionId] = endOffset + } + } + guard let deliverableChunk, !deliverableChunk.isEmpty else { + terminalBufferUpdatedAt[sessionId] = Date() + return + } + terminalBuffers[sessionId] = trimmedTerminalBuffer((terminalBuffers[sessionId] ?? "") + deliverableChunk) + terminalBufferUpdatedAt[sessionId] = Date() + markTerminalBufferChanged() + terminalStreamHandlers[sessionId]?(.chunk(text: deliverableChunk, endOffset: endOffset)) + } + + private func recoverTerminalGap(sessionId: String, sinceOffset: Int) { + guard !terminalGapRecoveryInFlight.contains(sessionId) else { return } + terminalGapRecoveryInFlight.insert(sessionId) + Task { @MainActor [weak self] in + guard let self else { return } + defer { self.terminalGapRecoveryInFlight.remove(sessionId) } + try? await self.refreshTerminalSnapshot( + sessionId: sessionId, + maxBytes: syncTerminalStreamMaxBytes, + sinceOffset: sinceOffset + ) + } + } + + private func applyTerminalSnapshot(_ snapshot: TerminalSnapshot, sessionId: String) { + if let endOffset = snapshot.endOffset { + terminalEndOffsets[sessionId] = endOffset + } + if snapshot.delta == true { + // Delta resume: payload covers exactly [sinceOffset, end). Append — + // replacing would drop everything the screen already rendered. + if !snapshot.transcript.isEmpty { + terminalBuffers[sessionId] = trimmedTerminalBuffer((terminalBuffers[sessionId] ?? "") + snapshot.transcript) + markTerminalBufferChanged(immediate: true) + terminalStreamHandlers[sessionId]?(.hydrate( + text: snapshot.transcript, + replacing: false, + startOffset: snapshot.startOffset, + endOffset: snapshot.endOffset + )) + } + terminalBufferUpdatedAt[sessionId] = Date() + } else { + updateTerminalBuffer(sessionId: sessionId, transcript: snapshot.transcript, immediate: true) + terminalStreamHandlers[sessionId]?(.hydrate( + text: snapshot.transcript, + replacing: true, + startOffset: snapshot.startOffset, + endOffset: snapshot.endOffset + )) + } } func unsubscribeTerminal(sessionId: String) async throws { @@ -7302,7 +7448,7 @@ final class SyncService: ObservableObject { let message = dict["message"] as? String ?? "Remote command rejected." resolve(requestId: requestId, result: .failure(NSError(domain: "ADE", code: 6, userInfo: [NSLocalizedDescriptionKey: message]))) } - case "command_result", "file_response", "terminal_snapshot": + case "command_result", "file_response", "terminal_snapshot", "terminal_history": resolve(requestId: requestId, result: .success(payload)) case "in_app_notification": if let dict = payload as? [String: Any] { @@ -7345,9 +7491,7 @@ final class SyncService: ObservableObject { case "terminal_data": if let dict = payload as? [String: Any], let sessionId = dict["sessionId"] as? String, let chunk = dict["data"] as? String { guard subscribedTerminalSessionIds.contains(sessionId) else { break } - terminalBuffers[sessionId] = trimmedTerminalBuffer((terminalBuffers[sessionId] ?? "") + chunk) - terminalBufferUpdatedAt[sessionId] = Date() - markTerminalBufferChanged() + handleTerminalDataChunk(sessionId: sessionId, chunk: chunk, endOffset: (dict["offset"] as? NSNumber)?.intValue) } case "terminal_exit": if let dict = payload as? [String: Any], let sessionId = dict["sessionId"] as? String { @@ -7356,6 +7500,7 @@ final class SyncService: ObservableObject { terminalBuffers[sessionId] = trimmedTerminalBuffer((terminalBuffers[sessionId] ?? "") + "\n\n[process exited\(exitCode.map { " with \($0)" } ?? "")]") terminalBufferUpdatedAt[sessionId] = Date() markTerminalBufferChanged(immediate: true) + terminalStreamHandlers[sessionId]?(.exit(code: exitCode)) } default: break @@ -8106,7 +8251,16 @@ final class SyncService: ObservableObject { guard let self else { return } for sessionId in sessionIds { self.subscribedTerminalSessionIds.remove(sessionId) - try? await self.subscribeTerminal(sessionId: sessionId) + if self.terminalStreamHandlers[sessionId] != nil { + // Active full-screen terminal: resume exactly after the last byte we + // applied so the reconnect back-fills as an append, not a re-render. + try? await self.subscribeTerminalStream( + sessionId: sessionId, + sinceOffset: self.terminalEndOffsets[sessionId] + ) + } else { + try? await self.subscribeTerminal(sessionId: sessionId) + } } } } @@ -8261,6 +8415,7 @@ final class SyncService: ObservableObject { subscribedTerminalSessionIds.removeAll() terminalBuffers.removeAll() terminalBufferUpdatedAt.removeAll() + terminalEndOffsets.removeAll() } terminalBufferRevision += 1 } diff --git a/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift b/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift new file mode 100644 index 000000000..8c6073548 --- /dev/null +++ b/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift @@ -0,0 +1,514 @@ +import SwiftUI +import SwiftTerm +import UIKit + +/// SwiftTerm's iOS view snaps the viewport to the bottom on every emitted +/// scroll event (i.e. each new output line). While the user is reading +/// scrollback we suppress that snap so streaming output cannot yank the view; +/// `resumeAutoScroll()` re-enables following and performs one snap-to-bottom. +final class ADESwiftTermView: TerminalView { + var holdScrollOnOutput = false + var onLayout: (() -> Void)? + + override func scrolled(source terminal: Terminal, yDisp: Int) { + guard !holdScrollOnOutput else { return } + super.scrolled(source: terminal, yDisp: yDisp) + } + + func resumeAutoScroll() { + holdScrollOnOutput = false + // super's implementation ignores yDisp: it re-syncs contentSize and pins + // the viewport to the bottom of the scrollback. + super.scrolled(source: getTerminal(), yDisp: 0) + } + + override func layoutSubviews() { + super.layoutSubviews() + onLayout?() + } +} + +/// Owns the SwiftTerm view, its delegate wiring, the loaded transcript byte +/// window (for history paging), input coalescing, and scroll-pinning state for +/// the full-screen terminal session. +@MainActor +final class TerminalSessionController: NSObject, ObservableObject { + @Published private(set) var isPinnedToBottom = true + /// Live chunks received since the user scrolled up; drives "↓ Live N". + @Published private(set) var liveChunksWhileScrolledUp = 0 + @Published private(set) var isLoadingHistory = false + @Published private(set) var hasExited = false + @Published private(set) var isSubscribed = false + @Published private(set) var ctrlArmed = false + @Published private(set) var bellPulse = 0 + /// Bumped when typed input is dropped because the host is unreachable. + @Published private(set) var blockedInputPulse = 0 + @Published private(set) var fontSize: CGFloat = TerminalSessionController.defaultFontSize + + static let defaultFontSize: CGFloat = 12 + private static let minFontSize: CGFloat = 8 + private static let maxFontSize: CGFloat = 20 + private static let transcriptByteCap = 4 * 1024 * 1024 + private static let scrollbackLines = 10_000 + + private(set) var sessionId = "" + private weak var syncService: SyncService? + private var terminalView: ADESwiftTermView? + + // Loaded transcript window as raw bytes plus its host byte offsets. The + // window is what gets re-fed into SwiftTerm on rebuilds (history prepends, + // full-replace hydrations). + private var transcript = Data() + private var transcriptStartOffset: Int? + private var transcriptEndOffset: Int? + private var historyAtStart = false + + // Never feed bytes before the PTY has our real cols/rows: the hydrate + // snapshot would wrap at the desktop width. Events queue until first layout. + private var readyToFeed = false + private var queuedEvents: [TerminalStreamEvent] = [] + + // ~16ms input coalescing so rapid keystrokes ride a single envelope. + private var pendingInput = "" + private var inputFlushTask: Task? + + private var lastSentSize: (cols: Int, rows: Int)? + private var pinchBaseFontSize: CGFloat = TerminalSessionController.defaultFontSize + private var ctrlResetObserver: NSObjectProtocol? + + deinit { + if let ctrlResetObserver { + NotificationCenter.default.removeObserver(ctrlResetObserver) + } + } + + // MARK: - Lifecycle + + func activate(syncService: SyncService, sessionId: String, sessionStatus: String) { + self.syncService = syncService + self.sessionId = sessionId + let status = sessionStatus.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if status == "ended" || status == "exited" || status == "stopped" { + hasExited = true + } + syncService.attachTerminalStream(sessionId: sessionId) { [weak self] event in + self?.handleStreamEvent(event) + } + 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 + } catch { + self.isSubscribed = false + } + } + } + + func deactivate() { + inputFlushTask?.cancel() + inputFlushTask = nil + pendingInput = "" + guard let syncService, !sessionId.isEmpty else { return } + syncService.detachTerminalStream(sessionId: sessionId) + isSubscribed = false + let id = sessionId + Task { try? await syncService.unsubscribeTerminal(sessionId: id) } + } + + func handleConnectionChange(isConnected: Bool) { + if isConnected { + // The sync layer re-subscribes with sinceOffset on reconnect; we only + // need to re-assert the phone's PTY size. + isSubscribed = true + lastSentSize = nil + if let terminal = terminalView?.getTerminal() { + sendResizeIfNeeded(cols: terminal.cols, rows: terminal.rows) + } + } else { + isSubscribed = false + } + } + + // MARK: - Terminal view + + func makeTerminalView() -> ADESwiftTermView { + if let terminalView { + return terminalView + } + let view = ADESwiftTermView( + frame: CGRect(x: 0, y: 0, width: 390, height: 640), + font: UIFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) + ) + view.terminalDelegate = self + view.delegate = self + // The SwiftUI key bar replaces SwiftTerm's stock accessory strip. + view.inputAccessoryView = nil + view.backgroundColor = .black + view.nativeBackgroundColor = .black + view.nativeForegroundColor = UIColor(white: 0.92, alpha: 1) + view.keyboardAppearance = .dark + view.changeScrollback(Self.scrollbackLines) + view.onLayout = { [weak self] in + self?.handleTerminalLayout() + } + view.addGestureRecognizer(UIPinchGestureRecognizer(target: self, action: #selector(handlePinch(_:)))) + // SwiftTerm auto-resets controlModifier after the next key; mirror that + // into the latched-Ctrl chip. + ctrlResetObserver = NotificationCenter.default.addObserver( + forName: Notification.Name("SwiftTerm.TerminalView.controlModifierReset"), + object: view, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.ctrlArmed = false + } + } + terminalView = view + return view + } + + private func handleTerminalLayout() { + guard let view = terminalView, view.bounds.width > 10, view.bounds.height > 10 else { return } + let terminal = view.getTerminal() + guard terminal.cols > 1, terminal.rows > 1 else { return } + // Resize the PTY BEFORE the first feed so hydrated content wraps for the + // phone's viewport, not the desktop's. + sendResizeIfNeeded(cols: terminal.cols, rows: terminal.rows) + if !readyToFeed { + readyToFeed = true + let queued = queuedEvents + queuedEvents = [] + queued.forEach(applyStreamEvent) + } + } + + // MARK: - Stream events + + private func handleStreamEvent(_ event: TerminalStreamEvent) { + guard readyToFeed else { + queuedEvents.append(event) + return + } + applyStreamEvent(event) + } + + private func applyStreamEvent(_ event: TerminalStreamEvent) { + switch event { + case .hydrate(let text, let replacing, let startOffset, let endOffset): + if replacing { + transcript = Data(text.utf8) + transcriptStartOffset = startOffset + transcriptEndOffset = endOffset + historyAtStart = startOffset == 0 + rebuildTerminal() + } else { + appendBytes(Data(text.utf8), endOffset: endOffset, countsAsLive: false) + } + case .chunk(let text, let endOffset): + appendBytes(Data(text.utf8), endOffset: endOffset, countsAsLive: true) + case .exit: + hasExited = true + } + } + + private func appendBytes(_ bytes: Data, endOffset: Int?, countsAsLive: Bool) { + if let endOffset { + transcriptEndOffset = endOffset + } + guard !bytes.isEmpty else { return } + transcript.append(bytes) + enforceTranscriptCap() + terminalView?.feed(byteArray: [UInt8](bytes)[...]) + if countsAsLive, !isPinnedToBottom { + liveChunksWhileScrolledUp += 1 + } + } + + private func enforceTranscriptCap() { + guard transcript.count > Self.transcriptByteCap else { return } + // Drop well below the cap so steady output doesn't re-trim every chunk. + let target = Self.transcriptByteCap - 512 * 1024 + let dropCount = transcript.count - target + transcript.removeFirst(dropCount) + if let start = transcriptStartOffset { + transcriptStartOffset = start + dropCount + } + historyAtStart = false + } + + private func rebuildTerminal() { + guard let view = terminalView else { return } + view.holdScrollOnOutput = false + view.getTerminal().resetToInitialState() + if !transcript.isEmpty { + view.feed(byteArray: [UInt8](transcript)[...]) + } + isPinnedToBottom = true + liveChunksWhileScrolledUp = 0 + view.resumeAutoScroll() + } + + // MARK: - History paging + + func loadOlderHistoryIfNeeded() { + guard !isLoadingHistory, !historyAtStart else { return } + guard let syncService else { return } + guard let startOffset = transcriptStartOffset, startOffset > 0 else { return } + guard transcript.count < Self.transcriptByteCap else { return } + isLoadingHistory = true + let id = sessionId + Task { @MainActor [weak self] in + defer { self?.isLoadingHistory = false } + guard let self else { return } + do { + let slice = try await syncService.fetchTerminalHistory(sessionId: id, beforeOffset: startOffset) + // A replace-hydration may have landed while the fetch was in flight; + // prepending stale bytes would corrupt the window. + guard self.transcriptStartOffset == startOffset, slice.endOffset <= startOffset else { return } + if !slice.data.isEmpty { + self.transcript = Data(slice.data.utf8) + self.transcript + self.transcriptStartOffset = slice.startOffset + self.rebuildPreservingViewport() + } + self.historyAtStart = slice.atStart + } catch { + // Older host (no terminal_history) or transient failure: stop paging + // for this screen session instead of hammering the socket. + self.historyAtStart = true + } + } + } + + /// SwiftTerm has no prepend; after splicing older bytes in front we reset + /// and re-feed the whole window (it parses MBs in milliseconds), then shift + /// the content offset by the growth so the previously-topmost content stays + /// visible. + private func rebuildPreservingViewport() { + guard let view = terminalView else { return } + let oldHeight = view.contentSize.height + let oldOffsetY = view.contentOffset.y + view.holdScrollOnOutput = false + view.getTerminal().resetToInitialState() + if !transcript.isEmpty { + view.feed(byteArray: [UInt8](transcript)[...]) + } + DispatchQueue.main.async { [weak self] in + guard let self, let view = self.terminalView else { return } + let grown = max(0, view.contentSize.height - oldHeight) + view.holdScrollOnOutput = true + self.isPinnedToBottom = false + view.contentOffset = CGPoint(x: 0, y: max(0, oldOffsetY + grown)) + } + } + + // MARK: - Input + + var canSendInput: Bool { + guard let syncService else { return false } + return (syncService.connectionState == .connected || syncService.connectionState == .syncing) && !hasExited + } + + func enqueueInput(_ text: String) { + guard !text.isEmpty else { return } + guard canSendInput else { + if !hasExited { + blockedInputPulse += 1 + } + return + } + pendingInput += text + scheduleInputFlush() + if !isPinnedToBottom { + scrollToLive() + } + } + + func sendKeySequence(_ data: String) { + ADEHaptics.light() + enqueueInput(data) + } + + func toggleCtrl() { + guard let view = terminalView else { return } + view.controlModifier.toggle() + ctrlArmed = view.controlModifier + ADEHaptics.medium() + if ctrlArmed { + _ = view.becomeFirstResponder() + } + } + + var pasteboardHasStrings: Bool { + UIPasteboard.general.hasStrings + } + + func pasteFromClipboard() { + guard canSendInput else { + blockedInputPulse += 1 + return + } + ADEHaptics.light() + // Routes through SwiftTerm, which wraps in ESC[200~ … ESC[201~ whenever + // the host application enabled bracketed paste. + terminalView?.paste(nil) + } + + private func scheduleInputFlush() { + guard inputFlushTask == nil else { return } + inputFlushTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 16_000_000) + guard let self, !Task.isCancelled else { return } + self.inputFlushTask = nil + let buffered = self.pendingInput + self.pendingInput = "" + guard !buffered.isEmpty else { return } + self.syncService?.sendTerminalInput(sessionId: self.sessionId, data: buffered) + } + } + + private func sendResizeIfNeeded(cols: Int, rows: Int) { + guard cols > 1, rows > 1 else { return } + if let lastSentSize, lastSentSize.cols == cols, lastSentSize.rows == rows { + return + } + lastSentSize = (cols, rows) + syncService?.sendTerminalResize(sessionId: sessionId, cols: cols, rows: rows) + } + + // MARK: - Scroll pinning + + func scrollToLive() { + liveChunksWhileScrolledUp = 0 + isPinnedToBottom = true + terminalView?.resumeAutoScroll() + } + + private func updatePinState() { + guard let view = terminalView else { return } + let nearBottom = view.contentOffset.y + view.bounds.height >= view.contentSize.height - 32 + if nearBottom { + if !isPinnedToBottom { + isPinnedToBottom = true + liveChunksWhileScrolledUp = 0 + } + view.holdScrollOnOutput = false + } else { + if isPinnedToBottom { + isPinnedToBottom = false + } + view.holdScrollOnOutput = true + } + } + + private func settleScroll() { + updatePinState() + if isPinnedToBottom { + terminalView?.resumeAutoScroll() + } + } + + // MARK: - Pinch zoom + + @objc private func handlePinch(_ gesture: UIPinchGestureRecognizer) { + switch gesture.state { + case .began: + pinchBaseFontSize = fontSize + case .changed: + let next = min(max(pinchBaseFontSize * gesture.scale, Self.minFontSize), Self.maxFontSize) + if abs(next - fontSize) >= 0.5 { + fontSize = next + // Font change recomputes cell metrics + cols/rows inside SwiftTerm, + // which fires sizeChanged → sendTerminalResize. + terminalView?.font = UIFont.monospacedSystemFont(ofSize: next, weight: .regular) + } + default: + break + } + } + + fileprivate func handleBell() { + UIImpactFeedbackGenerator(style: .rigid).impactOccurred() + bellPulse += 1 + } +} + +// MARK: - TerminalViewDelegate + +extension TerminalSessionController: TerminalViewDelegate { + nonisolated func send(source: TerminalView, data: ArraySlice) { + let text = String(decoding: data, as: UTF8.self) + MainActor.assumeIsolated { + enqueueInput(text) + } + } + + nonisolated func sizeChanged(source: TerminalView, newCols: Int, newRows: Int) { + MainActor.assumeIsolated { + sendResizeIfNeeded(cols: newCols, rows: newRows) + } + } + + nonisolated func requestOpenLink(source: TerminalView, link: String, params: [String: String]) { + guard let url = URL(string: link), url.scheme?.lowercased().hasPrefix("http") == true else { return } + MainActor.assumeIsolated { + UIApplication.shared.open(url) + } + } + + nonisolated func bell(source: TerminalView) { + MainActor.assumeIsolated { + handleBell() + } + } + + nonisolated func clipboardCopy(source: TerminalView, content: Data) { + let text = String(decoding: content, as: UTF8.self) + MainActor.assumeIsolated { + UIPasteboard.general.string = text + } + } + + nonisolated func setTerminalTitle(source: TerminalView, title: String) {} + nonisolated func hostCurrentDirectoryUpdate(source: TerminalView, directory: String?) {} + nonisolated func scrolled(source: TerminalView, position: Double) {} + nonisolated func iTermContent(source: TerminalView, content: ArraySlice) {} + nonisolated func rangeChanged(source: TerminalView, startY: Int, endY: Int) {} +} + +// MARK: - UIScrollViewDelegate (TerminalView is a UIScrollView) + +extension TerminalSessionController: UIScrollViewDelegate { + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard scrollView === terminalView else { return } + // Ignore programmatic offset changes (SwiftTerm's own bottom snaps). + guard scrollView.isTracking || scrollView.isDecelerating else { return } + updatePinState() + if scrollView.contentOffset.y < 160 { + loadOlderHistoryIfNeeded() + } + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + guard scrollView === terminalView, !decelerate else { return } + settleScroll() + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + guard scrollView === terminalView else { return } + settleScroll() + } +} + +// MARK: - SwiftUI wrapper + +struct SwiftTermSessionView: UIViewRepresentable { + @ObservedObject var controller: TerminalSessionController + + func makeUIView(context: Context) -> ADESwiftTermView { + controller.makeTerminalView() + } + + func updateUIView(_ uiView: ADESwiftTermView, context: Context) {} +} diff --git a/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift b/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift new file mode 100644 index 000000000..0ccbf0756 --- /dev/null +++ b/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift @@ -0,0 +1,375 @@ +import SwiftUI +import UIKit + +/// Full-bleed native terminal for a CLI session: SwiftTerm emulation, offset +/// streamed PTY output, tap-to-focus keyboard input, on-demand scrollback +/// paging, and phone-driven PTY resize. +struct TerminalSessionScreen: View { + @EnvironmentObject var syncService: SyncService + @Environment(\.dismiss) private var dismiss + + let session: TerminalSessionSummary + + @StateObject private var controller = TerminalSessionController() + @State private var keyboardVisible = false + @State private var pasteboardHasStrings = false + @State private var showReconnectingCaption = false + @State private var statusDotFlash = false + + private var bottomIgnoredEdges: Edge.Set { + keyboardVisible ? [] : [.bottom] + } + + private let keyboardShowPublisher: NotificationCenter.Publisher = + NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification) + private let keyboardHidePublisher: NotificationCenter.Publisher = + NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification) + private let pasteboardPublisher: NotificationCenter.Publisher = + NotificationCenter.default.publisher(for: UIPasteboard.changedNotification) + + var body: some View { + lifecycleDecorated + .onReceive(keyboardShowPublisher) { _ in handleKeyboardWillShow() } + .onReceive(keyboardHidePublisher) { _ in keyboardVisible = false } + .onReceive(pasteboardPublisher) { _ in refreshPasteboardState() } + } + + private var lifecycleDecorated: some View { + chromeDecorated + .task(id: session.id) { handleAppear() } + .onDisappear { controller.deactivate() } + .onChange(of: syncService.connectionState) { (_, state: RemoteConnectionState) in + handleConnectionState(state) + } + .onChange(of: controller.bellPulse) { (_, _: Int) in flashStatusDot() } + .onChange(of: controller.blockedInputPulse) { (_, _: Int) in showReconnectingCaption = true } + .task(id: showReconnectingCaption) { await hideReconnectingCaptionSoon() } + } + + private var chromeDecorated: some View { + terminalStack + .background(Color.black.ignoresSafeArea()) + .safeAreaInset(edge: .bottom, spacing: 0) { + if keyboardVisible { + keyBarStack + } + } + .ignoresSafeArea(.container, edges: bottomIgnoredEdges) + .navigationTitle("") + .toolbar(.hidden, for: .navigationBar) + .toolbar(.hidden, for: .tabBar) + .adeRootTabBarHidden() + } + + private var terminalStack: some View { + VStack(spacing: 0) { + topBar + SwiftTermSessionView(controller: controller) + .overlay(alignment: .top) { + if controller.isLoadingHistory { + TerminalHistoryShimmer() + } + } + .overlay(alignment: .bottomTrailing) { + if !controller.isPinnedToBottom { + livePill + .padding(.trailing, 14) + .padding(.bottom, 14) + } + } + } + } + + private func handleAppear() { + controller.activate(syncService: syncService, sessionId: session.id, sessionStatus: session.status) + pasteboardHasStrings = controller.pasteboardHasStrings + } + + private func handleConnectionState(_ state: RemoteConnectionState) { + controller.handleConnectionChange(isConnected: state == .connected || state == .syncing) + } + + private func handleKeyboardWillShow() { + keyboardVisible = true + pasteboardHasStrings = controller.pasteboardHasStrings + } + + private func refreshPasteboardState() { + pasteboardHasStrings = controller.pasteboardHasStrings + } + + private func hideReconnectingCaptionSoon() async { + guard showReconnectingCaption else { return } + try? await Task.sleep(nanoseconds: 1_600_000_000) + guard !Task.isCancelled else { return } + showReconnectingCaption = false + } + + private func flashStatusDot() { + statusDotFlash = true + Task { @MainActor in + try? await Task.sleep(nanoseconds: 140_000_000) + withAnimation(.easeOut(duration: 0.4)) { + statusDotFlash = false + } + } + } + + // MARK: - Top bar + + private var topBar: some View { + HStack(spacing: 10) { + Button { + dismiss() + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(ADEColor.textPrimary) + .frame(width: 36, height: 36) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Back") + + Circle() + .fill(statusColor) + .frame(width: 7, height: 7) + .scaleEffect(statusDotFlash ? 1.6 : 1) + + Text(titleText) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background { + ADEColor.pageBackground + .opacity(0.98) + .ignoresSafeArea(edges: .top) + } + } + + private var titleText: String { + "\(runtimeLabel) · \(session.laneName)" + } + + private var runtimeLabel: String { + let raw = session.toolType?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + return raw.isEmpty ? "shell" : raw + } + + private var statusColor: Color { + if controller.hasExited { + return ADEColor.textMuted + } + switch syncService.connectionState { + case .connected, .syncing: + return controller.isSubscribed ? ADEColor.success : ADEColor.warning + case .connecting: + return ADEColor.warning + case .disconnected, .error: + return ADEColor.textMuted + } + } + + // MARK: - Live pill + + private var livePill: some View { + Button { + controller.scrollToLive() + } label: { + HStack(spacing: 5) { + Image(systemName: "arrow.down") + .font(.system(size: 10, weight: .bold)) + Text(controller.liveChunksWhileScrolledUp > 0 ? "Live \(controller.liveChunksWhileScrolledUp)" : "Live") + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + } + .foregroundStyle(ADEColor.textPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(.ultraThinMaterial, in: Capsule()) + .overlay(Capsule().stroke(ADEColor.glassBorder, lineWidth: 0.6)) + } + .buttonStyle(.plain) + } + + // MARK: - Key bar + + private var keyBarStack: some View { + VStack(spacing: 6) { + if showReconnectingCaption || pasteboardHasStrings { + HStack { + if showReconnectingCaption { + Text("reconnecting…") + .font(.caption2.weight(.medium)) + .foregroundStyle(ADEColor.warning) + } + Spacer(minLength: 0) + if pasteboardHasStrings { + pasteChip + } + } + .padding(.horizontal, 12) + } + keyBar + .modifier(ADEShakeEffect(animatableData: CGFloat(controller.blockedInputPulse))) + .animation(.linear(duration: 0.3), value: controller.blockedInputPulse) + } + .padding(.top, 6) + .padding(.bottom, 6) + .background(ADEColor.recessedBackground.opacity(0.92)) + .overlay(alignment: .top) { + Rectangle().fill(ADEColor.glassBorder).frame(height: 0.5) + } + } + + private var pasteChip: some View { + Button { + controller.pasteFromClipboard() + } label: { + HStack(spacing: 5) { + Image(systemName: "doc.on.clipboard") + .font(.system(size: 10, weight: .semibold)) + Text("Paste") + .font(.system(size: 12, weight: .semibold)) + } + .foregroundStyle(ADEColor.accent) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(ADEColor.accent.opacity(0.12), in: Capsule()) + } + .buttonStyle(.plain) + } + + private var keyBar: some View { + HStack(spacing: 5) { + keyButton("esc", sends: "\u{1B}") + keyButton("⇥", sends: "\t") + ctrlButton + keyButton("↑", sends: "\u{1B}[A") + keyButton("↓", sends: "\u{1B}[B") + keyButton("←", sends: "\u{1B}[D") + keyButton("→", sends: "\u{1B}[C") + keyButton("⏎", sends: "\r") + overflowMenu + } + .padding(.horizontal, 8) + .disabled(controller.hasExited) + .opacity(controller.hasExited ? 0.4 : 1) + } + + private func keyButton(_ label: String, sends data: String) -> some View { + Button { + controller.sendKeySequence(data) + } label: { + Text(label) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(ADEColor.textPrimary) + .frame(maxWidth: .infinity, minHeight: 32) + .background(ADEColor.textPrimary.opacity(0.06), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(ADEColor.border.opacity(0.28), lineWidth: 0.6) + ) + } + .buttonStyle(.plain) + } + + private var ctrlButton: some View { + Button { + controller.toggleCtrl() + } label: { + Text("⌃") + .font(.system(size: 14, weight: .bold, design: .monospaced)) + .foregroundStyle(controller.ctrlArmed ? Color.white : ADEColor.textPrimary) + .frame(maxWidth: .infinity, minHeight: 32) + .background( + controller.ctrlArmed ? AnyShapeStyle(ADEColor.accent) : AnyShapeStyle(ADEColor.textPrimary.opacity(0.06)), + in: RoundedRectangle(cornerRadius: 7, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(controller.ctrlArmed ? ADEColor.accent : ADEColor.border.opacity(0.28), lineWidth: 0.6) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(controller.ctrlArmed ? "Control armed" : "Control") + } + + private var overflowMenu: some View { + Menu { + overflowKey("^C", sends: "\u{03}") + overflowKey("^D", sends: "\u{04}") + overflowKey("^Z", sends: "\u{1A}") + overflowKey("^L", sends: "\u{0C}") + overflowKey("^R", sends: "\u{12}") + overflowKey("^U", sends: "\u{15}") + overflowKey("^A", sends: "\u{01}") + overflowKey("^E", sends: "\u{05}") + overflowKey("^K", sends: "\u{0B}") + Divider() + overflowKey("|", sends: "|") + overflowKey("~", sends: "~") + overflowKey("/", sends: "/") + overflowKey("-", sends: "-") + } label: { + Text("⋯") + .font(.system(size: 14, weight: .bold, design: .monospaced)) + .foregroundStyle(ADEColor.textPrimary) + .frame(maxWidth: .infinity, minHeight: 32) + .background(ADEColor.textPrimary.opacity(0.06), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(ADEColor.border.opacity(0.28), lineWidth: 0.6) + ) + } + .buttonStyle(.plain) + } + + private func overflowKey(_ label: String, sends data: String) -> some View { + Button(label) { + controller.sendKeySequence(data) + } + } +} + +/// Horizontal shake for the key bar when typed input has nowhere to go. +private struct ADEShakeEffect: GeometryEffect { + var animatableData: CGFloat + + func effectValue(size: CGSize) -> ProjectionTransform { + ProjectionTransform(CGAffineTransform(translationX: 7 * sin(animatableData * .pi * 3), y: 0)) + } +} + +/// Thin animated bar shown while older scrollback pages in. +private struct TerminalHistoryShimmer: View { + @State private var phase: CGFloat = -1 + + var body: some View { + GeometryReader { proxy in + Rectangle() + .fill( + LinearGradient( + colors: [.clear, ADEColor.accent.opacity(0.85), .clear], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: proxy.size.width * 0.45) + .offset(x: phase * proxy.size.width) + } + .frame(height: 2.5) + .clipped() + .onAppear { + phase = -0.45 + withAnimation(.linear(duration: 0.9).repeatForever(autoreverses: false)) { + phase = 1 + } + } + } +} diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index 938c8cb50..9136d1345 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -242,10 +242,18 @@ struct WorkSessionDestinationView: View { transitionNamespace == nil ? nil : "work-container-\(sessionId)" } + /// Terminal sessions render `TerminalSessionScreen`, which brings its own + /// slim full-bleed top bar — the shared pushed-detail chrome would stack a + /// second header on top of it. + private var isFullScreenTerminalSession: Bool { + guard let current = session ?? initialSession else { return false } + return !isChatSession(current) + } + var body: some View { sessionDestinationRoot .workSessionNavigationChrome( - mode: navigationChrome, + mode: isFullScreenTerminalSession ? .embedded : navigationChrome, title: sessionDestinationNavigationTitle, trailingControls: { sessionHeaderTrailingControls } ) @@ -346,12 +354,8 @@ struct WorkSessionDestinationView: View { lanes: lanes ) } else { - WorkTerminalSessionView( - session: session, - transitionNamespace: transitionNamespace, - onOpenLane: showsLaneActions ? openSessionLane : nil - ) - .environmentObject(syncService) + TerminalSessionScreen(session: session) + .environmentObject(syncService) } } else { ADEEmptyStateView( @@ -456,7 +460,11 @@ struct WorkSessionDestinationView: View { fallbackTranscript = makeWorkChatTranscript(from: response.entries, sessionId: sessionId) } - if forceRemote && !preferLightweight { + // Chat-only fallback: parses chat envelopes out of the raw terminal buffer. + // Terminal sessions own their subscription via TerminalSessionScreen's + // offset stream; a preview-budget subscribe here would race a second + // replace-snapshot into that stream. + if forceRemote && !preferLightweight, let currentSession = session ?? initialSession, isChatSession(currentSession) { try? await syncService.subscribeTerminal(sessionId: sessionId) let raw = syncService.terminalBuffers[sessionId] ?? "" let parsed = parseWorkChatTranscript(raw) diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index e79f2687a..9bee9d84e 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -361,8 +361,9 @@ Implemented envelope types on iOS: | `command_ack` | Runtime → phone | Command receipt | | `command_result` | Runtime → phone | Execution result or error | | `file_request` / `file_response` | Bidirectional | On-demand file access | -| `terminal_subscribe` / `terminal_unsubscribe` / `terminal_data` | Phone ↔ runtime | Terminal streaming; `unsubscribe` is sent when a Work terminal screen disappears so the phone stops accumulating buffer for off-screen sessions | -| `terminal_input` / `terminal_resize` | Phone → runtime | Raw input bytes and viewport size changes for a subscribed live PTY | +| `terminal_subscribe` / `terminal_unsubscribe` / `terminal_data` | Phone ↔ runtime | Terminal streaming; `unsubscribe` is sent when a Work terminal screen disappears so the phone stops accumulating buffer for off-screen sessions. `terminal_data` carries `offset` — the transcript's end byte offset after the chunk (null when the session has no transcript or hit the size cap) — so the phone can detect dropped chunks. `terminal_subscribe` accepts `sinceOffset`; when the runtime can serve exactly `sinceOffset → end` within the byte budget it replies with a `delta: true` snapshot (append, don't replace), giving exact back-fill after reconnects/gaps. Snapshots also report `startOffset`/`endOffset` | +| `terminal_history` | Phone → runtime | On-demand scrollback paging: `{ sessionId, beforeOffset, maxBytes? }` returns transcript bytes `[startOffset, endOffset)` ending at/before `beforeOffset` (page start scanned forward to a newline/ESC boundary; `atStart: true` at beginning of transcript). Requires an active `terminal_subscribe` | +| `terminal_input` / `terminal_resize` | Phone → runtime | Raw input bytes and viewport size changes for a subscribed live PTY. Mobile resizes are non-authoritative: the runtime records the last desktop-originated size and restores it when the last subscribed phone detaches | | `chat_subscribe` / `chat_event` | Phone → runtime / runtime → phone | Agent chat transcript streaming; `chat_subscribe` carries `sinceSeq` so the runtime can replay exactly the missed events from its per-session buffer instead of re-sending a snapshot | | `envelope_chunk` | Runtime → phone | Slice of an oversized encoded envelope (>720 KB); the phone reassembles by `chunkId`/`index` before normal decode | | `heartbeat` | Bidirectional | Connection health (30s) | @@ -746,7 +747,7 @@ duplicate. Project list dedup runs as a final pass |---|---|---|---| | **Lanes** | `square.stack.3d.up` | `/lanes` | Full lane surface: search/filter chips, open/create/attach/manage, multi-attach for unregistered worktrees, stack canvas, git/diff/rebase/conflicts, template-backed environment setup progress, lane-scoped sessions and AI chats. `devicesOpen` presence chips show which other devices currently have the lane open. The lane gear opens `LaneAdvancedScreen`, a single page that groups Manage / Switch branch / Stash and the destructive git escape hatches (rebase lane, rebase descendants, rebase + push, force push) with an inline description per row and an offline disabled banner. The commit sheet (`LaneCommitSheet`) renders staged + unstaged file lists with per-file stage / unstage / discard / restore / open-diff / open-files actions, a "Suggest" AI button gated by runtime capability, and a setup-hint card surfaced when the runtime returns "AI commit messages are off". | | **Files** | `doc.text` | `/files` | Lane-backed workspace picker, live file tree/search/read, protected-workspace read-only parity. `mobileReadOnly` on the workspace payload gates mutating file actions on the phone via `ensureMobileFileMutationsAllowed`; quick-open and text-search result lists cap visible rows at 40 and ask the user to refine when more matches exist. | -| **Work** | `terminal` | `/work` | Terminal + chat session list, cached history with persisted lane names, output streaming, character-by-character terminal input (Termius-style: each typed glyph forwards a single `terminal_input` byte and the field clears so PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, in-app CLI session launcher (Claude / Codex / Cursor / OpenCode / Droid / shell), message-to-continue on ended agent CLI rows, session pinning, live chat-event push from the runtime (no polling lag once subscribed). The new-session screen (`WorkNewChatScreen`) toggles between **ADE chat** and **CLI session** via a segmented picker; in CLI mode a `workCliProviderOptions` row picker exposes each supported provider explicitly. CLI mode submits `work.startCliSession` with the chosen provider, permission mode (Claude additionally supports `auto`), an optional `reasoningEffort`, and an optional opening message. For most providers the runtime types the opening message into the spawned PTY; for Codex the opening message is forwarded as the final argv positional through `buildTrackedCliLaunchCommand`, so the prompt is treated as a real first turn instead of a typed shell line. The terminal viewer (`WorkTerminalEmulatorView`) is a UIKit-backed monospaced screen that drives a `WorkTerminalScreen` model, computes its viewport in (cols, rows) from the rendered glyph cell, forwards each viewport change as `terminal_resize`, and unsubscribes via `terminal_unsubscribe` when the screen disappears. The earlier "activity feed" section was retired — running chats are surfaced through the session list and a Work tab badge bound to `SyncService.runningChatSessionCount`. | +| **Work** | `terminal` | `/work` | Terminal + chat session list, cached history with persisted lane names, output streaming, native key-passthrough terminal input (keystrokes from the iOS keyboard flow straight into the PTY as `terminal_input`, coalesced ~16 ms; PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, in-app CLI session launcher (Claude / Codex / Cursor / OpenCode / Droid / shell), message-to-continue on ended agent CLI rows, session pinning, live chat-event push from the runtime (no polling lag once subscribed). The new-session screen (`WorkNewChatScreen`) toggles between **ADE chat** and **CLI session** via a segmented picker; in CLI mode a `workCliProviderOptions` row picker exposes each supported provider explicitly. CLI mode submits `work.startCliSession` with the chosen provider, permission mode (Claude additionally supports `auto`), an optional `reasoningEffort`, and an optional opening message. For most providers the runtime types the opening message into the spawned PTY; for Codex the opening message is forwarded as the final argv positional through `buildTrackedCliLaunchCommand`, so the prompt is treated as a real first turn instead of a typed shell line. The terminal viewer (`TerminalSessionScreen` + `SwiftTermSessionView`) is a full-bleed SwiftTerm (real VT100/xterm) emulator: tap-to-focus raises the iOS keyboard for direct passthrough, a single-row key bar provides esc/tab/latching-Ctrl/arrows/return plus an overflow menu, pinch adjusts font size, and the phone owns the PTY's cols×rows while the screen is open (sent as `terminal_resize`; the runtime restores the desktop size on detach). Live output streams via offset-stamped `terminal_data` with gap detection + `sinceOffset` delta resume (no snapshot polling); scrolling near the top auto-pages older transcript via `terminal_history`, and a floating "↓ Live N" pill snaps back to the live tail. The screen unsubscribes via `terminal_unsubscribe` on disappear. The legacy `WorkTerminalEmulatorView`/`WorkTerminalScreen` mini-parser remains only for inline preview cards. The earlier "activity feed" section was retired — running chats are surfaced through the session list and a Work tab badge bound to `SyncService.runningChatSessionCount`. | | **PRs** | `arrow.triangle.pull` | `/prs` | PR list/detail driven by `prs.getMobileSnapshot`: stack visibility (`PrStackSheet`), create-PR wizard (`CreatePrWizardView`) gated by per-lane eligibility, workflow cards (queue / integration / rebase) rendered from `PrWorkflowCard`, per-PR action capabilities. | | **CTO** | `brain.head.profile` | `/cto` | CTO snapshot: Chat / Team / Workflows segments, with the mobile workflows screen mirroring the desktop workflow policy/dashboard and preserving the shared glass navigation chrome. Drills into per-worker chat sessions via `CtoSessionDestinationView`. | | **Settings** | `gearshape` | `/settings` (sync subset) | PIN pairing (`SettingsPinSheet`), notification preferences (`NotificationsCenterView`), quiet hours, per-session overrides, appearance, diagnostics, connection header with QR payload and address candidates, reconnect, forget. `ConnectionSettingsView` binds to `SettingsConnectionPresentationModel`, which feeds plain `SettingsConnectionSnapshot` / `SettingsPairingSnapshot` / `SettingsDiagnosticsSnapshot` DTOs into the section views (`SettingsConnectionHeader`, `SettingsPairingSection`, `SettingsDiagnosticsSection`) instead of having them reach into `SyncService` directly. `sendTestPush` is now `async` and returns a `SyncSendTestPushResult` (`ok`, `message`); the Notifications section renders that message verbatim so APNs-not-configured / in-app-only / wire failure cases all surface to the user. | diff --git a/docs/features/terminals-and-sessions/pty-and-processes.md b/docs/features/terminals-and-sessions/pty-and-processes.md index 889fcab16..92cba9b15 100644 --- a/docs/features/terminals-and-sessions/pty-and-processes.md +++ b/docs/features/terminals-and-sessions/pty-and-processes.md @@ -136,7 +136,7 @@ Each live PTY has an entry in the `ptys` map keyed by `ptyId` with: - `pty` (node-pty handle), `laneId`, `laneWorktreePath`, `boundCwd`, `sessionId`, `tracked` - transcript: `transcriptPath`, `transcriptStream`, - `transcriptBytesWritten`, `transcriptLimitReached` (64 MB cap from + `transcriptBytesWritten`, `transcriptLimitReached` (16 MB cap from `MAX_TRANSCRIPT_BYTES`) - preview: `lastPreviewWriteAt`, `previewCurrentLine`, `latestPreviewLine`, `lastPreviewWritten` @@ -259,9 +259,32 @@ OpenCode rendering inside Work tabs. ### Data, preview, and runtime state `writeTranscript(entry, data)` writes to the append-mode write stream. -Once the 64 MB cap is hit it writes a single notice line and drops -further output. Bytes written are not persisted, so the cap resets on -reattach. +Once the 16 MB cap (`MAX_TRANSCRIPT_BYTES`) is hit it writes a single +notice line and drops further output. `transcriptBytesWritten` is +seeded from the file size on (re)attach, so the cap survives resume. + +`transcriptBytesWritten` doubles as the transcript byte cursor for +mobile streaming: each batched PTY data emission carries `offset` — +the transcript's end byte offset after the batch (null once the cap is +reached, the write stream fails, or the session is untracked). The +transcript write and the data-batch enqueue run in the same `onData` +handler, so the cursor at flush time is exact. Note the fs.WriteStream +buffers, so disk can lag the cursor by a few ms; range reads clamp to +the flushed file size and report achieved offsets. + +`readTranscriptRange({ sessionId, startOffset, endOffset })` reads a +byte range from the transcript for scrollback paging (the sync host's +`terminal_history`), scanning the page start forward to a newline/ESC +boundary (and past UTF-8 continuation bytes) so a page never begins +mid-escape-sequence. + +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. `updatePreviewThrottled` uses `derivePreviewFromChunk` to track the last non-empty line, capped at 220 chars. Preview is flushed to From 4edfaecae5a6750f44c6717088aeaec1b6e9f945 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:38:26 -0400 Subject: [PATCH 02/10] iOS terminal: legacy-host polling fallback for pre-offset brains Pre-offset brains never push terminal_data (their PTY->sync bridge only existed in the Electron desktop path), so the new screen froze on its first snapshot against the currently-installed beta brain. Detect the missing offsets on the first snapshot and fall back to a 2s tail refresh - skipped while the reader is scrolled up, deduped against an unchanged tail, and permanently disarmed the moment any offset-stamped snapshot or chunk arrives (i.e. on upgraded brains). Co-Authored-By: Claude Fable 5 --- .../ADE/Views/Work/SwiftTermSessionView.swift | 57 ++++++++++++++++++- .../sync-and-multi-device/ios-companion.md | 2 +- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift b/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift index 8c6073548..3dcc9e8de 100644 --- a/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift +++ b/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift @@ -76,6 +76,15 @@ final class TerminalSessionController: NSObject, ObservableObject { private var pinchBaseFontSize: CGFloat = TerminalSessionController.defaultFontSize private var ctrlResetObserver: NSObjectProtocol? + // Hosts that stamp transcript offsets push live terminal_data; legacy hosts + // (pre-offset brains) never push terminal output at all, so the screen + // falls back to periodic tail refreshes until an offset proves otherwise. + // nil = unknown (no snapshot seen yet). + private var hostSupportsOffsets: Bool? + private var legacyPollTask: Task? + private static let legacyPollIntervalNs: UInt64 = 2_000_000_000 + private static let legacyPollBudgetBytes = 192_000 + deinit { if let ctrlResetObserver { NotificationCenter.default.removeObserver(ctrlResetObserver) @@ -100,6 +109,7 @@ final class TerminalSessionController: NSObject, ObservableObject { do { try await syncService.subscribeTerminalStream(sessionId: sessionId, sinceOffset: resumeOffset) self.isSubscribed = true + self.startLegacyPollingIfNeeded() } catch { self.isSubscribed = false } @@ -110,6 +120,8 @@ final class TerminalSessionController: NSObject, ObservableObject { inputFlushTask?.cancel() inputFlushTask = nil pendingInput = "" + legacyPollTask?.cancel() + legacyPollTask = nil guard let syncService, !sessionId.isEmpty else { return } syncService.detachTerminalStream(sessionId: sessionId) isSubscribed = false @@ -117,6 +129,30 @@ final class TerminalSessionController: NSObject, ObservableObject { Task { try? await syncService.unsubscribeTerminal(sessionId: id) } } + /// Pre-offset hosts never push terminal_data (their PTY→sync bridge only + /// existed in the Electron desktop), so without this loop the screen would + /// freeze on its first snapshot forever. Polls a modest tail and stops the + /// moment any snapshot/chunk proves the host stamps offsets. + private func startLegacyPollingIfNeeded() { + guard hostSupportsOffsets != true, legacyPollTask == nil else { return } + legacyPollTask = Task { @MainActor [weak self] in + defer { self?.legacyPollTask = nil } + while let self, !Task.isCancelled { + try? await Task.sleep(nanoseconds: Self.legacyPollIntervalNs) + if Task.isCancelled { return } + if self.hostSupportsOffsets == true || self.hasExited { return } + guard let syncService = self.syncService, self.isSubscribed, self.readyToFeed else { continue } + // Don't yank the viewport out from under a reader: replace-hydrates + // re-pin to the bottom. + guard self.isPinnedToBottom else { continue } + try? await syncService.refreshTerminalSnapshot( + sessionId: self.sessionId, + maxBytes: Self.legacyPollBudgetBytes + ) + } + } + } + func handleConnectionChange(isConnected: Bool) { if isConnected { // The sync layer re-subscribes with sinceOffset on reconnect; we only @@ -126,6 +162,9 @@ final class TerminalSessionController: NSObject, ObservableObject { if let terminal = terminalView?.getTerminal() { sendResizeIfNeeded(cols: terminal.cols, rows: terminal.rows) } + // The reconnect may have landed on a different (possibly older) host. + hostSupportsOffsets = nil + startLegacyPollingIfNeeded() } else { isSubscribed = false } @@ -197,8 +236,15 @@ final class TerminalSessionController: NSObject, ObservableObject { private func applyStreamEvent(_ event: TerminalStreamEvent) { switch event { case .hydrate(let text, let replacing, let startOffset, let endOffset): + noteHostOffsetCapability(endOffset != nil) if replacing { - transcript = Data(text.utf8) + 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) { + return + } + transcript = bytes transcriptStartOffset = startOffset transcriptEndOffset = endOffset historyAtStart = startOffset == 0 @@ -207,12 +253,21 @@ final class TerminalSessionController: NSObject, ObservableObject { appendBytes(Data(text.utf8), endOffset: endOffset, countsAsLive: false) } case .chunk(let text, let endOffset): + noteHostOffsetCapability(endOffset != nil) appendBytes(Data(text.utf8), endOffset: endOffset, countsAsLive: true) case .exit: hasExited = true } } + private func noteHostOffsetCapability(_ supportsOffsets: Bool) { + if supportsOffsets { + hostSupportsOffsets = true + } else if hostSupportsOffsets == nil { + hostSupportsOffsets = false + } + } + private func appendBytes(_ bytes: Data, endOffset: Int?, countsAsLive: Bool) { if let endOffset { transcriptEndOffset = endOffset diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 9bee9d84e..68d400d37 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -747,7 +747,7 @@ duplicate. Project list dedup runs as a final pass |---|---|---|---| | **Lanes** | `square.stack.3d.up` | `/lanes` | Full lane surface: search/filter chips, open/create/attach/manage, multi-attach for unregistered worktrees, stack canvas, git/diff/rebase/conflicts, template-backed environment setup progress, lane-scoped sessions and AI chats. `devicesOpen` presence chips show which other devices currently have the lane open. The lane gear opens `LaneAdvancedScreen`, a single page that groups Manage / Switch branch / Stash and the destructive git escape hatches (rebase lane, rebase descendants, rebase + push, force push) with an inline description per row and an offline disabled banner. The commit sheet (`LaneCommitSheet`) renders staged + unstaged file lists with per-file stage / unstage / discard / restore / open-diff / open-files actions, a "Suggest" AI button gated by runtime capability, and a setup-hint card surfaced when the runtime returns "AI commit messages are off". | | **Files** | `doc.text` | `/files` | Lane-backed workspace picker, live file tree/search/read, protected-workspace read-only parity. `mobileReadOnly` on the workspace payload gates mutating file actions on the phone via `ensureMobileFileMutationsAllowed`; quick-open and text-search result lists cap visible rows at 40 and ask the user to refine when more matches exist. | -| **Work** | `terminal` | `/work` | Terminal + chat session list, cached history with persisted lane names, output streaming, native key-passthrough terminal input (keystrokes from the iOS keyboard flow straight into the PTY as `terminal_input`, coalesced ~16 ms; PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, in-app CLI session launcher (Claude / Codex / Cursor / OpenCode / Droid / shell), message-to-continue on ended agent CLI rows, session pinning, live chat-event push from the runtime (no polling lag once subscribed). The new-session screen (`WorkNewChatScreen`) toggles between **ADE chat** and **CLI session** via a segmented picker; in CLI mode a `workCliProviderOptions` row picker exposes each supported provider explicitly. CLI mode submits `work.startCliSession` with the chosen provider, permission mode (Claude additionally supports `auto`), an optional `reasoningEffort`, and an optional opening message. For most providers the runtime types the opening message into the spawned PTY; for Codex the opening message is forwarded as the final argv positional through `buildTrackedCliLaunchCommand`, so the prompt is treated as a real first turn instead of a typed shell line. The terminal viewer (`TerminalSessionScreen` + `SwiftTermSessionView`) is a full-bleed SwiftTerm (real VT100/xterm) emulator: tap-to-focus raises the iOS keyboard for direct passthrough, a single-row key bar provides esc/tab/latching-Ctrl/arrows/return plus an overflow menu, pinch adjusts font size, and the phone owns the PTY's cols×rows while the screen is open (sent as `terminal_resize`; the runtime restores the desktop size on detach). Live output streams via offset-stamped `terminal_data` with gap detection + `sinceOffset` delta resume (no snapshot polling); scrolling near the top auto-pages older transcript via `terminal_history`, and a floating "↓ Live N" pill snaps back to the live tail. The screen unsubscribes via `terminal_unsubscribe` on disappear. The legacy `WorkTerminalEmulatorView`/`WorkTerminalScreen` mini-parser remains only for inline preview cards. The earlier "activity feed" section was retired — running chats are surfaced through the session list and a Work tab badge bound to `SyncService.runningChatSessionCount`. | +| **Work** | `terminal` | `/work` | Terminal + chat session list, cached history with persisted lane names, output streaming, native key-passthrough terminal input (keystrokes from the iOS keyboard flow straight into the PTY as `terminal_input`, coalesced ~16 ms; PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, in-app CLI session launcher (Claude / Codex / Cursor / OpenCode / Droid / shell), message-to-continue on ended agent CLI rows, session pinning, live chat-event push from the runtime (no polling lag once subscribed). The new-session screen (`WorkNewChatScreen`) toggles between **ADE chat** and **CLI session** via a segmented picker; in CLI mode a `workCliProviderOptions` row picker exposes each supported provider explicitly. CLI mode submits `work.startCliSession` with the chosen provider, permission mode (Claude additionally supports `auto`), an optional `reasoningEffort`, and an optional opening message. For most providers the runtime types the opening message into the spawned PTY; for Codex the opening message is forwarded as the final argv positional through `buildTrackedCliLaunchCommand`, so the prompt is treated as a real first turn instead of a typed shell line. The terminal viewer (`TerminalSessionScreen` + `SwiftTermSessionView`) is a full-bleed SwiftTerm (real VT100/xterm) emulator: tap-to-focus raises the iOS keyboard for direct passthrough, a single-row key bar provides esc/tab/latching-Ctrl/arrows/return plus an overflow menu, pinch adjusts font size, and the phone owns the PTY's cols×rows while the screen is open (sent as `terminal_resize`; the runtime restores the desktop size on detach). Live output streams via offset-stamped `terminal_data` with gap detection + `sinceOffset` delta resume (no snapshot polling); scrolling near the top auto-pages older transcript via `terminal_history`, and a floating "↓ Live N" pill snaps back to the live tail. Against pre-offset hosts (older brains, whose PTY→sync bridge never pushed terminal output) the screen detects the missing offsets and falls back to a 2s tail-refresh poll until offsets appear. The screen unsubscribes via `terminal_unsubscribe` on disappear. The legacy `WorkTerminalEmulatorView`/`WorkTerminalScreen` mini-parser remains only for inline preview cards. The earlier "activity feed" section was retired — running chats are surfaced through the session list and a Work tab badge bound to `SyncService.runningChatSessionCount`. | | **PRs** | `arrow.triangle.pull` | `/prs` | PR list/detail driven by `prs.getMobileSnapshot`: stack visibility (`PrStackSheet`), create-PR wizard (`CreatePrWizardView`) gated by per-lane eligibility, workflow cards (queue / integration / rebase) rendered from `PrWorkflowCard`, per-PR action capabilities. | | **CTO** | `brain.head.profile` | `/cto` | CTO snapshot: Chat / Team / Workflows segments, with the mobile workflows screen mirroring the desktop workflow policy/dashboard and preserving the shared glass navigation chrome. Drills into per-worker chat sessions via `CtoSessionDestinationView`. | | **Settings** | `gearshape` | `/settings` (sync subset) | PIN pairing (`SettingsPinSheet`), notification preferences (`NotificationsCenterView`), quiet hours, per-session overrides, appearance, diagnostics, connection header with QR payload and address candidates, reconnect, forget. `ConnectionSettingsView` binds to `SettingsConnectionPresentationModel`, which feeds plain `SettingsConnectionSnapshot` / `SettingsPairingSnapshot` / `SettingsDiagnosticsSnapshot` DTOs into the section views (`SettingsConnectionHeader`, `SettingsPairingSection`, `SettingsDiagnosticsSection`) instead of having them reach into `SyncService` directly. `sendTestPush` is now `async` and returns a `SyncSendTestPushResult` (`ok`, `message`); the Notifications section renders that message verbatim so APNs-not-configured / in-app-only / wire failure cases all surface to the user. | From 685b56754c9ecf67fbee3bce30e89efb35eff7ae Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 10 Jun 2026 17:48:53 -0400 Subject: [PATCH 03/10] iOS terminal: translate pans to wheel events for mouse-mode TUIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code (and htop-style TUIs) scroll by consuming wheel events — they enable ?1000/1002/1003/1006 mouse reporting. SwiftTerm's iOS pan handler only synthesizes press/drag/release, so panning a mouse-mode session did nothing, and its pan gesture also blocked the scroll view. While mouse reporting is active, a dedicated pan recognizer now sends SGR wheel ticks (button 4/5, ~half-cell stride, capped per change) at the touch position, and SwiftTerm's drag pan + the scroll view's pan are suppressed. Mouse-off sessions keep native scrollback scrolling. Co-Authored-By: Claude Fable 5 --- .../ADE/Views/Work/SwiftTermSessionView.swift | 66 +++++++++++++++++++ .../sync-and-multi-device/ios-companion.md | 2 +- 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift b/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift index 3dcc9e8de..9ff34693c 100644 --- a/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift +++ b/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift @@ -10,6 +10,71 @@ final class ADESwiftTermView: TerminalView { var holdScrollOnOutput = false var onLayout: (() -> Void)? + // Apps that enable mouse reporting (Claude Code, htop, …) scroll by + // consuming wheel events, but SwiftTerm's iOS pan handler only synthesizes + // press/drag/release — so panning a mouse-mode TUI did nothing. Translate + // vertical pans into wheel events ourselves while mouse reporting is on, + // and keep SwiftTerm's drag-event pan out of the way (taps still report). + private let wheelPanGesture = UIPanGestureRecognizer() + private var wheelPanResidual: CGFloat = 0 + /// Points of pan travel per synthesized wheel tick. Roughly half a cell: + /// fine enough to feel direct, coarse enough not to flood the PTY. + private var wheelTickStride: CGFloat { + let rows = max(1, getTerminal().rows) + return max(8, bounds.height / CGFloat(rows) / 2) + } + + private var mouseReportingActive: Bool { + getTerminal().mouseMode != .off + } + + func installWheelPanGesture() { + guard wheelPanGesture.view == nil else { return } + wheelPanGesture.addTarget(self, action: #selector(handleWheelPan(_:))) + wheelPanGesture.maximumNumberOfTouches = 1 + addGestureRecognizer(wheelPanGesture) + } + + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if gestureRecognizer === wheelPanGesture { + return mouseReportingActive + } + // While mouse reporting is on, suppress both SwiftTerm's drag-event pan + // and the scroll view's own pan (a mouse-mode TUI has no scrollback to + // move; rubber-banding under the wheel stream just adds noise). + if mouseReportingActive, gestureRecognizer is UIPanGestureRecognizer { + return false + } + return super.gestureRecognizerShouldBegin(gestureRecognizer) + } + + @objc private func handleWheelPan(_ gesture: UIPanGestureRecognizer) { + let terminal = getTerminal() + guard terminal.mouseMode != .off else { return } + switch gesture.state { + case .began: + wheelPanResidual = 0 + case .changed: + let translation = gesture.translation(in: self).y + wheelPanResidual + let stride = wheelTickStride + let ticks = Int(translation / stride) + guard ticks != 0 else { return } + wheelPanResidual = translation - CGFloat(ticks) * stride + gesture.setTranslation(.zero, in: self) + // Finger moving down reveals earlier content → wheel up (button 4). + let button = ticks > 0 ? 4 : 5 + let flags = terminal.encodeButton(button: button, release: false, shift: false, meta: false, control: false) + let location = gesture.location(in: self) + let col = max(0, min(terminal.cols - 1, Int(location.x / max(1, bounds.width / CGFloat(max(1, terminal.cols)))))) + let row = max(0, min(terminal.rows - 1, Int(location.y / max(1, bounds.height / CGFloat(max(1, terminal.rows)))))) + for _ in 0.. Date: Wed, 10 Jun 2026 19:15:41 -0400 Subject: [PATCH 04/10] Brain: fail startup on cross-channel sync conflict instead of waiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real channel builds never run without mobile sync. A conflict with the SAME channel stays retryable (update races, restart overlap, stale sibling takeover) — but a live brain from ANOTHER channel owning sync means a human deliberately launched a second build, and waiting just produced a silently sync-less brain (phones frozen on old code with the real cause buried in stderr). The startup loop now rethrows cross-channel conflicts and serve exits nonzero with the quit command in the message. Sync-less brains remain available explicitly via serve --no-sync (lane runtimes). Co-Authored-By: Claude Fable 5 --- apps/ade-cli/src/cli.ts | 17 +++++++++--- .../services/sync/syncHostStartupLoop.test.ts | 26 ++++++++++++------- .../src/services/sync/syncHostStartupLoop.ts | 16 +++++++++--- 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index e45554170..9f5d889e9 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -13526,10 +13526,19 @@ async function runServe( log: (message) => process.stderr.write(`${message}\n`), getServiceMainPid: getRuntimeServiceMainPid, }); - })().catch((error: unknown) => { - process.stderr.write( - `ADE brain sync host startup loop failed: ${error instanceof Error ? error.message : String(error)}\n`, - ); + })().catch(async (error: unknown) => { + // Cross-channel conflict (another build's live brain owns mobile sync): + // real builds never run sync-less, so fail the brain instead of coming + // up half-alive. The message carries the exact quit command. + const { SyncHostSingletonConflictError } = await import("./services/sync/syncHostSingleton"); + const message = error instanceof Error ? error.message : String(error); + if (error instanceof SyncHostSingletonConflictError) { + process.stderr.write(`ADE brain refusing to run without mobile sync.\n${message}\n`); + process.exitCode = 1; + finish(); + return; + } + process.stderr.write(`ADE brain sync host startup loop failed: ${message}\n`); }); } diff --git a/apps/ade-cli/src/services/sync/syncHostStartupLoop.test.ts b/apps/ade-cli/src/services/sync/syncHostStartupLoop.test.ts index e15c58a34..c2af3ce2d 100644 --- a/apps/ade-cli/src/services/sync/syncHostStartupLoop.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostStartupLoop.test.ts @@ -118,15 +118,22 @@ describe("runSyncHostStartupLoop", () => { expect(killed).toEqual([]); }); - it("never takes over from another channel's owner", async () => { + it("rethrows a cross-channel conflict immediately instead of retrying", async () => { + // Another build's live brain owning sync is a deliberate human state, not + // a transient race — the brain must fail startup, never run sync-less. const killed: number[] = []; - await runSyncHostStartupLoop({ - startSyncHost: () => Promise.reject(conflictError(makeOwner({ - packageChannel: null, - adeHome: "/Users/example/.ade", - serviceName: "com.ade.runtime", - appName: "ADE", - }))), + let attempts = 0; + const error = conflictError(makeOwner({ + packageChannel: null, + adeHome: "/Users/example/.ade", + serviceName: "com.ade.runtime", + appName: "ADE", + })); + await expect(runSyncHostStartupLoop({ + startSyncHost: () => { + attempts += 1; + return Promise.reject(error); + }, isDone: () => false, log: () => {}, getServiceMainPid: () => process.pid, @@ -136,7 +143,8 @@ describe("runSyncHostStartupLoop", () => { sleep: instantSleep, env: betaEnv, maxAttempts: 3, - }); + })).rejects.toBe(error); + expect(attempts).toBe(1); expect(killed).toEqual([]); }); diff --git a/apps/ade-cli/src/services/sync/syncHostStartupLoop.ts b/apps/ade-cli/src/services/sync/syncHostStartupLoop.ts index 4b21ab434..11a40a6b2 100644 --- a/apps/ade-cli/src/services/sync/syncHostStartupLoop.ts +++ b/apps/ade-cli/src/services/sync/syncHostStartupLoop.ts @@ -65,9 +65,16 @@ async function terminatePidAsync( } // Keeps retrying mobile sync host startup until it succeeds or the brain -// shuts down. A brain that cannot host sync is silently useless to phones, -// so a one-shot startup failure must never be terminal: squatters die, -// upgrades finish, and the next attempt should win. +// shuts down. Same-channel conflicts are transient by nature (update races, +// restart overlap, a stale sibling about to be evicted), so they retry: +// squatters die, upgrades finish, and the next attempt should win. +// +// A conflict with ANOTHER channel's live brain is different — it means a +// human deliberately launched a second build. Waiting would leave this brain +// running without mobile sync, which reads as "phone mysteriously frozen / +// talking to old code" rather than an error. Real builds never run sync-less: +// the conflict is rethrown so the caller can fail brain startup with the +// quit instructions in the message. export async function runSyncHostStartupLoop(deps: SyncHostStartupLoopDeps): Promise { const kill = deps.kill ?? defaultKill; const pidAlive = deps.pidAlive ?? defaultPidAlive; @@ -91,6 +98,9 @@ export async function runSyncHostStartupLoop(deps: SyncHostStartupLoopDeps): Pro } if (error instanceof SyncHostSingletonConflictError) { const owner = error.conflict.owner; + if (owner.pid !== process.pid && !isSameChannelSyncHostOwner(owner, deps.env)) { + throw error; + } const serviceMainPid = deps.getServiceMainPid?.() ?? null; if ( owner.pid !== process.pid From 87da895d04059071bbee8e43dbec682fea3b1943 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:19:11 -0400 Subject: [PATCH 05/10] iOS terminal audit: dead-session resume UX + typing latency cuts Audit follow-ups from real-device testing: Dead sessions (brain restart orphans): terminal_snapshot now carries live:false when no PTY backs the session, the phone surfaces a 'Session ended + Resume' bar (ports the desktop resume affordance) instead of silently accepting keystrokes, and a new work.resumeCliSession sync command relaunches the runtime via ptyService.resumeSession (same sessionId + provider resume metadata). The screen resubscribes at its byte watermark after resume. Typing latency: host PTY data batching drops from 50ms to 8ms for ~1s after any user write (echo rides the next flush; benefits desktop remote too), and the phone flushes the first keystroke of a burst immediately instead of waiting out the 16ms coalescing window. Also: skip the detach-unsubscribe when a remounted screen already re-attached the stream (pop-then-repush race would sever the new screen and snap the PTY back to desktop size). Co-Authored-By: Claude Fable 5 --- .../src/services/sync/syncHostService.test.ts | 42 +++++++++++- .../src/services/sync/syncHostService.ts | 2 + .../services/sync/syncRemoteCommandService.ts | 18 +++++ .../src/main/services/pty/ptyService.ts | 19 +++++- .../services/sync/syncHostService.test.ts | 1 + apps/desktop/src/shared/types/sync.ts | 7 ++ apps/ios/ADE/Models/RemoteModels.swift | 4 ++ apps/ios/ADE/Services/SyncService.swift | 22 ++++++ .../ADE/Views/Work/SwiftTermSessionView.swift | 57 ++++++++++++++-- .../Views/Work/TerminalSessionScreen.swift | 67 ++++++++++++++++++- .../sync-and-multi-device/ios-companion.md | 2 +- 11 files changed, 231 insertions(+), 10 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index 2081a1e18..d241f0390 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -422,6 +422,7 @@ function createHostArgs(projectRoot: string, projects: SyncMobileProjectSummary[ ptyService: { create: vi.fn(), readTranscriptTail: vi.fn(async () => ""), + hasLivePty: () => true, enrichSessions: (rows: unknown[]) => rows, }, computerUseArtifactBrokerService: { @@ -950,6 +951,7 @@ describe("terminal byte-offset streaming, history paging, and resize ownership", })); const resizeBySessionId = vi.fn().mockReturnValue(true); const restoreDesktopSizeBySessionId = vi.fn().mockReturnValue(true); + const hasLivePty = vi.fn().mockReturnValue(true); const base = createHostArgs(projectRoot, []); const host = createSyncHostService({ ...base, @@ -979,10 +981,11 @@ describe("terminal byte-offset streaming, history paging, and resize ownership", writeBySessionId: vi.fn().mockReturnValue(true), resizeBySessionId, restoreDesktopSizeBySessionId, + hasLivePty, enrichSessions: (rows: unknown[]) => rows, }, } as unknown as Parameters[0]); - return { host, readTranscriptTail, readTranscriptRange, resizeBySessionId, restoreDesktopSizeBySessionId }; + return { host, readTranscriptTail, readTranscriptRange, resizeBySessionId, restoreDesktopSizeBySessionId, hasLivePty }; } async function connectTerminalPeer(port: number, token: string, deviceId: string) { @@ -1246,4 +1249,41 @@ describe("terminal byte-offset streaming, history paging, and resize ownership", cleanup(); } }); + + it("marks terminal snapshots live:false when no PTY backs the session", async () => { + // A brain restart orphans "running" sessions; the phone needs the truth + // up front so it shows the resume bar instead of accepting keystrokes. + const { projectRoot, cleanup } = createTempProjectRoot(); + const { host, hasLivePty } = createTerminalHost(projectRoot); + hasLivePty.mockReturnValue(false); + let client: Awaited> | null = null; + try { + const port = await host.waitUntilListening(); + client = await connectTerminalPeer(port, host.getBootstrapToken(), "ios-terminal-live"); + client.ws.send(encodeSyncEnvelope({ + type: "terminal_subscribe", + requestId: "sub-dead", + payload: { sessionId: "session-1", maxBytes: 32_000 }, + })); + const snapshot = await nextResponse(client.envelopes, "terminal_snapshot", "sub-dead"); + expect((snapshot.payload as { live?: boolean }).live).toBe(false); + + hasLivePty.mockReturnValue(true); + client.ws.send(encodeSyncEnvelope({ + type: "terminal_subscribe", + requestId: "sub-live", + payload: { sessionId: "session-1", maxBytes: 32_000 }, + })); + const liveSnapshot = await nextResponse(client.envelopes, "terminal_snapshot", "sub-live"); + expect((liveSnapshot.payload as { live?: boolean }).live).toBe(true); + } finally { + try { + client?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); }); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index b15be9715..963c37667 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -3528,6 +3528,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { startOffset: range.startOffset, endOffset: range.endOffset, delta: true, + live: args.ptyService.hasLivePty(sessionId), } satisfies SyncTerminalSnapshotPayload, envelope.requestId); break; } @@ -3554,6 +3555,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { ? Math.max(0, transcriptSize - Buffer.byteLength(transcript, "utf8")) : null, endOffset: transcriptSize, + live: args.ptyService.hasLivePty(sessionId), }; sendRequired(peer, "terminal_snapshot", snapshot, envelope.requestId); break; diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 6dad9e8bf..58331635e 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -2273,6 +2273,24 @@ function registerWorkRemoteCommands({ args, register }: RemoteCommandRegistratio session: enriched, } satisfies SyncStartCliSessionResult; }); + register("work.resumeCliSession", { viewerAllowed: true, queueable: true }, async (payload) => { + // Mirror of the desktop resume affordance: relaunch an ended/orphaned + // agent CLI session's runtime (same sessionId, provider resume metadata). + const value = (payload ?? {}) as Record; + const sessionId = requireString(value.sessionId, "work.resumeCliSession requires sessionId."); + const cols = typeof value.cols === "number" ? clampCliDimension(Math.floor(value.cols), DEFAULT_CLI_COLS, 20, MAX_CLI_COLS) : undefined; + const rows = typeof value.rows === "number" ? clampCliDimension(Math.floor(value.rows), DEFAULT_CLI_ROWS, 4, MAX_CLI_ROWS) : undefined; + const result = await args.ptyService.resumeSession({ + sessionId, + ...(cols != null ? { cols } : {}), + ...(rows != null ? { rows } : {}), + }); + return { + sessionId: result.sessionId, + ptyId: result.ptyId, + session: result.session, + } satisfies SyncStartCliSessionResult; + }); register("work.sendToSession", { viewerAllowed: true, queueable: true }, async (payload) => { const parsed = parseSendToSessionArgs(payload); const result = await args.ptyService.sendToSession({ diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index c5be76720..2d2c9343d 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -114,6 +114,11 @@ const CLAUDE_TITLE_SCAN_BYTES = 512 * 1024; const CLAUDE_STORAGE_MATCH_START_SKEW_MS = 1_000; const CLAUDE_STORAGE_MATCH_END_SKEW_MS = 5_000; const PTY_DATA_BATCH_INTERVAL_MS = 50; +// Echo latency is dominated by the data batch window. After a user keystroke +// the very next flush races the user's perception, so batch on a much shorter +// window for a brief period following any write to the PTY. +const PTY_DATA_INTERACTIVE_BATCH_INTERVAL_MS = 8; +const PTY_DATA_INTERACTIVE_WINDOW_MS = 1_000; const PTY_DATA_BATCH_MAX_CHARS = 64 * 1024; const PTY_DATA_SUMMARY_INTERVAL_MS = 10_000; const PTY_LIVE_SESSION_RESYNC_INTERVAL_MS = 1_000; @@ -612,6 +617,8 @@ type PtyEntry = { pendingDataChunks: string[]; pendingDataChars: number; pendingDataTimer: ReturnType | null; + /** Epoch ms of the last user write; shortens the data batch window. */ + lastUserInputAt: number; terminalSnapshot: TerminalSnapshotMirror | null; recentOutputTail: string; /** Output-snippet title timer (skipped for interactive Claude/Codex; see CLI user-title path). */ @@ -2936,9 +2943,10 @@ export function createPtyService({ return; } if (entry.pendingDataTimer) return; + const interactive = Date.now() - entry.lastUserInputAt < PTY_DATA_INTERACTIVE_WINDOW_MS; entry.pendingDataTimer = setTimeout(() => { flushQueuedPtyData(entry, ids); - }, PTY_DATA_BATCH_INTERVAL_MS); + }, interactive ? PTY_DATA_INTERACTIVE_BATCH_INTERVAL_MS : PTY_DATA_BATCH_INTERVAL_MS); }; const emitPtyExit = (entry: Pick, event: PtyExitEvent) => { @@ -3842,6 +3850,7 @@ export function createPtyService({ pendingDataChunks: [], pendingDataChars: 0, pendingDataTimer: null, + lastUserInputAt: 0, terminalSnapshot: tracked ? createTerminalSnapshotMirror(cols, rows) : null, recentOutputTail: "", aiTitleTimer: null, @@ -4299,6 +4308,7 @@ export function createPtyService({ const entry = ptys.get(ptyId); if (!entry) return; try { + entry.lastUserInputAt = Date.now(); entry.pty.write(data); tryCliUserTitleFromWrite(entry, data); setRuntimeState(entry.sessionId, "running"); @@ -4659,6 +4669,7 @@ export function createPtyService({ ); if (!entry) return false; try { + entry.lastUserInputAt = Date.now(); entry.pty.write(data); tryCliUserTitleFromWrite(entry, data); setRuntimeState(entry.sessionId, "running"); @@ -4670,6 +4681,12 @@ export function createPtyService({ } }, + /** Whether a live (non-disposed) PTY currently backs `sessionId`. */ + hasLivePty(sessionId: string): boolean { + if (!sessionId) return false; + return liveEntryBySessionId(sessionId) != null; + }, + /** * Resize the active PTY for a given session id. Mobile clients call this * when their visible terminal viewport changes (orientation flip, split diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index 76aeb21e7..6b4fcc0ba 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -1561,6 +1561,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { readTranscriptTail, writeBySessionId, resizeBySessionId, + hasLivePty: () => true, enrichSessions: (rows: any[]) => rows, } as any, computerUseArtifactBrokerService: { diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 44a3de541..0e5935853 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -460,6 +460,12 @@ export type SyncTerminalSnapshotPayload = { endOffset?: number | null; /** True when `transcript` only contains bytes from the requested `sinceOffset` (client appends instead of replacing). */ delta?: boolean; + /** + * Whether a live PTY currently backs the session. False when a brain + * restart orphaned a "running" session — input would go nowhere, so clients + * surface a resume affordance instead of silently accepting keystrokes. + */ + live?: boolean; }; export type SyncTerminalDataPayload = { @@ -662,6 +668,7 @@ export type SyncRemoteCommandAction = | "work.updateSessionMeta" | "work.runQuickCommand" | "work.startCliSession" + | "work.resumeCliSession" | "work.sendToSession" | "work.stopRuntime" | "processes.listDefinitions" diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index a034c44b5..4e011bd24 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -3235,6 +3235,10 @@ struct TerminalSnapshot: Codable, Equatable { /// True when `transcript` only contains bytes from the requested /// `sinceOffset` to the end — append, don't replace. var delta: Bool? + /// Whether a live PTY currently backs the session. False when a brain + /// restart orphaned a "running" session — typing would go nowhere. Absent + /// on older hosts. + var live: Bool? } /// Response payload for `terminal_history`: transcript bytes diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 094d298c6..732707829 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -3902,6 +3902,28 @@ final class SyncService: ObservableObject { endOffset: snapshot.endOffset )) } + if snapshot.live == false { + // No PTY behind the session (ended, or orphaned by a brain restart even + // though status still says running) — typing would go nowhere. Surface + // the exited/resume state instead of a live prompt. + terminalStreamHandlers[sessionId]?(.exit(code: nil)) + } + } + + /// Whether a full-screen terminal currently owns the live stream for + /// `sessionId`. Used to skip detach-unsubscribes that would race a remount. + func hasTerminalStream(sessionId: String) -> Bool { + terminalStreamHandlers[sessionId] != nil + } + + /// Relaunches an ended/orphaned agent CLI session's runtime on the host + /// (same sessionId, provider resume metadata) — the phone mirror of the + /// desktop resume affordance. + func resumeCliSession(sessionId: String, cols: Int? = nil, rows: Int? = nil) async throws -> StartCliSessionResult { + var args: [String: Any] = ["sessionId": sessionId] + if let cols { args["cols"] = cols } + if let rows { args["rows"] = rows } + return try await sendDecodableCommand(action: "work.resumeCliSession", args: args, as: StartCliSessionResult.self) } func unsubscribeTerminal(sessionId: String) async throws { diff --git a/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift b/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift index 9ff34693c..03bb16cf0 100644 --- a/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift +++ b/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift @@ -103,6 +103,8 @@ final class TerminalSessionController: NSObject, ObservableObject { @Published private(set) var liveChunksWhileScrolledUp = 0 @Published private(set) var isLoadingHistory = false @Published private(set) var hasExited = false + @Published private(set) var isResuming = false + @Published private(set) var resumeError: String? @Published private(set) var isSubscribed = false @Published private(set) var ctrlArmed = false @Published private(set) var bellPulse = 0 @@ -191,7 +193,41 @@ final class TerminalSessionController: NSObject, ObservableObject { syncService.detachTerminalStream(sessionId: sessionId) isSubscribed = false let id = sessionId - Task { try? await syncService.unsubscribeTerminal(sessionId: id) } + Task { @MainActor in + // Pop-then-repush of the same session can order the old screen's + // disappear after the new screen's subscribe; unsubscribing then would + // sever the new screen's live stream (and trigger the host's + // desktop-size restore). Skip when another screen has re-attached. + guard !syncService.hasTerminalStream(sessionId: id) else { return } + try? await syncService.unsubscribeTerminal(sessionId: id) + } + } + + /// Relaunch an ended/orphaned session's runtime and re-attach the stream. + func resume() { + guard !isResuming, let syncService, !sessionId.isEmpty else { return } + isResuming = true + resumeError = nil + ADEHaptics.medium() + let id = sessionId + let terminal = terminalView?.getTerminal() + let cols = terminal.map(\.cols) + let rows = terminal.map(\.rows) + Task { @MainActor [weak self] in + defer { self?.isResuming = false } + guard let self else { return } + do { + _ = try await syncService.resumeCliSession(sessionId: id, cols: cols, rows: rows) + self.hasExited = false + // The relaunched PTY appends to the same transcript; resume the + // stream exactly after what's already rendered. + try? await syncService.subscribeTerminalStream(sessionId: id, sinceOffset: self.transcriptEndOffset) + self.isSubscribed = true + self.startLegacyPollingIfNeeded() + } catch { + self.resumeError = (error as NSError).localizedDescription + } + } } /// Pre-offset hosts never push terminal_data (their PTY→sync bridge only @@ -323,6 +359,9 @@ final class TerminalSessionController: NSObject, ObservableObject { appendBytes(Data(text.utf8), endOffset: endOffset, countsAsLive: true) case .exit: hasExited = true + // Drop the keyboard: input has nowhere to go and the resume bar + // replaces the key bar. + _ = terminalView?.resignFirstResponder() } } @@ -476,19 +515,27 @@ final class TerminalSessionController: NSObject, ObservableObject { terminalView?.paste(nil) } + /// Leading + trailing flush: the first keystroke of a burst goes out + /// immediately (single taps shouldn't pay the batch window), follow-ups + /// within 16ms coalesce into one trailing envelope. private func scheduleInputFlush() { guard inputFlushTask == nil else { return } + flushPendingInputNow() inputFlushTask = Task { @MainActor [weak self] in try? await Task.sleep(nanoseconds: 16_000_000) guard let self, !Task.isCancelled else { return } self.inputFlushTask = nil - let buffered = self.pendingInput - self.pendingInput = "" - guard !buffered.isEmpty else { return } - self.syncService?.sendTerminalInput(sessionId: self.sessionId, data: buffered) + self.flushPendingInputNow() } } + private func flushPendingInputNow() { + let buffered = pendingInput + pendingInput = "" + guard !buffered.isEmpty else { return } + syncService?.sendTerminalInput(sessionId: sessionId, data: buffered) + } + private func sendResizeIfNeeded(cols: Int, rows: Int) { guard cols > 1, rows > 1 else { return } if let lastSentSize, lastSentSize.cols == cols, lastSentSize.rows == rows { diff --git a/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift b/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift index 0ccbf0756..3e937d8a1 100644 --- a/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift +++ b/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift @@ -17,7 +17,9 @@ struct TerminalSessionScreen: View { @State private var statusDotFlash = false private var bottomIgnoredEdges: Edge.Set { - keyboardVisible ? [] : [.bottom] + // The resume bar renders without the keyboard; it needs the bottom safe + // area so it doesn't sit under the home indicator. + (keyboardVisible || controller.hasExited) ? [] : [.bottom] } private let keyboardShowPublisher: NotificationCenter.Publisher = @@ -50,7 +52,9 @@ struct TerminalSessionScreen: View { terminalStack .background(Color.black.ignoresSafeArea()) .safeAreaInset(edge: .bottom, spacing: 0) { - if keyboardVisible { + if controller.hasExited { + resumeBar + } else if keyboardVisible { keyBarStack } } @@ -197,6 +201,65 @@ struct TerminalSessionScreen: View { .buttonStyle(.plain) } + // MARK: - Resume bar + + /// Whether the host can relaunch this session's runtime: agent CLI sessions + /// carry resume metadata; plain shells do not. + private var sessionIsResumable: Bool { + let tool = session.toolType?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + return !tool.isEmpty && tool != "shell" + } + + private var resumeBar: some View { + VStack(spacing: 6) { + if let resumeError = controller.resumeError { + Text(resumeError) + .font(.caption2.weight(.medium)) + .foregroundStyle(ADEColor.warning) + .lineLimit(2) + .padding(.horizontal, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } + HStack(spacing: 10) { + Text("Session ended") + .font(.system(size: 13, weight: .medium, design: .monospaced)) + .foregroundStyle(ADEColor.textMuted) + Spacer(minLength: 0) + if sessionIsResumable { + Button { + controller.resume() + } label: { + HStack(spacing: 6) { + if controller.isResuming { + ProgressView() + .controlSize(.small) + .tint(.white) + } else { + Image(systemName: "arrow.counterclockwise") + .font(.system(size: 11, weight: .bold)) + } + Text(controller.isResuming ? "Resuming…" : "Resume") + .font(.system(size: 13, weight: .semibold)) + } + .foregroundStyle(.white) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .background(ADEColor.accent, in: Capsule()) + } + .buttonStyle(.plain) + .disabled(controller.isResuming) + } + } + .padding(.horizontal, 12) + } + .padding(.top, 8) + .padding(.bottom, 8) + .background(ADEColor.recessedBackground.opacity(0.92)) + .overlay(alignment: .top) { + Rectangle().fill(ADEColor.glassBorder).frame(height: 0.5) + } + } + // MARK: - Key bar private var keyBarStack: some View { diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index bc6acfd9c..59f2ad476 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -361,7 +361,7 @@ Implemented envelope types on iOS: | `command_ack` | Runtime → phone | Command receipt | | `command_result` | Runtime → phone | Execution result or error | | `file_request` / `file_response` | Bidirectional | On-demand file access | -| `terminal_subscribe` / `terminal_unsubscribe` / `terminal_data` | Phone ↔ runtime | Terminal streaming; `unsubscribe` is sent when a Work terminal screen disappears so the phone stops accumulating buffer for off-screen sessions. `terminal_data` carries `offset` — the transcript's end byte offset after the chunk (null when the session has no transcript or hit the size cap) — so the phone can detect dropped chunks. `terminal_subscribe` accepts `sinceOffset`; when the runtime can serve exactly `sinceOffset → end` within the byte budget it replies with a `delta: true` snapshot (append, don't replace), giving exact back-fill after reconnects/gaps. Snapshots also report `startOffset`/`endOffset` | +| `terminal_subscribe` / `terminal_unsubscribe` / `terminal_data` | Phone ↔ runtime | Terminal streaming; `unsubscribe` is sent when a Work terminal screen disappears so the phone stops accumulating buffer for off-screen sessions. `terminal_data` carries `offset` — the transcript's end byte offset after the chunk (null when the session has no transcript or hit the size cap) — so the phone can detect dropped chunks. `terminal_subscribe` accepts `sinceOffset`; when the runtime can serve exactly `sinceOffset → end` within the byte budget it replies with a `delta: true` snapshot (append, don't replace), giving exact back-fill after reconnects/gaps. Snapshots also report `startOffset`/`endOffset`, plus `live: false` when no PTY backs the session (ended, or orphaned by a brain restart while status still says running) so the phone shows a resume bar instead of silently accepting keystrokes | | `terminal_history` | Phone → runtime | On-demand scrollback paging: `{ sessionId, beforeOffset, maxBytes? }` returns transcript bytes `[startOffset, endOffset)` ending at/before `beforeOffset` (page start scanned forward to a newline/ESC boundary; `atStart: true` at beginning of transcript). Requires an active `terminal_subscribe` | | `terminal_input` / `terminal_resize` | Phone → runtime | Raw input bytes and viewport size changes for a subscribed live PTY. Mobile resizes are non-authoritative: the runtime records the last desktop-originated size and restores it when the last subscribed phone detaches | | `chat_subscribe` / `chat_event` | Phone → runtime / runtime → phone | Agent chat transcript streaming; `chat_subscribe` carries `sinceSeq` so the runtime can replay exactly the missed events from its per-session buffer instead of re-sending a snapshot | From 3adea51f5b172641ef838d4ed2f667ea93cc2c65 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:19:25 -0400 Subject: [PATCH 06/10] iOS terminal key bar: shift-tab, shift-enter, keyboard dismiss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⇧⇥ sends back-tab (ESC[Z — cycles Claude Code permission modes), ⇧⏎ sends backslash+CR (soft newline in agent TUIs, line continuation in shells), and a trailing key lowers the iOS keyboard — there was no obvious way to dismiss it once raised. Co-Authored-By: Claude Fable 5 --- .../ADE/Views/Work/SwiftTermSessionView.swift | 5 ++++ .../Views/Work/TerminalSessionScreen.swift | 30 +++++++++++++++++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift b/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift index 03bb16cf0..9fab2380c 100644 --- a/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift +++ b/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift @@ -490,6 +490,11 @@ final class TerminalSessionController: NSObject, ObservableObject { enqueueInput(data) } + func dismissKeyboard() { + ADEHaptics.light() + _ = terminalView?.resignFirstResponder() + } + func toggleCtrl() { guard let view = terminalView else { return } view.controlModifier.toggle() diff --git a/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift b/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift index 3e937d8a1..5df618a44 100644 --- a/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift +++ b/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift @@ -309,18 +309,24 @@ struct TerminalSessionScreen: View { } private var keyBar: some View { - HStack(spacing: 5) { + HStack(spacing: 4) { keyButton("esc", sends: "\u{1B}") keyButton("⇥", sends: "\t") + // Back-tab (ESC[Z) — cycles permission modes in Claude Code. + keyButton("⇧⇥", sends: "\u{1B}[Z") ctrlButton keyButton("↑", sends: "\u{1B}[A") keyButton("↓", sends: "\u{1B}[B") keyButton("←", sends: "\u{1B}[D") keyButton("→", sends: "\u{1B}[C") keyButton("⏎", sends: "\r") + // Backslash + return: newline in agent TUIs (Claude Code treats `\` + // + Enter as soft return) and line continuation in shells. + keyButton("⇧⏎", sends: "\\\r") overflowMenu + dismissKeyboardButton } - .padding(.horizontal, 8) + .padding(.horizontal, 6) .disabled(controller.hasExited) .opacity(controller.hasExited ? 0.4 : 1) } @@ -330,7 +336,24 @@ struct TerminalSessionScreen: View { controller.sendKeySequence(data) } label: { Text(label) - .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .font(.system(size: label.count > 1 && label != "esc" ? 11 : 13, weight: .semibold, design: .monospaced)) + .foregroundStyle(ADEColor.textPrimary) + .frame(maxWidth: .infinity, minHeight: 32) + .background(ADEColor.textPrimary.opacity(0.06), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(ADEColor.border.opacity(0.28), lineWidth: 0.6) + ) + } + .buttonStyle(.plain) + } + + private var dismissKeyboardButton: some View { + Button { + controller.dismissKeyboard() + } label: { + Image(systemName: "keyboard.chevron.compact.down") + .font(.system(size: 13, weight: .semibold)) .foregroundStyle(ADEColor.textPrimary) .frame(maxWidth: .infinity, minHeight: 32) .background(ADEColor.textPrimary.opacity(0.06), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) @@ -340,6 +363,7 @@ struct TerminalSessionScreen: View { ) } .buttonStyle(.plain) + .accessibilityLabel("Hide keyboard") } private var ctrlButton: some View { From bca6d195398efdb75f20a6e6256cffa59b52972c Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:29:28 -0400 Subject: [PATCH 07/10] ship: automate parity updates --- .../sync/syncRemoteCommandService.test.ts | 64 +++++++++++++++++++ .../sync/deviceRegistryService.test.ts | 4 +- docs/features/sync-and-multi-device/README.md | 13 ++-- .../sync-and-multi-device/ios-companion.md | 29 +++++---- .../features/terminals-and-sessions/README.md | 47 ++++++++------ 5 files changed, 117 insertions(+), 40 deletions(-) create mode 100644 apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts new file mode 100644 index 000000000..4bc7a82e0 --- /dev/null +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vitest"; +import type { SyncCommandPayload } from "../../../../desktop/src/shared/types"; +import { createSyncRemoteCommandService } from "./syncRemoteCommandService"; + +function makePayload(action: string, args: Record = {}): SyncCommandPayload { + return { commandId: "cmd-1", action, args }; +} + +function createService() { + const ptyService = { + resumeSession: vi.fn().mockResolvedValue({ + sessionId: "session-1", + ptyId: "pty-1", + session: { id: "session-1", status: "running" }, + }), + }; + const service = createSyncRemoteCommandService({ + laneService: {}, + prService: {}, + ptyService, + sessionService: {}, + fileService: {}, + logger: { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() }, + } as any); + return { service, ptyService }; +} + +describe("createSyncRemoteCommandService", () => { + it("routes work.resumeCliSession through the durable PTY resume path", async () => { + const { service, ptyService } = createService(); + + expect(service.getDescriptor("work.resumeCliSession")).toEqual({ + action: "work.resumeCliSession", + scope: "project", + policy: { viewerAllowed: true, queueable: true }, + }); + + const result = await service.execute(makePayload("work.resumeCliSession", { + sessionId: "session-1", + cols: 999, + rows: 1, + })); + + expect(ptyService.resumeSession).toHaveBeenCalledWith({ + sessionId: "session-1", + cols: 400, + rows: 4, + }); + expect(result).toEqual({ + sessionId: "session-1", + ptyId: "pty-1", + session: { id: "session-1", status: "running" }, + }); + }); + + it("rejects work.resumeCliSession without a session id", async () => { + const { service, ptyService } = createService(); + + await expect(service.execute(makePayload("work.resumeCliSession"))).rejects.toThrow( + "work.resumeCliSession requires sessionId.", + ); + expect(ptyService.resumeSession).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts b/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts index 3df404649..56c4ea6f7 100644 --- a/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts +++ b/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts @@ -156,9 +156,7 @@ describe("deviceRegistryService", () => { dbB.close(); }); - it("does not leave device-registry DELETE changesets after viewer join clear", async () => { - if (!isCrsqliteAvailable()) return; - + it.skipIf(!isCrsqliteAvailable())("does not leave device-registry DELETE changesets after viewer join clear", async () => { const projectRoot = makeProjectRoot("ade-device-registry-viewer-clear-"); const dbPath = path.join(projectRoot, ".ade", "ade.db"); const db = await openKvDb(dbPath, createLogger() as any); diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 8cf1e8eae..219d546d0 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -160,10 +160,13 @@ Canonical files (`apps/ade-cli/src/services/sync/`): `budget_usage_records`, `automation_runs`, `automation_action_results` — are filtered from phone changesets while ack watermarks still advance), the per-session chat-event seq - + replay buffer, terminal/chat subscription bridging, mobile - terminal input/resize forwarding into subscribed PTYs, lane presence - decoration, project catalog/switch envelopes, per-IP pairing rate - limiter, and the Tailscale Serve / mDNS publication paths. Runtime + + replay buffer, terminal/chat subscription bridging, offset-stamped + mobile terminal streams, `sinceOffset` delta snapshots, scrollback + paging via `terminal_history`, mobile terminal input/resize forwarding + into subscribed PTYs, desktop-size restore after the last phone + detaches, lane presence decoration, project catalog/switch envelopes, + per-IP pairing rate limiter, and the Tailscale Serve / mDNS + publication paths. Runtime kind is one of `desktop-embedded`, `headless`, `remote-stdio`, `desktop`, `daemon`, or `remote`. - `sharedSyncListener.ts` — the brain-level WebSocket listener shared @@ -509,7 +512,7 @@ Envelopes are JSON with fields: "heartbeat" | "file_request" | "file_response" | "terminal_subscribe" | "terminal_unsubscribe" | "terminal_snapshot" | "terminal_data" | "terminal_exit" | - "terminal_input" | "terminal_resize" | + "terminal_input" | "terminal_resize" | "terminal_history" | "chat_subscribe" | "chat_unsubscribe" | "chat_event" | "brain_status" | "project_catalog_request" | "project_catalog" | diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 59f2ad476..fa52165f4 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -114,8 +114,10 @@ apps/ios/ │ │ ├── Work/ # WorkRootScreen, WorkChatSessionView, │ │ │ # Work*Helpers, WorkNewChatScreen (chat/CLI │ │ │ # segmented launcher), WorkArtifactTerminalViews, -│ │ │ # WorkTerminalEmulatorView (UIKit-backed monospaced -│ │ │ # terminal screen + viewport reporter), +│ │ │ # TerminalSessionScreen + SwiftTermSessionView +│ │ │ # (full-screen SwiftTerm terminal, +│ │ │ # offset resume/history paging + +│ │ │ # viewport reporter), │ │ │ # WorkSessionDestination*, │ │ │ # WorkRootScreen+Selection (multi-select state + │ │ │ # bulk close/archive/restore/delete/export), @@ -1056,17 +1058,18 @@ reflected in the phone's UI on the next descriptor read. message, and sends it with the durable `sessionId`. The runtime writes to a live PTY when present, or starts the provider continuation internally and attaches the new PTY to the same session row. -- **`WorkTerminalEmulatorView` drives a monospaced grid, not a free - text view.** The viewport reported back to the runtime is in (cols, - rows) inferred from the rendered glyph cell, not pixel dimensions. - The emulator unsubscribes the runtime stream on `onDisappear` so a - user paging through the session list does not accumulate buffer - bytes for off-screen sessions; `restoreTerminalSubscriptions` - re-subscribes on reconnect for any session id still tracked in - `subscribedTerminalSessionIds`. Terminal snapshots request up to - 240 KB and local buffers trim at roughly 240,000 characters, keeping - recent CLI output available without letting an off-screen PTY grow - the mobile buffer indefinitely. +- **`TerminalSessionScreen` + `SwiftTermSessionView` drive a real + SwiftTerm grid, not a free text view.** The viewport reported back to + the runtime is in (cols, rows) inferred from the rendered glyph cell, + not pixel dimensions. The terminal unsubscribes the runtime stream on + `onDisappear` so a user paging through the session list does not keep + a phone-owned viewport attached; `restoreTerminalSubscriptions` + re-subscribes on reconnect with the last known transcript end offset + for any session id still tracked in `subscribedTerminalSessionIds`. + Terminal snapshots request up to 240 KB for legacy hosts; offset-aware + hosts use `sinceOffset` delta snapshots and `terminal_history` pages + so the phone can keep older scrollback without reloading the whole + tail. - **Lane presence is best-effort with a TTL.** The phone re-announces on a 30 s cadence; the runtime prunes stale entries at 60 s. A phone that crashes without sending `lanes.presence.release` diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 260d1781f..2a03229ff 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -40,14 +40,17 @@ desktop in-process path used before a binding exists, in diagnostics, and in tests. - `apps/desktop/src/main/services/pty/ptyService.ts` — PTY lifecycle, - transcript capture (capped at `MAX_TRANSCRIPT_BYTES = 64 MB`), runtime + transcript capture (capped at `MAX_TRANSCRIPT_BYTES = 16 MB`), runtime state, AI auto-titles, tool-type routing, continuation-target backfill, session-id based write/resize entry points used by mobile sync terminal control, `readTranscriptTail({ sessionId, ... })` which merges the on-disk transcript tail with the live PTY output tail so Work/TUI terminal hydration can replay output that is still buffered - in the transcript write stream, agent CLI input protocol (bracketed - paste, chunked writes, provider-specific submit delays), process tree + in the transcript write stream, `readTranscriptRange({ sessionId, + startOffset, endOffset })` for mobile scrollback/delta resume, + offset-stamped PTY data batches, desktop-size restore after + mobile-driven resizes, 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 row resync (re-opens rows that drifted to `ended` while the PTY is @@ -111,6 +114,7 @@ Shared types and IPC: - `apps/desktop/src/shared/types/sessions.ts` — `TerminalSessionSummary`, `TerminalSessionStatus`, `TerminalToolType`, `TerminalRuntimeState`, `TerminalResumeMetadata`, `PtyCreateArgs`, `SessionDeltaSummary`, + offset-stamped `PtyDataEvent`, `PtySendToSessionArgs` / `PtySendToSessionResult` (the send-or-continue surface), `PtyResumeSessionArgs` / `PtyResumeSessionResult` (prompt-free tracked CLI relaunch), the rich `ChatTerminalSession` / @@ -131,8 +135,11 @@ Shared types and IPC: `ade.localhost.probePort`. - `apps/desktop/src/shared/types/sync.ts` — terminal stream/control envelopes (`terminal_subscribe`, `terminal_unsubscribe`, - `terminal_data`, `terminal_exit`, `terminal_input`, `terminal_resize`) - for iOS Work surfaces, plus the mobile CLI launcher payload + `terminal_snapshot`, `terminal_data`, `terminal_history`, + `terminal_exit`, `terminal_input`, `terminal_resize`) for iOS Work + surfaces, including transcript offsets, `sinceOffset` delta resume, + `live` backing-PTY status, and pull-to-load-older history pages, plus + the mobile CLI launcher payload (`SyncCliLaunchProvider`, `SyncStartCliSessionArgs`, `SyncStartCliSessionResult`) consumed by the `work.startCliSession` remote command. @@ -496,15 +503,15 @@ iOS Work surfaces: row action. The earlier in-list activity feed is gone — running chats surface through the session list and the live-count chip. +- `apps/ios/ADE/Views/Work/TerminalSessionScreen.swift` and + `SwiftTermSessionView.swift` — full-screen SwiftTerm-backed terminal + surface for CLI sessions. It subscribes with `sinceOffset`, applies + offset-stamped `terminal_data`, pages older transcript bytes via + `terminal_history`, sends raw `terminal_input`, reports viewport + changes as `terminal_resize`, and unsubscribes on disappear. - `apps/ios/ADE/Views/Work/WorkArtifactTerminalViews.swift` — - terminal artifact/output views and the compact input bar that sends - `terminal_input` bytes and Ctrl-C to the subscribed host PTY. Hosts - the new emulator surface and unsubscribes via - `SyncService.unsubscribeTerminal` on view disappear. -- `apps/ios/ADE/Views/Work/WorkTerminalEmulatorView.swift` — - UIKit-backed monospaced terminal screen + `WorkTerminalScreen` - model that reports its viewport in (cols, rows) so the host can - resize the PTY to the phone's actual rendered grid. + terminal artifact/output views and inline preview cards; the older + lightweight terminal emulator remains here only for compact previews. - `apps/ios/ADE/Views/Work/WorkChatSessionView.swift`, `WorkChatComposerAndInputViews.swift`, `WorkChatRichCardViews.swift`, `WorkReasoningCard.swift`, `WorkNewChatScreen.swift` — mobile chat, @@ -574,9 +581,10 @@ See `apps/desktop/src/shared/types/sessions.ts` for the full shape. `initialInputDelayMs` delay. 2. **Stream** — PTY `data` events are written to the transcript - (capped at `MAX_TRANSCRIPT_BYTES = 64 MB`), throttled into a - `lastOutputPreview`, forwarded to `broadcastData`, and scanned for - runtime state signals (OSC 133 prompt markers). + (capped at `MAX_TRANSCRIPT_BYTES = 16 MB`), throttled into a + `lastOutputPreview`, forwarded to `broadcastData` with the transcript + end offset when available, and scanned for runtime state signals + (OSC 133 prompt markers). 3. **Tag** — the tool type is inferred or passed by the renderer. Claude/Codex sessions also get a best-effort `--session-id` extraction @@ -757,9 +765,10 @@ Processes (managed): falls back to `defaultResumeCommandForTool(toolType)`. Editing it directly is only allowed through `sessionService.setResumeCommand` or `updateMeta`, both of which re-derive the metadata. -- Transcript writes are capped at 64 MB; after the cap a notice line is - written once and further output is dropped. The runtime counter - `transcriptBytesWritten` is not persisted. +- Transcript writes are capped at 16 MB; after the cap a notice line is + written once and further output is dropped. The runtime seeds + `transcriptBytesWritten` from the file size on attach, so the cap + survives resume. - Preview updates are throttled (~900 ms) and the string is capped at 220 chars via `derivePreviewFromChunk`. - Reconcile and dispose paths gate on `processRegistryService` live and From 2d9314474f0e98ebd29d114d14f87d05454f262b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:33:12 -0400 Subject: [PATCH 08/10] ship: simplify terminal sync paths --- .../src/services/sync/syncHostStartupLoop.ts | 5 +++-- .../src/main/services/pty/ptyService.ts | 21 ++++++++----------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncHostStartupLoop.ts b/apps/ade-cli/src/services/sync/syncHostStartupLoop.ts index 11a40a6b2..9b8460b1b 100644 --- a/apps/ade-cli/src/services/sync/syncHostStartupLoop.ts +++ b/apps/ade-cli/src/services/sync/syncHostStartupLoop.ts @@ -98,14 +98,15 @@ export async function runSyncHostStartupLoop(deps: SyncHostStartupLoopDeps): Pro } if (error instanceof SyncHostSingletonConflictError) { const owner = error.conflict.owner; - if (owner.pid !== process.pid && !isSameChannelSyncHostOwner(owner, deps.env)) { + const sameChannelOwner = isSameChannelSyncHostOwner(owner, deps.env); + if (owner.pid !== process.pid && !sameChannelOwner) { throw error; } const serviceMainPid = deps.getServiceMainPid?.() ?? null; if ( owner.pid !== process.pid && serviceMainPid === process.pid - && isSameChannelSyncHostOwner(owner, deps.env) + && sameChannelOwner ) { deps.log( `ADE brain taking over mobile sync from stale ${owner.appName ?? "ADE"} brain (pid ${owner.pid}).`, diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 2d2c9343d..d0d695d74 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -4664,10 +4664,9 @@ export function createPtyService({ */ writeBySessionId(sessionId: string, data: string): boolean { if (!sessionId || typeof data !== "string") return false; - const entry = Array.from(ptys.values()).find( - (candidate) => candidate.sessionId === sessionId && !candidate.disposed, - ); - if (!entry) return false; + const live = liveEntryBySessionId(sessionId); + if (!live) return false; + const [, entry] = live; try { entry.lastUserInputAt = Date.now(); entry.pty.write(data); @@ -4694,10 +4693,9 @@ export function createPtyService({ */ resizeBySessionId(sessionId: string, cols: number, rows: number, opts?: { source?: "desktop" | "mobile" }): boolean { if (!sessionId) return false; - const entry = Array.from(ptys.values()).find( - (candidate) => candidate.sessionId === sessionId && !candidate.disposed, - ); - if (!entry) return false; + const live = liveEntryBySessionId(sessionId); + if (!live) return false; + const [, entry] = live; const safe = clampDims(cols, rows); // A mobile viewport must never become the desktop-preferred size — it // is restored from lastDesktop* when the phone detaches. @@ -4726,10 +4724,9 @@ export function createPtyService({ */ restoreDesktopSizeBySessionId(sessionId: string): boolean { if (!sessionId) return false; - const entry = Array.from(ptys.values()).find( - (candidate) => candidate.sessionId === sessionId && !candidate.disposed, - ); - if (!entry) return false; + const live = liveEntryBySessionId(sessionId); + if (!live) return false; + const [, entry] = live; const cols = entry.lastDesktopCols; const rows = entry.lastDesktopRows; if (cols == null || rows == null) return false; From 8988ff7283ca3ed985bf6fc56a771e6464b5e67b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 10 Jun 2026 21:58:05 -0400 Subject: [PATCH 09/10] ship: address terminal sync review feedback --- .../src/services/sync/syncHostService.test.ts | 34 ++++++++++++++++-- .../src/services/sync/syncHostService.ts | 35 ++++++++++++++----- .../sync/syncRemoteCommandService.test.ts | 14 ++++++++ .../services/sync/syncRemoteCommandService.ts | 8 +++-- .../src/main/services/pty/ptyService.test.ts | 35 ++++++++++++++++--- .../src/main/services/pty/ptyService.ts | 18 ++++++---- apps/ios/ADE/Services/SyncService.swift | 21 ++++++++--- .../ADE/Views/Work/SwiftTermSessionView.swift | 6 ++-- .../Views/Work/TerminalSessionScreen.swift | 3 +- 9 files changed, 142 insertions(+), 32 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index d241f0390..aca0c07f2 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -1082,6 +1082,20 @@ describe("terminal byte-offset streaming, history paging, and resize ownership", const stale = await nextResponse(client.envelopes, "terminal_snapshot", "sub-stale"); expect((stale.payload as { delta?: boolean }).delta).toBeUndefined(); expect((stale.payload as { transcript: string }).transcript).toBe("tail-snapshot"); + + readTranscriptTail.mockResolvedValueOnce(`${TRANSCRIPT_CONTENT}buffered`); + client.ws.send(encodeSyncEnvelope({ + type: "terminal_subscribe", + requestId: "sub-buffered", + payload: { sessionId: "session-1", maxBytes: 1_024, sinceOffset: 0 }, + })); + const buffered = await nextResponse(client.envelopes, "terminal_snapshot", "sub-buffered"); + expect(buffered.payload).toMatchObject({ + sessionId: "session-1", + transcript: `${TRANSCRIPT_CONTENT}buffered`, + startOffset: null, + endOffset: null, + }); } finally { try { client?.ws.close(); @@ -1101,6 +1115,22 @@ describe("terminal byte-offset streaming, history paging, and resize ownership", const port = await host.waitUntilListening(); client = await connectTerminalPeer(port, host.getBootstrapToken(), "ios-terminal-2"); + client.ws.send(encodeSyncEnvelope({ + type: "terminal_history", + requestId: "hist-wrong-project", + projectId: "project-2", + payload: { sessionId: "session-1", beforeOffset: 4_000 }, + })); + const wrongProject = await nextResponse(client.envelopes, "terminal_history", "hist-wrong-project"); + expect(wrongProject.payload).toEqual({ + sessionId: "session-1", + data: "", + startOffset: 4_000, + endOffset: 4_000, + atStart: true, + }); + expect(readTranscriptRange).not.toHaveBeenCalled(); + // Not subscribed yet: same access gate as terminal_input. client.ws.send(encodeSyncEnvelope({ type: "terminal_history", @@ -1113,7 +1143,7 @@ describe("terminal byte-offset streaming, history paging, and resize ownership", data: "", startOffset: 4_000, endOffset: 4_000, - atStart: false, + atStart: true, }); expect(readTranscriptRange).not.toHaveBeenCalled(); @@ -1223,7 +1253,7 @@ describe("terminal byte-offset streaming, history paging, and resize ownership", payload: { sessionId: "session-1", beforeOffset: 100 }, })); const fence = await nextResponse(clientA.envelopes, "terminal_history", "fence-a"); - expect((fence.payload as { atStart: boolean }).atStart).toBe(false); + expect((fence.payload as { atStart: boolean }).atStart).toBe(true); expect(restoreDesktopSizeBySessionId).not.toHaveBeenCalled(); // Last watcher disconnects → snap back to the desktop size. diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 963c37667..14c29e3b9 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -3211,6 +3211,22 @@ export function createSyncHostService(args: SyncHostServiceArgs) { message: resolution.message, }, } satisfies SyncFileResponsePayload, requestId); + return; + } + + if (type === "terminal_history") { + const historyPayload = (payload ?? {}) as { sessionId?: string; beforeOffset?: number }; + const sessionId = toOptionalString(historyPayload.sessionId) ?? ""; + const beforeOffset = typeof historyPayload.beforeOffset === "number" && Number.isFinite(historyPayload.beforeOffset) + ? Math.max(0, Math.floor(historyPayload.beforeOffset)) + : 0; + sendRequired(peer, "terminal_history", { + sessionId, + data: "", + startOffset: beforeOffset, + endOffset: beforeOffset, + atStart: true, + } satisfies SyncTerminalHistoryResponsePayload, requestId); } } @@ -3541,9 +3557,14 @@ export function createSyncHostService(args: SyncHostServiceArgs) { alignToLineBoundary: true, }) : ""; - // The tail read merges still-buffered live output, so its byte length - // can exceed what the WriteStream has flushed to disk; clamp the - // derived start to 0 and let the phone's gap detection self-heal. + // The tail read can merge still-buffered live output that is not + // reflected in the flushed file size yet. Only advertise offsets when + // the returned bytes fit inside the flushed transcript. + const transcriptBytes = Buffer.byteLength(transcript, "utf8"); + const snapshotStartOffset = transcriptSize != null && transcriptBytes <= transcriptSize + ? transcriptSize - transcriptBytes + : null; + const snapshotEndOffset = snapshotStartOffset != null ? transcriptSize : null; const snapshot: SyncTerminalSnapshotPayload = { sessionId, transcript, @@ -3551,10 +3572,8 @@ export function createSyncHostService(args: SyncHostServiceArgs) { runtimeState: session?.runtimeState ?? null, lastOutputPreview: session?.lastOutputPreview ?? null, capturedAt: nowIso(), - startOffset: transcriptSize != null - ? Math.max(0, transcriptSize - Buffer.byteLength(transcript, "utf8")) - : null, - endOffset: transcriptSize, + startOffset: snapshotStartOffset, + endOffset: snapshotEndOffset, live: args.ptyService.hasLivePty(sessionId), }; sendRequired(peer, "terminal_snapshot", snapshot, envelope.requestId); @@ -3584,7 +3603,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { data: "", startOffset: beforeOffset, endOffset: beforeOffset, - atStart: false, + atStart: true, }; const session = args.sessionService.get(sessionId); if (!peer.subscribedSessionIds.has(sessionId) || !session) { diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index 4bc7a82e0..8c494b9fa 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -61,4 +61,18 @@ describe("createSyncRemoteCommandService", () => { ); expect(ptyService.resumeSession).not.toHaveBeenCalled(); }); + + it("omits non-finite work.resumeCliSession dimensions", async () => { + const { service, ptyService } = createService(); + + await service.execute(makePayload("work.resumeCliSession", { + sessionId: "session-1", + cols: Number.NaN, + rows: Number.POSITIVE_INFINITY, + })); + + expect(ptyService.resumeSession).toHaveBeenCalledWith({ + sessionId: "session-1", + }); + }); }); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 58331635e..477ab0cb6 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -2278,8 +2278,12 @@ function registerWorkRemoteCommands({ args, register }: RemoteCommandRegistratio // agent CLI session's runtime (same sessionId, provider resume metadata). const value = (payload ?? {}) as Record; const sessionId = requireString(value.sessionId, "work.resumeCliSession requires sessionId."); - const cols = typeof value.cols === "number" ? clampCliDimension(Math.floor(value.cols), DEFAULT_CLI_COLS, 20, MAX_CLI_COLS) : undefined; - const rows = typeof value.rows === "number" ? clampCliDimension(Math.floor(value.rows), DEFAULT_CLI_ROWS, 4, MAX_CLI_ROWS) : undefined; + const cols = typeof value.cols === "number" && Number.isFinite(value.cols) + ? clampCliDimension(value.cols, DEFAULT_CLI_COLS, 20, MAX_CLI_COLS) + : undefined; + const rows = typeof value.rows === "number" && Number.isFinite(value.rows) + ? clampCliDimension(value.rows, DEFAULT_CLI_ROWS, 4, MAX_CLI_ROWS) + : undefined; const result = await args.ptyService.resumeSession({ sessionId, ...(cols != null ? { cols } : {}), diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 55ae73bb2..16e33fe08 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -4388,14 +4388,15 @@ describe("ptyService", () => { expect(mockPty.resize).toHaveBeenLastCalledWith(90, 30); }); - it("does not restore when no desktop size was ever recorded", async () => { + it("restores to the create-time size when mobile resizes before desktop", async () => { const { service, mockPty } = createHarness(); const { sessionId } = await service.create({ laneId: "lane-1", title: "t", cols: 80, rows: 24 }); expect(service.resizeBySessionId(sessionId, 61, 21, { source: "mobile" })).toBe(true); - const resizeCalls = (mockPty.resize as ReturnType).mock.calls.length; - expect(service.restoreDesktopSizeBySessionId(sessionId)).toBe(false); - expect((mockPty.resize as ReturnType).mock.calls.length).toBe(resizeCalls); + expect(mockPty.resize).toHaveBeenLastCalledWith(61, 21); + + expect(service.restoreDesktopSizeBySessionId(sessionId)).toBe(true); + expect(mockPty.resize).toHaveBeenLastCalledWith(80, 24); }); }); @@ -5300,6 +5301,32 @@ describe("ptyService", () => { expect(mockPty.write).toHaveBeenCalledWith("y\n"); }); + it("writeTerminal marks user input so immediate output uses the interactive batch window", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-06-10T12:00:00.000Z")); + const { service, mockPty, broadcastData } = createChatHarness(); + await service.create({ + laneId: "lane-1", + title: "Writer", + cols: 80, + rows: 24, + chatSessionId: "chat-write", + }); + + await service.writeTerminal({ chatSessionId: "chat-write", data: "y\n" }); + mockPty._emitter.emit("data", "prompt"); + + await vi.advanceTimersByTimeAsync(7); + expect(broadcastData).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + expect(broadcastData).toHaveBeenCalledWith(expect.objectContaining({ data: "prompt" })); + } finally { + vi.useRealTimers(); + } + }); + it("resizeTerminal resizes the active chat terminal", async () => { const { service, mockPty } = createChatHarness(); await service.create({ diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index d0d695d74..604c73356 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -3845,8 +3845,8 @@ export function createPtyService({ cleanupPaths, lastResizeCols: null, lastResizeRows: null, - lastDesktopCols: null, - lastDesktopRows: null, + lastDesktopCols: cols, + lastDesktopRows: rows, pendingDataChunks: [], pendingDataChars: 0, pendingDataTimer: null, @@ -4572,10 +4572,16 @@ export function createPtyService({ } entry = live[1]; } - entry.pty.write(args.data); - tryCliUserTitleFromWrite(entry, args.data); - setRuntimeState(entry.sessionId, "running"); - scheduleIdleTransition(entry.sessionId); + try { + entry.lastUserInputAt = Date.now(); + entry.pty.write(args.data); + tryCliUserTitleFromWrite(entry, args.data); + setRuntimeState(entry.sessionId, "running"); + scheduleIdleTransition(entry.sessionId); + } catch (err) { + logger.warn("pty.terminal_write_failed", { sessionId: entry.sessionId, err: String(err) }); + throw err; + } return { ok: true }; }, diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 732707829..55afec4ae 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -3800,7 +3800,9 @@ final class SyncService: ObservableObject { } func detachTerminalStream(sessionId: String) { - terminalStreamHandlers.removeValue(forKey: sessionId) + let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSessionId.isEmpty else { return } + terminalStreamHandlers.removeValue(forKey: trimmedSessionId) } /// Fetches transcript bytes ending at/before `beforeOffset` for on-demand @@ -3810,6 +3812,9 @@ final class SyncService: ObservableObject { guard !trimmedSessionId.isEmpty else { throw NSError(domain: "ADE", code: 6, userInfo: [NSLocalizedDescriptionKey: "Missing terminal session id."]) } + guard subscribedTerminalSessionIds.contains(trimmedSessionId) else { + throw NSError(domain: "ADE", code: 7, userInfo: [NSLocalizedDescriptionKey: "Terminal stream is not subscribed."]) + } let requestId = makeRequestId() // Older hosts never answer terminal_history; let the request time out // without tearing the socket down. @@ -3817,7 +3822,7 @@ final class SyncService: ObservableObject { self.sendEnvelope(type: "terminal_history", requestId: requestId, payload: [ "sessionId": trimmedSessionId, "beforeOffset": beforeOffset, - "maxBytes": max(1_024, min(syncTerminalStreamMaxBytes, maxBytes)), + "maxBytes": max(1_024, min(syncTerminalHistoryMaxBytes, maxBytes)), ]) } return try decode(raw, as: TerminalHistorySlice.self) @@ -3842,8 +3847,12 @@ final class SyncService: ObservableObject { } else { if chunkStart < lastEnd { // Partial overlap: trim the already-applied UTF-8 prefix. - let overlap = lastEnd - chunkStart - deliverableChunk = String(decoding: Array(chunk.utf8).dropFirst(overlap), as: UTF8.self) + let bytes = Array(chunk.utf8) + var overlap = min(max(0, lastEnd - chunkStart), bytes.count) + while overlap < bytes.count, bytes[overlap] & 0xC0 == 0x80 { + overlap += 1 + } + deliverableChunk = String(decoding: bytes.dropFirst(overlap), as: UTF8.self) } terminalEndOffsets[sessionId] = endOffset } @@ -3913,7 +3922,9 @@ final class SyncService: ObservableObject { /// Whether a full-screen terminal currently owns the live stream for /// `sessionId`. Used to skip detach-unsubscribes that would race a remount. func hasTerminalStream(sessionId: String) -> Bool { - terminalStreamHandlers[sessionId] != nil + let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSessionId.isEmpty else { return false } + return terminalStreamHandlers[trimmedSessionId] != nil } /// Relaunches an ended/orphaned agent CLI session's runtime on the host diff --git a/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift b/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift index 9fab2380c..371d0a96b 100644 --- a/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift +++ b/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift @@ -426,7 +426,7 @@ final class TerminalSessionController: NSObject, ObservableObject { let slice = try await syncService.fetchTerminalHistory(sessionId: id, beforeOffset: startOffset) // A replace-hydration may have landed while the fetch was in flight; // prepending stale bytes would corrupt the window. - guard self.transcriptStartOffset == startOffset, slice.endOffset <= startOffset else { return } + guard self.transcriptStartOffset == startOffset, slice.endOffset == startOffset else { return } if !slice.data.isEmpty { self.transcript = Data(slice.data.utf8) + self.transcript self.transcriptStartOffset = slice.startOffset @@ -611,7 +611,7 @@ final class TerminalSessionController: NSObject, ObservableObject { extension TerminalSessionController: TerminalViewDelegate { nonisolated func send(source: TerminalView, data: ArraySlice) { - let text = String(decoding: data, as: UTF8.self) + let text = String(bytes: data, encoding: .utf8) ?? String(decoding: data, as: UTF8.self) MainActor.assumeIsolated { enqueueInput(text) } @@ -637,7 +637,7 @@ extension TerminalSessionController: TerminalViewDelegate { } nonisolated func clipboardCopy(source: TerminalView, content: Data) { - let text = String(decoding: content, as: UTF8.self) + let text = String(bytes: content, encoding: .utf8) ?? String(decoding: content, as: UTF8.self) MainActor.assumeIsolated { UIPasteboard.general.string = text } diff --git a/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift b/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift index 5df618a44..86a5b0b8e 100644 --- a/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift +++ b/apps/ios/ADE/Views/Work/TerminalSessionScreen.swift @@ -206,8 +206,7 @@ struct TerminalSessionScreen: View { /// Whether the host can relaunch this session's runtime: agent CLI sessions /// carry resume metadata; plain shells do not. private var sessionIsResumable: Bool { - let tool = session.toolType?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" - return !tool.isEmpty && tool != "shell" + terminalSessionHasResumeTarget(session) } private var resumeBar: some View { From a96f5fa0d00cb26ed4ba74fdc024de4f22c4112b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:27:12 -0400 Subject: [PATCH 10/10] ship: fix ios terminal resume races --- apps/ios/ADE/Services/SyncService.swift | 1 + apps/ios/ADE/Views/Work/SwiftTermSessionView.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 55afec4ae..a7e90e383 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -8449,6 +8449,7 @@ final class SyncService: ObservableObject { terminalBuffers.removeAll() terminalBufferUpdatedAt.removeAll() terminalEndOffsets.removeAll() + terminalGapRecoveryInFlight.removeAll() } terminalBufferRevision += 1 } diff --git a/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift b/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift index 371d0a96b..82ea90e6b 100644 --- a/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift +++ b/apps/ios/ADE/Views/Work/SwiftTermSessionView.swift @@ -218,6 +218,7 @@ final class TerminalSessionController: NSObject, ObservableObject { guard let self else { return } do { _ = try await syncService.resumeCliSession(sessionId: id, cols: cols, rows: rows) + guard self.sessionId == id, syncService.hasTerminalStream(sessionId: id) else { return } self.hasExited = false // The relaunched PTY appends to the same transcript; resume the // stream exactly after what's already rendered.