diff --git a/MirrorClient/Android/README.md b/MirrorClient/Android/README.md index 2efc710b..366ae008 100644 --- a/MirrorClient/Android/README.md +++ b/MirrorClient/Android/README.md @@ -50,7 +50,8 @@ is no private AI console or dependency on personal shell wrappers. including applications that manage their own scrolling, stay enabled; unchanged text alone never disables a direction. Older Hosts need no boundary metadata. Both directions resume reading at the top of the returned screen. Automatic - following is paused by remote scrolling. + following is paused by remote scrolling. Completion is consumed once: switching + panes or recreating the reader preserves later local reading positions. - Saving credentials does not persist terminal output or drafts across process death. Rotation retains the current ViewModel; reopening the app offers saved Hosts. diff --git a/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt b/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt index ea62c213..3f0e9645 100644 --- a/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt +++ b/MirrorClient/Android/app/src/androidTest/java/com/awhisper/prowlmirror/RemoteScrollTest.kt @@ -2,6 +2,8 @@ package com.awhisper.prowlmirror import android.graphics.Bitmap import androidx.activity.ComponentActivity +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.test.* @@ -25,6 +27,7 @@ class RemoteScrollTest { private val lease = uuid() private var sequence = 1L private var includesScrollState = false + private val compositionGeneration = mutableStateOf(0) @After fun close() { @@ -74,7 +77,7 @@ class RemoteScrollTest { model.sessions += session model.selected = session.id } - rule.setContent { MirrorApp(model) } + rule.setContent { key(compositionGeneration.value) { MirrorApp(model) } } } private fun bounds(sequence: Long, atTop: Boolean? = null, atBottom: Boolean? = null) { @@ -228,6 +231,25 @@ class RemoteScrollTest { } } + @Test + fun rebuildingReaderDoesNotReplayCompletedRemoteScroll() { + val text = (1..140).joinToString("\n") { "Terminal row $it" } + show(text) + rule.onNodeWithText("Scroll up").performClick() + complete(text) + rule.onNodeWithTag("mirror-output").performTouchInput { swipeUp() } + var position = 0 to 0 + rule.runOnIdle { + position = session.liveScrollIndex to session.liveScrollOffset + assertTrue(position.first > 0 || position.second > 0) + compositionGeneration.value++ + } + rule.waitForIdle() + rule.runOnIdle { + assertEquals(position, session.liveScrollIndex to session.liveScrollOffset) + } + } + @Test fun oldHostsHaveNoRemoteControlsOrRemoteGestures() { show(capability = false) diff --git a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt index c0af7c00..0704210f 100644 --- a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/MirrorApp.kt @@ -404,6 +404,7 @@ private fun Reading(session: Session, state: SessionState) { val completion = state.scrollCompletion if (!state.showsHistory && completion != null && blocks.isNotEmpty()) { scroll.scrollToItem(0) + session.consumeScrollCompletion(completion.requestID) } } Column(Modifier.fillMaxSize()) { diff --git a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Session.kt b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Session.kt index 45343d76..c6817b6b 100644 --- a/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Session.kt +++ b/MirrorClient/Android/app/src/main/java/com/awhisper/prowlmirror/Session.kt @@ -521,6 +521,12 @@ class Session( ))) } + fun consumeScrollCompletion(id: String) { + state.update { + if (it.scrollCompletion?.requestID == id) it.copy(scrollCompletion = null) else it + } + } + private fun clearScroll() { scrollTimeout?.cancel() scrollTimeout = null diff --git a/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt b/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt index 981e87e1..8a43ca09 100644 --- a/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt +++ b/MirrorClient/Android/app/src/test/java/com/awhisper/prowlmirror/SessionTest.kt @@ -219,6 +219,12 @@ class SessionTest { ))) assertNull(f.session.state.value.scrolling) assertEquals(ScrollDirection.UP, f.session.state.value.scrollCompletion?.direction) + f.session.consumeScrollCompletion(uuid()) + assertNotNull(f.session.state.value.scrollCompletion) + f.session.consumeScrollCompletion(request.string("requestID")) + assertNull(f.session.state.value.scrollCompletion) + f.session.consumeScrollCompletion(request.string("requestID")) + assertNull(f.session.state.value.scrollCompletion) assertTrue(f.session.state.value.canScrollRemote(ScrollDirection.UP)) assertTrue(f.session.state.value.canScrollRemote(ScrollDirection.DOWN)) assertFalse(f.session.state.value.follow) diff --git a/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md b/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md index 3ea36f9c..f56ca7e7 100644 --- a/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md +++ b/docs-ai/067-remote-mirror/012-scroll-controls-and-report-feedback.md @@ -93,6 +93,10 @@ Both mobile clients now anchor both completed directions at the top. Mac exposes a correlated completion identity only after the reply and displayed frame match; the viewport resets once for each completion, including Original Size mode. Ordinary subsequent frames and duplicate results preserve local reading offsets. +Android consumes each matching completion after positioning the reader. Keeping a +consumed completion in Session would replay it when composition is recreated and +overwrite the saved reading position. Unknown completion IDs leave the current +event intact. Verification: 23 Mac tests pass, including real TUI event counts in both directions, exact native scrolling with a detected Codex Agent, correlated @@ -118,9 +122,14 @@ A synchronous surface-scoped callback intercepts only that binding's temporary file path, leaving the system clipboard untouched. Bounded archives are cached briefly; unbounded/oversized histories keep the established text fallback. The replica restores live input modes, replays history with trailing blank padding, waits for a -private end-of-stream title marker, scrolls locally, and verifies the visible +private end-of-stream OSC 7 working-directory marker, scrolls locally, and verifies the visible text against Host before revealing styled history. PTY write acknowledgement -alone is insufficient because parsing may still be in flight. +alone is insufficient because parsing may still be in flight. The replica consumes +the marker before it updates public working-directory or title state. Unlike OSC 2 +title callbacks, this callback is not disabled by a fixed Ghostty `title`, including +after configuration reload. A 30-second parser watchdog reports a stalled replica +and disconnects Mirror rather than leaving Host's outstanding frame unacknowledged +indefinitely. The archive omits trailing empty rows. Locating from the retained buffer start rather than its bottom avoids dropped rows at the active/history boundary. diff --git a/docs/remote-mirror.md b/docs/remote-mirror.md index 4d904ffb..130f6c37 100644 --- a/docs/remote-mirror.md +++ b/docs/remote-mirror.md @@ -268,7 +268,9 @@ and scan again if it expires. Camera permission is requested only when scanning; manual entry remains available if permission is denied or scanning is unsupported. On Android, the scanner opens in portrait without a red scanning line. The Live scroll toolbar places **Scroll up** and **Scroll down** at opposite ends, with -loading feedback between them. +loading feedback between them. Each completed scroll moves the reader to the top +once. Switching panes or recreating the Android reader preserves subsequent local +reading positions instead of repeating that move. ### Native history appearance on Mac @@ -278,3 +280,7 @@ This is separate from **History**, which remains a retained-text snapshot. Older Hosts, histories exceeding the replay limit, or a viewport that cannot be reconstructed safely use the existing **Host scrollback · Plain text** fallback. Mobile clients continue using text-v1 and require no update for this feature. +Mac frame updates also work with a fixed Ghostty `title`, including after a config +reload. If the local replica cannot confirm frame parsing within 30 seconds, +Mirror disconnects with an error rather than silently stopping updates. Reconnect +to try again. diff --git a/supacode/Features/RemoteMirror/MirrorReplica.swift b/supacode/Features/RemoteMirror/MirrorReplica.swift index 56e3170f..6ea4a113 100644 --- a/supacode/Features/RemoteMirror/MirrorReplica.swift +++ b/supacode/Features/RemoteMirror/MirrorReplica.swift @@ -16,6 +16,8 @@ final class MirrorReplica { @ObservationIgnored private var peer: MirrorRelayConnection? @ObservationIgnored private var candidate: MirrorRelayConnection? @ObservationIgnored private let runtime: GhosttyRuntime + @ObservationIgnored private let clock: any Clock + @ObservationIgnored private var parseTimeout: Task? @ObservationIgnored private let token = UUID().uuidString + UUID().uuidString @ObservationIgnored private var pending: MirrorMessage? @ObservationIgnored private var pendingScrollback: MirrorStyledScrollback? @@ -29,7 +31,10 @@ final class MirrorReplica { @ObservationIgnored private var stopped = false @ObservationIgnored private var needsRestart = false - init(runtime: GhosttyRuntime) { self.runtime = runtime } + init(runtime: GhosttyRuntime, clock: any Clock = ContinuousClock()) { + self.runtime = runtime + self.clock = clock + } func start() throws { if needsRestart { stop() } @@ -55,11 +60,16 @@ final class MirrorReplica { self.view = GhosttySurfaceView( runtime: self.runtime, workingDirectory: nil, context: GHOSTTY_SURFACE_CONTEXT_WINDOW, command: command) - self.view?.bridge.consumeTitle = { [weak self] title in - guard let self, title == self.presentationMarker else { return false } + self.view?.bridge.consumeWorkingDirectory = { [weak self] path in + guard let self, path == self.presentationMarker else { return false } self.didParseFrame() return true } + // Ghostty can also derive a title from OSC 7. Do not expose private markers. + self.view?.bridge.consumeTitle = { [weak self] title in + guard let self else { return false } + return title.hasPrefix("/prowl-replica-\(self.token)-") + } case .failed(let error): self.fail(error.localizedDescription) default: break } @@ -102,8 +112,15 @@ final class MirrorReplica { displayedMessage = message displayedScrollback = styledScrollback expectedViewportText = viewportText - let marker = "prowl-replica-\(token)-\(sequence)" + let marker = "/prowl-replica-\(token)-\(sequence)" presentationMarker = marker + parseTimeout?.cancel() + let clock = clock + parseTimeout = Task { @MainActor [weak self] in + do { try await clock.sleep(for: .seconds(30)) } catch { return } + guard let self, self.presentationMarker == marker else { return } + self.fail(String(localized: "Display replica timed out while parsing a Host frame. Reconnect to try again.")) + } var payload = MirrorRelayPacket.sequenceBytes(sequence) payload.append(frame.bytes) if let styledScrollback { @@ -115,7 +132,8 @@ final class MirrorReplica { payload.append(Data(String(repeating: "\r\n", count: Int(frame.rows)).utf8)) payload.append(Data("\u{1b}[?25l".utf8)) } - payload.append(Data("\u{1b}]2;\(marker)\u{7}".utf8)) + // Unlike title callbacks, OSC 7 remains available with a static title and after config reloads. + payload.append(Data("\u{1b}]7;file://localhost\(marker)\u{7}".utf8)) peer.send(MirrorRelayPacket(kind: .frame, payload: payload)) } else { pending = message @@ -129,6 +147,8 @@ final class MirrorReplica { let lease = message.subscriptionID, let terminal = view?.surface else { return } presentationMarker = nil + parseTimeout?.cancel() + parseTimeout = nil guard let scrollback = displayedScrollback else { usesStyledScrollback = false resumeRendering() @@ -176,6 +196,8 @@ final class MirrorReplica { func stop() { stopped = true + parseTimeout?.cancel() + parseTimeout = nil presentationTask?.cancel() presentationTask = nil needsRestart = false diff --git a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift index 0100b226..3329a991 100644 --- a/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift +++ b/supacode/Infrastructure/Ghostty/GhosttySurfaceBridge.swift @@ -9,6 +9,7 @@ final class GhosttySurfaceBridge { weak var surfaceView: GhosttySurfaceView? // Local replica markers acknowledge parsing, rather than merely a PTY write. var consumeTitle: ((String) -> Bool)? + var consumeWorkingDirectory: ((String) -> Bool)? var captureClipboard: (([(mime: String, data: String)]) -> Void)? struct MirrorScrollbar { let total: UInt64 @@ -263,6 +264,9 @@ final class GhosttySurfaceBridge { return true case GHOSTTY_ACTION_PWD: + if let path = string(from: action.action.pwd.pwd), consumeWorkingDirectory?(path) == true { + return true + } state.pwd = string(from: action.action.pwd.pwd) if let surfaceView { NSAccessibility.post(element: surfaceView, notification: .valueChanged) diff --git a/supacodeTests/RemoteMirror/MirrorReplicaInputTests.swift b/supacodeTests/RemoteMirror/MirrorReplicaInputTests.swift index 9a10957e..65da7442 100644 --- a/supacodeTests/RemoteMirror/MirrorReplicaInputTests.swift +++ b/supacodeTests/RemoteMirror/MirrorReplicaInputTests.swift @@ -1,5 +1,7 @@ import AppKit +import Clocks import Foundation +import GhosttyKit import Testing @testable import supacode @@ -7,10 +9,22 @@ import Testing @Suite(.serialized) @MainActor struct MirrorReplicaInputTests { - private static let runtime = GhosttyRuntime() - - @Test(.timeLimit(.minutes(1))) func replayDoesNotForwardAutomaticTerminalReports() async throws { - let replica = MirrorReplica(runtime: Self.runtime) + @Test(.timeLimit(.minutes(2)), arguments: [false, true]) + func replayDoesNotForwardAutomaticTerminalReports(staticTitle: Bool) async throws { + let runtime = GhosttyRuntime() + let configFile = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString + ".conf") + defer { try? FileManager.default.removeItem(at: configFile) } + func reloadTitle(_ title: String) throws { + let config = try #require(ghostty_config_new()) + defer { ghostty_config_free(config) } + try "title = \(title)\n".write(to: configFile, atomically: true, encoding: .utf8) + ghostty_config_load_file(config, configFile.path) + ghostty_config_finalize(config) + ghostty_app_update_config(runtime.app, config) + } + if staticTitle { try reloadTitle("Fixed replica title") } + let clock = TestClock() + let replica = MirrorReplica(runtime: runtime, clock: clock) let lease = UUID() var input = Data() var acknowledged: UInt64 = 0 @@ -29,6 +43,7 @@ struct MirrorReplicaInputTests { window.contentView = view defer { window.close() } for sequence in 1...3 { + if staticTitle, sequence == 2 { try reloadTitle("Reloaded fixed title") } replica.display( .frame( .init( @@ -44,8 +59,21 @@ struct MirrorReplicaInputTests { #expect(input.isEmpty, "Frame replay generated Host input: \(Array(input))") view.insertText("用户输入", replacementRange: NSRange(location: NSNotFound, length: 0)) - try await wait { (String(data: input, encoding: .utf8) ?? "").contains("用户输入") } - #expect((String(data: input, encoding: .utf8) ?? "").contains("\u{1b}[200~用户输入\u{1b}[201~")) + try await wait { + (String(data: input, encoding: .utf8) ?? "").contains("\u{1b}[200~用户输入\u{1b}[201~") + } + #expect(view.bridge.state.pwd?.contains("prowl-replica-") != true) + #expect(view.bridge.state.title?.contains("prowl-replica-") != true) + + // A lost parser callback must report failure rather than hold Host's frame gate forever. + var failure: String? + replica.onFailure = { failure = $0 } + view.bridge.consumeWorkingDirectory = { _ in true } + replica.display( + .frame(.init(frame: .init(columns: 80, rows: 24, bytes: Data("LAST".utf8)), sequence: 4, subscriptionID: lease))) + await clock.advance(by: .seconds(30)) + #expect(failure?.contains("timed out") == true) + #expect(acknowledged == 3) } private func wait(until condition: @MainActor () -> Bool) async throws { @@ -55,7 +83,7 @@ struct MirrorReplicaInputTests { timer.invalidate() continuation.finish() } - let deadline = ContinuousClock.now.advanced(by: .seconds(10)) + let deadline = ContinuousClock.now.advanced(by: .seconds(30)) for await _ in ticks { if condition() { return } if ContinuousClock.now >= deadline { throw Timeout() }