Skip to content
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)")
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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``.
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading