diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AltScreenNotice.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AltScreenNotice.swift new file mode 100644 index 000000000000..0da06d6db054 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+AltScreenNotice.swift @@ -0,0 +1,9 @@ +extension MobileShellComposite { + /// Returns whether the latest render-grid frame for a surface is alternate screen. + /// + /// - Parameter surfaceID: The terminal surface identifier to inspect. + /// - Returns: `true` when the surface is currently tracked as alternate screen. + public func isAlternateScreen(surfaceID: String) -> Bool { + terminalActiveScreenBySurfaceID[surfaceID] == .alternate + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift index 1631706b7cff..2ccd83cc91fa 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalOutputDelivery.swift @@ -18,7 +18,11 @@ extension MobileShellComposite { } func recordTerminalRenderGridDelivery(_ renderGrid: MobileTerminalRenderGridFrame) { - terminalActiveScreenBySurfaceID[renderGrid.surfaceID] = renderGrid.activeScreen + // The toolbar observes this dictionary via `isAlternateScreen`; same-value + // writes would re-fire observers for every delivered render-grid frame. + if terminalActiveScreenBySurfaceID[renderGrid.surfaceID] != renderGrid.activeScreen { + terminalActiveScreenBySurfaceID[renderGrid.surfaceID] = renderGrid.activeScreen + } if renderGrid.activeScreen == .alternate, renderGrid.full { terminalAlternateRenderGridBaselineSurfaceIDs.insert(renderGrid.surfaceID) } else if renderGrid.activeScreen == .primary { @@ -102,7 +106,9 @@ extension MobileShellComposite { ) if source == "event", needsRenderGridBaseline, !establishesRenderGridBaseline { if renderGrid.activeScreen == .alternate { - terminalActiveScreenBySurfaceID[renderGrid.surfaceID] = .alternate + if terminalActiveScreenBySurfaceID[renderGrid.surfaceID] != .alternate { + terminalActiveScreenBySurfaceID[renderGrid.surfaceID] = .alternate + } deliverTerminalViewportPolicy(renderGrid.mobileViewportPolicy, surfaceID: renderGrid.surfaceID) } MobileDebugLog.anchormux("sync.render_grid_waiting_for_baseline source=\(source) surface=\(renderGrid.surfaceID) seq=\(renderGrid.stateSeq)") @@ -123,7 +129,9 @@ extension MobileShellComposite { ) } if deliveryDecision.updateTrackedScreen { - terminalActiveScreenBySurfaceID[renderGrid.surfaceID] = renderGrid.activeScreen + if terminalActiveScreenBySurfaceID[renderGrid.surfaceID] != renderGrid.activeScreen { + terminalActiveScreenBySurfaceID[renderGrid.surfaceID] = renderGrid.activeScreen + } if renderGrid.activeScreen == .primary { terminalAlternateRenderGridBaselineSurfaceIDs.remove(renderGrid.surfaceID) } diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellAltScreenNoticeTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellAltScreenNoticeTests.swift new file mode 100644 index 000000000000..da8e25d98540 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellAltScreenNoticeTests.swift @@ -0,0 +1,103 @@ +import CMUXMobileCore +import CmuxMobileRPC +import CmuxMobileShellModel +import Foundation +import Observation +import Testing +@testable import CmuxMobileShell + +@MainActor +@Suite struct MobileShellAltScreenNoticeTests { + @Test func alternateScreenAccessorTracksRenderGridFrames() throws { + let suiteName = "altscreen-state-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + let store = Self.makeStore(defaults: defaults) + + #expect(store.isAlternateScreen(surfaceID: "surface-a") == false) + + store.recordTerminalRenderGridDelivery(try Self.renderGridFrame( + surfaceID: "surface-a", + seq: 1, + activeScreen: .alternate + )) + #expect(store.isAlternateScreen(surfaceID: "surface-a")) + #expect(store.isAlternateScreen(surfaceID: "unknown-surface") == false) + + store.recordTerminalRenderGridDelivery(try Self.renderGridFrame( + surfaceID: "surface-a", + seq: 2, + activeScreen: .primary + )) + #expect(store.isAlternateScreen(surfaceID: "surface-a") == false) + } + + @Test func sameActiveScreenRenderGridDoesNotNotifyAlternateScreenObservers() async throws { + let suiteName = "altscreen-observation-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { + defaults.removePersistentDomain(forName: suiteName) + } + let store = Self.makeStore(defaults: defaults) + let surfaceID = "surface-a" + + store.recordTerminalRenderGridDelivery(try Self.renderGridFrame( + surfaceID: surfaceID, + seq: 1, + activeScreen: .alternate + )) + + try await confirmation("same active screen does not notify", expectedCount: 0) { didChange in + withObservationTracking { + _ = store.isAlternateScreen(surfaceID: surfaceID) + } onChange: { + didChange() + } + store.recordTerminalRenderGridDelivery(try Self.renderGridFrame( + surfaceID: surfaceID, + seq: 2, + activeScreen: .alternate + )) + } + + try await confirmation("different active screen notifies") { didChange in + withObservationTracking { + _ = store.isAlternateScreen(surfaceID: surfaceID) + } onChange: { + didChange() + } + store.recordTerminalRenderGridDelivery(try Self.renderGridFrame( + surfaceID: surfaceID, + seq: 3, + activeScreen: .primary + )) + } + } + + private static func makeStore(defaults: UserDefaults) -> MobileShellComposite { + return MobileShellComposite( + clientIDRepository: MobileClientIDRepository(defaults: defaults), + pairingHintDefaults: defaults + ) + } + + private static func renderGridFrame( + surfaceID: String, + seq: UInt64, + activeScreen: MobileTerminalRenderGridFrame.Screen + ) throws -> MobileTerminalRenderGridFrame { + var encodedFrame = try renderGridEventFrame( + surfaceID: surfaceID, + seq: seq, + text: "frame", + activeScreen: activeScreen + ) + let payloads = try MobileSyncFrameCodec.decodeFrames(from: &encodedFrame) + let payload = try #require(payloads.first) + let envelope = try #require(JSONSerialization.jsonObject(with: payload) as? [String: Any]) + let renderGridObject = try #require(envelope["payload"]) + return try MobileTerminalRenderGridFrame.decodeJSONObject(renderGridObject) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AltScreenNoticeButton.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AltScreenNoticeButton.swift new file mode 100644 index 000000000000..b0bf23036c8b --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/AltScreenNoticeButton.swift @@ -0,0 +1,82 @@ +import CmuxMobileSupport +import SwiftUI + +struct AltScreenNoticeButton: View { + let dismissNotice: () -> Void + @State private var isPresentingExplanation = false + + var body: some View { + Button { + isPresentingExplanation = true + } label: { + Label(buttonAccessibilityLabel, systemImage: "exclamationmark.triangle.fill") + } + .labelStyle(.iconOnly) + .foregroundStyle(.orange) + .accessibilityLabel(buttonAccessibilityLabel) + .accessibilityIdentifier("MobileTerminalAltScreenNoticeButton") + .popover(isPresented: $isPresentingExplanation) { + popoverContent + .presentationCompactAdaptation(.popover) + } + } + + private var popoverContent: some View { + VStack(alignment: .leading, spacing: 10) { + Label { + Text(title) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.orange) + + Text(explanation) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Button(action: dismissFromPopover) { + Text(dismissActionTitle) + } + .font(.footnote.weight(.medium)) + } + .padding(16) + .frame(maxWidth: 320, alignment: .leading) + .multilineTextAlignment(.leading) + } + + private var buttonAccessibilityLabel: String { + L10n.string( + "mobile.altScreenNotice.button.accessibilityLabel", + defaultValue: "Explain full-screen terminal sizing" + ) + } + + private var title: String { + L10n.string( + "mobile.altScreenNotice.title", + defaultValue: "Full-screen terminal app" + ) + } + + private var explanation: String { + L10n.string( + "mobile.altScreenNotice.explanation", + defaultValue: "A full-screen terminal app is running in this session. Full-screen apps mirror the Mac terminal's exact size, so the view may not fill this screen." + ) + } + + private var dismissActionTitle: String { + L10n.string( + "mobile.altScreenNotice.dismissAction", + defaultValue: "Don't Show Again" + ) + } + + private func dismissFromPopover() { + dismissNotice() + isPresentingExplanation = false + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift index ace6c1027b33..0a6063d43bcd 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift @@ -21,6 +21,7 @@ public final class MobileDisplaySettings { // `init` and the write-through in `didSet` are safe nonisolated. private nonisolated(unsafe) let defaults: UserDefaults private static let wrapWorkspaceTitlesKey = "cmux.mobile.wrapWorkspaceTitles" + private static let showAltScreenNoticeKey = "cmux.mobile.showAltScreenNotice" private static let workspacePreviewLineCountKey = "cmux.mobile.workspacePreviewLineCount" private static let unreadIndicatorLeftShiftKey = "cmux.mobile.debug.unreadIndicatorLeftShift.v2" private static let profilePictureLeftShiftKey = "cmux.mobile.debug.profilePictureLeftShift" @@ -50,6 +51,13 @@ public final class MobileDisplaySettings { didSet { defaults.set(wrapWorkspaceTitles, forKey: Self.wrapWorkspaceTitlesKey) } } + /// Whether the alternate-screen sizing notice is shown. Defaults to `true`. + /// The notice's "Don't Show Again" action sets this to `false`; mutating + /// this writes through to the injected ``UserDefaults``. + public var showAltScreenNotice: Bool { + didSet { defaults.set(showAltScreenNotice, forKey: Self.showAltScreenNoticeKey) } + } + /// How many lines a workspace row's activity preview shows (1 or 2). /// Defaults to 2. Mutating this clamps to the supported range and writes /// through to the injected ``UserDefaults``. @@ -99,6 +107,7 @@ public final class MobileDisplaySettings { public init(defaults: UserDefaults = .standard) { self.defaults = defaults self.wrapWorkspaceTitles = defaults.bool(forKey: Self.wrapWorkspaceTitlesKey) + self.showAltScreenNotice = defaults.object(forKey: Self.showAltScreenNoticeKey) as? Bool ?? true let storedPreviewLines = defaults.object(forKey: Self.workspacePreviewLineCountKey) as? Int self.workspacePreviewLineCount = Self.clampedWorkspacePreviewLineCount( storedPreviewLines ?? Self.defaultWorkspacePreviewLineCount diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 6555d5c45c41..b0a71c7d1b11 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -157,6 +157,14 @@ struct MobileSettingsView: View { } Section(L10n.string("mobile.settings.terminal", defaultValue: "Terminal")) { + Toggle(isOn: $displaySettings.showAltScreenNotice) { + Text(L10n.string( + "mobile.settings.altScreenNotice", + defaultValue: "Full-Screen Sizing Notice" + )) + } + .accessibilityIdentifier("MobileSettingsAltScreenNoticeToggle") + Button { showingShortcuts = true } label: { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift index f855726743fa..66824062a22e 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift @@ -33,6 +33,7 @@ struct WorkspaceDetailView: View { let backButtonConfiguration: WorkspaceBackButtonConfiguration? let signOut: (() -> Void)? @Environment(BrowserSurfaceStore.self) private var browserStore + @Environment(MobileDisplaySettings.self) private var displaySettings /// Drives the destructive close-workspace confirmation dialog. @State var isConfirmingClose = false #if canImport(UIKit) @@ -129,6 +130,15 @@ struct WorkspaceDetailView: View { ToolbarItem(id: "workspace-title", placement: .topBarLeading) { workspaceTitleToolbarMenu } + if let selectedTerminalID, + store.isAlternateScreen(surfaceID: selectedTerminalID), + displaySettings.showAltScreenNotice { + ToolbarItem(id: "workspace-altscreen-notice", placement: .topBarTrailing) { + AltScreenNoticeButton { + displaySettings.showAltScreenNotice = false + } + } + } ToolbarItem(id: "workspace-trailing", placement: .topBarTrailing) { toolbarTrailingCluster } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift index 7f5e81b6aca8..1b6bd15a8d64 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileDisplaySettingsTests.swift @@ -20,6 +20,28 @@ import Testing #expect(defaults.object(forKey: "cmux.mobile.workspacePreviewLineCount") == nil) } + @Test func showAltScreenNoticeDefaultsToTrueWithoutAWrite() throws { + let defaults = try makeDefaults("altScreenNoticeDefaults") + let settings = MobileDisplaySettings(defaults: defaults) + #expect(settings.showAltScreenNotice) + #expect(defaults.object(forKey: "cmux.mobile.showAltScreenNotice") == nil) + } + + @Test func showAltScreenNoticePersistsFalseAcrossInstances() throws { + let defaults = try makeDefaults("altScreenNoticePersistsFalse") + let settings = MobileDisplaySettings(defaults: defaults) + settings.showAltScreenNotice = false + #expect(MobileDisplaySettings(defaults: defaults).showAltScreenNotice == false) + } + + @Test func showAltScreenNoticePersistsTrueAcrossInstances() throws { + let defaults = try makeDefaults("altScreenNoticePersistsTrue") + let settings = MobileDisplaySettings(defaults: defaults) + settings.showAltScreenNotice = false + settings.showAltScreenNotice = true + #expect(MobileDisplaySettings(defaults: defaults).showAltScreenNotice) + } + @Test func previewLineCountPersistsAcrossInstances() throws { let defaults = try makeDefaults("persists") let settings = MobileDisplaySettings(defaults: defaults) diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index ce94462ee18b..7ba0a9c10d44 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -4098,6 +4098,23 @@ } } }, + "mobile.settings.altScreenNotice": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Full-Screen Sizing Notice" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "全画面サイズ通知" + } + } + } + }, "mobile.settings.previewLines": { "extractionState": "manual", "localizations": { @@ -5203,6 +5220,74 @@ } } }, + "mobile.altScreenNotice.button.accessibilityLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Explain full-screen terminal sizing" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "全画面ターミナルのサイズ表示を説明" + } + } + } + }, + "mobile.altScreenNotice.dismissAction": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Don't Show Again" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "今後表示しない" + } + } + } + }, + "mobile.altScreenNotice.explanation": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A full-screen terminal app is running in this session. Full-screen apps mirror the Mac terminal's exact size, so the view may not fill this screen." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このセッションでは全画面ターミナルアプリが実行されています。全画面アプリは Mac 側ターミナルの正確なサイズをそのまま表示するため、この画面いっぱいに広がらない場合があります。" + } + } + } + }, + "mobile.altScreenNotice.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Full-screen terminal app" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "全画面ターミナルアプリ" + } + } + } + }, "mobile.terminal.action.alt": { "extractionState": "manual", "localizations": {