Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/swift-file-length-budget.tsv
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
34546 CLI/cmux.swift
17841 Sources/AppDelegate.swift
16132 Sources/ContentView.swift
13824 Sources/TerminalController.swift
14017 Sources/TerminalController.swift
12828 Sources/Workspace.swift
12237 Sources/GhosttyTerminalView.swift
12144 cmuxTests/AppDelegateShortcutRoutingTests.swift
Expand All @@ -28,7 +28,7 @@
3934 Sources/Feed/FeedPanelView.swift
3926 cmuxTests/TabManagerUnitTests.swift
3896 cmuxTests/WindowAndDragTests.swift
3734 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
3767 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift
3699 cmuxTests/CLIGenericHookPersistenceTests.swift
3397 Sources/CmuxConfig.swift
3364 cmuxTests/TabManagerSessionSnapshotTests.swift
Expand All @@ -39,7 +39,7 @@
2565 Sources/Panels/CmuxWebView.swift
2546 cmuxTests/WorkspaceManualUnreadTests.swift
2524 cmuxTests/CommandPaletteSearchEngineTests.swift
2395 Sources/Mobile/MobileHostService.swift
2396 Sources/Mobile/MobileHostService.swift
2328 cmuxTests/CJKIMEInputTests.swift
2242 Sources/TerminalNotificationStore.swift
2233 Sources/TerminalWindowPortal.swift
Expand All @@ -57,7 +57,7 @@
1680 cmuxUITests/BrowserPaneNavigationKeybindUITests.swift
1656 Sources/FileExplorerView.swift
1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift
1581 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift
1591 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift
1560 cmuxTests/TextBoxMentionCompletionTests.swift
1547 cmuxTests/TerminalControllerSocketSecurityTests.swift
1500 cmuxUITests/MultiWindowNotificationsUITests.swift
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ public struct MobileTerminalRenderGridFrame: Codable, Equatable, Sendable {
/// Styled spans for the scrollback lines, row index `0..<scrollbackRows`
/// (oldest first). Reuses ``styles`` by `styleID`.
public var scrollbackSpans: [RowSpan]
/// Whether the producing Mac surface is pinned to the live bottom. `nil`
/// means an older producer did not report the position, so clients should
/// keep jump-to-bottom UI hidden rather than guessing.
public var atBottom: Bool?

/// Whether a client can scroll toward newer terminal output.
public var hasRoomToScrollToBottom: Bool {
atBottom == false
}

public init(
format: String = Self.currentFormat,
Expand All @@ -61,7 +70,8 @@ public struct MobileTerminalRenderGridFrame: Codable, Equatable, Sendable {
terminalBackground: String? = nil,
terminalCursorColor: String? = nil,
scrollbackRows: Int = 0,
scrollbackSpans: [RowSpan] = []
scrollbackSpans: [RowSpan] = [],
atBottom: Bool? = nil
) throws {
guard format == Self.currentFormat else {
throw MobileTerminalRenderGridError.invalidFormat(format)
Expand Down Expand Up @@ -138,6 +148,7 @@ public struct MobileTerminalRenderGridFrame: Codable, Equatable, Sendable {
self.terminalCursorColor = terminalCursorColor
self.scrollbackRows = full ? resolvedScrollbackRows : 0
self.scrollbackSpans = full ? scrollbackSpans : []
self.atBottom = atBottom
}

public init(from decoder: Decoder) throws {
Expand All @@ -159,6 +170,7 @@ public struct MobileTerminalRenderGridFrame: Codable, Equatable, Sendable {
let terminalCursorColor = try container.decodeIfPresent(String.self, forKey: .terminalCursorColor)
let scrollbackRows = try container.decodeIfPresent(Int.self, forKey: .scrollbackRows) ?? 0
let scrollbackSpans = try container.decodeIfPresent([RowSpan].self, forKey: .scrollbackSpans) ?? []
let atBottom = try container.decodeIfPresent(Bool.self, forKey: .atBottom)
try self.init(
format: format,
surfaceID: surfaceID,
Expand All @@ -176,7 +188,8 @@ public struct MobileTerminalRenderGridFrame: Codable, Equatable, Sendable {
terminalBackground: terminalBackground,
terminalCursorColor: terminalCursorColor,
scrollbackRows: scrollbackRows,
scrollbackSpans: scrollbackSpans
scrollbackSpans: scrollbackSpans,
atBottom: atBottom
)
}

Expand All @@ -188,7 +201,8 @@ public struct MobileTerminalRenderGridFrame: Codable, Equatable, Sendable {
text: String,
cursor: Cursor? = nil,
full: Bool = true,
changedRows: Set<Int>? = nil
changedRows: Set<Int>? = nil,
atBottom: Bool? = nil
) throws -> MobileTerminalRenderGridFrame {
let lines = normalizedRows(from: text, maxRows: rows)
let includedRows = changedRows ?? Set(0..<rows)
Expand All @@ -211,7 +225,8 @@ public struct MobileTerminalRenderGridFrame: Codable, Equatable, Sendable {
cursor: cursor,
full: full,
clearedRows: full ? [] : Array(includedRows.sorted()),
rowSpans: spans
rowSpans: spans,
atBottom: atBottom
)
}

Expand Down Expand Up @@ -294,7 +309,8 @@ public struct MobileTerminalRenderGridFrame: Codable, Equatable, Sendable {
terminalBackground: full ? terminalBackground : nil,
terminalCursorColor: full ? terminalCursorColor : nil,
scrollbackRows: full ? scrollbackRows : 0,
scrollbackSpans: full ? scrollbackSpans : []
scrollbackSpans: full ? scrollbackSpans : [],
atBottom: atBottom
)
}

Expand Down Expand Up @@ -405,6 +421,7 @@ public struct MobileTerminalRenderGridFrame: Codable, Equatable, Sendable {
case terminalCursorColor = "terminal_cursor_color"
case scrollbackRows = "scrollback_rows"
case scrollbackSpans = "scrollback_spans"
case atBottom = "at_bottom"
}

/// Which terminal screen a full snapshot represents.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,84 @@ import Testing
#expect(decoded.terminalForeground == "#010203")
}

@Test func renderGridHasRoomToScrollOnlyWhenProducerReportsScrolledUp() throws {
let atBottom = try MobileTerminalRenderGridFrame(
surfaceID: "terminal-a",
stateSeq: 1,
columns: 8,
rows: 1,
rowSpans: [],
atBottom: true
)
let scrolledUp = try MobileTerminalRenderGridFrame(
surfaceID: "terminal-a",
stateSeq: 2,
columns: 8,
rows: 1,
rowSpans: [],
atBottom: false
)
let unknown = try MobileTerminalRenderGridFrame(
surfaceID: "terminal-a",
stateSeq: 3,
columns: 8,
rows: 1,
rowSpans: []
)

#expect(!atBottom.hasRoomToScrollToBottom)
#expect(scrolledUp.hasRoomToScrollToBottom)
#expect(!unknown.hasRoomToScrollToBottom)
}

@Test func renderGridAtBottomSurvivesJSONRoundTrip() throws {
let frame = try MobileTerminalRenderGridFrame(
surfaceID: "terminal-a",
stateSeq: 1,
columns: 8,
rows: 1,
rowSpans: [],
atBottom: false
)

let decoded = try MobileTerminalRenderGridFrame.decodeJSONObject(frame.jsonObject())

#expect(decoded.atBottom == false)
#expect(decoded.hasRoomToScrollToBottom)
}

@Test func renderGridDecodesMissingAtBottomAsUnknown() throws {
let object: [String: Any] = [
"format": MobileTerminalRenderGridFrame.currentFormat,
"surface_id": "terminal-a",
"state_seq": NSNumber(value: 44),
"columns": 8,
"rows": 4,
"row_spans": [],
]

let frame = try MobileTerminalRenderGridFrame.decodeJSONObject(object)

#expect(frame.atBottom == nil)
#expect(!frame.hasRoomToScrollToBottom)
}

@Test func renderGridDeltaFrameCarriesAtBottom() throws {
let frame = try MobileTerminalRenderGridFrame(
surfaceID: "terminal-a",
stateSeq: 1,
columns: 8,
rows: 2,
rowSpans: [.init(row: 1, column: 0, text: "hi")],
atBottom: false
)

let delta = try frame.filteredRows([1], full: false)

#expect(delta.atBottom == false)
#expect(delta.hasRoomToScrollToBottom)
}

@Test func renderGridDeltaDropsFullStateFields() throws {
let frame = try MobileTerminalRenderGridFrame(
surfaceID: "terminal-a",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ extension MobileShellComposite {
}

func deliverTerminalRenderGrid(_ frame: MobileTerminalRenderGridFrame, surfaceID: String) {
updateTerminalScrolledUp(
surfaceID: surfaceID,
scrolledUp: frame.hasRoomToScrollToBottom
)
deliverTerminalOutput(
TerminalOutputDelivery(
renderGrid: frame,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,26 @@ extension MobileShellComposite {
))
}

public func scrollTerminalToBottom(surfaceID: String) async {
guard let client = remoteClient,
let workspaceID = workspaceID(forTerminalID: surfaceID) else {
return
}
do {
let request = try MobileCoreRPCClient.requestData(
method: "mobile.terminal.scroll_to_bottom",
params: [
"workspace_id": workspaceID.rawValue,
"surface_id": surfaceID,
"client_id": clientID,
]
)
_ = try await client.sendRequest(request)
} catch {
terminalScrollDeliveryLog.error("scroll-to-bottom failed surface=\(surfaceID, privacy: .public) error=\(String(describing: error), privacy: .public)")
}
}

private func enqueueTerminalScroll(_ delivery: TerminalScrollDelivery) {
guard delivery.lines != 0 else { return }
let queueToken = terminalScrollQueueTokensBySurfaceID[delivery.surfaceID] ?? UUID()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,8 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
var terminalScrollQueueTokensBySurfaceID: [String: UUID]
var terminalScrollQueuesBySurfaceID: [String: TerminalScrollDeliveryQueue]
var terminalScrollbackPrefetchStatesBySurfaceID: [String: TerminalScrollbackPrefetchState]
/// Latest server-reported scroll position by terminal surface.
public private(set) var terminalScrolledUpBySurfaceID: [String: Bool]
private var rawTerminalInputBuffer: MobileTerminalInputSendBuffer
private var pairingAttemptID: UUID

Expand Down Expand Up @@ -842,6 +844,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
self.terminalScrollQueueTokensBySurfaceID = [:]
self.terminalScrollQueuesBySurfaceID = [:]
self.terminalScrollbackPrefetchStatesBySurfaceID = [:]
self.terminalScrolledUpBySurfaceID = [:]
self.rawTerminalInputBuffer = MobileTerminalInputSendBuffer()
self.pairingAttemptID = UUID()
}
Expand Down Expand Up @@ -4773,6 +4776,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
terminalScrollQueueTokensBySurfaceID = [:]
terminalScrollQueuesBySurfaceID = [:]
terminalScrollbackPrefetchStatesBySurfaceID = [:]
terminalScrolledUpBySurfaceID = [:]
terminalOutputTransport = .rawBytes
supportedHostCapabilities = []
terminalSubscriptionRefreshTask?.cancel()
Expand Down Expand Up @@ -6135,6 +6139,16 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
deliverTerminalRenderGrid(renderGrid, surfaceID: renderGrid.surfaceID)
}

/// Returns whether the terminal surface is currently scrolled away from the bottom.
public func terminalScrolledUp(surfaceID: String) -> Bool {
terminalScrolledUpBySurfaceID[surfaceID] ?? false
}

func updateTerminalScrolledUp(surfaceID: String, scrolledUp: Bool) {
guard terminalScrolledUpBySurfaceID[surfaceID] != scrolledUp else { return }
terminalScrolledUpBySurfaceID[surfaceID] = scrolledUp
}

private static func terminalSnapshotReplacementBytes(_ snapshotBytes: Data) -> Data {
var bytes = Data("\u{1B}c\u{1B}[H\u{1B}[2J\u{1B}[3J".utf8)
bytes.append(snapshotBytes)
Expand Down Expand Up @@ -6168,6 +6182,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking {
terminalScrollQueueTokensBySurfaceID.removeValue(forKey: surfaceID)
terminalScrollQueuesBySurfaceID.removeValue(forKey: surfaceID)
terminalScrollbackPrefetchStatesBySurfaceID.removeValue(forKey: surfaceID)
terminalScrolledUpBySurfaceID.removeValue(forKey: surfaceID)
deliveredTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID)
pendingTerminalByteEndSeqBySurfaceID.removeValue(forKey: surfaceID)
// Tell the Mac this device is no longer viewing the surface so it stops
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,15 @@ actor RoutingHostRouter {
var surfaceID: String
var text: String
}
struct ScrollToBottomRecord: Sendable {
var workspaceID: String
var surfaceID: String
var clientID: String
}

private(set) var pasteImages: [PasteImageRecord] = []
private(set) var pastes: [PasteRecord] = []
private(set) var scrollToBottomRequests: [ScrollToBottomRecord] = []
private(set) var dismisses: [(notificationIDs: [String], clientID: String?)] = []
/// Reject the Nth (0-based) and later paste_image requests; `nil` accepts all.
private var rejectPasteImageFromIndex: Int?
Expand Down Expand Up @@ -89,18 +96,20 @@ actor RoutingHostRouter {

func recordedPasteImages() -> [PasteImageRecord] { pasteImages }
func recordedPastes() -> [PasteRecord] { pastes }
func recordedScrollToBottomRequests() -> [ScrollToBottomRecord] { scrollToBottomRequests }
func recordedDismisses() -> [(notificationIDs: [String], clientID: String?)] { dismisses }

/// Sendable extract of the request fields the router needs, pulled off the
/// non-Sendable params dictionary before crossing the Task boundary.
struct RequestInfo: Sendable {
var method: String?
var id: String?
var workspaceID: String?
var surfaceID: String?
var clientID: String?
var imageFormat: String?
var text: String?
var notificationIDs: [String]?
var clientID: String?
}

func response(_ info: RequestInfo) async -> Data? {
Expand Down Expand Up @@ -166,6 +175,13 @@ actor RoutingHostRouter {
let text = info.text ?? ""
pastes.append(PasteRecord(surfaceID: surfaceID, text: text))
return try? Self.resultFrame(id: id, result: [:])
case "mobile.terminal.scroll_to_bottom":
scrollToBottomRequests.append(ScrollToBottomRecord(
workspaceID: info.workspaceID ?? "",
surfaceID: info.surfaceID ?? "",
clientID: info.clientID ?? ""
))
return try? Self.resultFrame(id: id, result: [:])
case "notification.dismiss":
dismisses.append((
notificationIDs: info.notificationIDs ?? [],
Expand Down Expand Up @@ -241,11 +257,12 @@ private actor RoutingTransport: CmxByteTransport {
let info = RoutingHostRouter.RequestInfo(
method: parsed?["method"] as? String,
id: parsed?["id"] as? String,
workspaceID: params?["workspace_id"] as? String,
surfaceID: params?["surface_id"] as? String,
clientID: params?["client_id"] as? String,
imageFormat: params?["image_format"] as? String,
text: params?["text"] as? String,
notificationIDs: params?["notification_ids"] as? [String],
clientID: params?["client_id"] as? String
notificationIDs: params?["notification_ids"] as? [String]
)
Task { [router, weak self] in
guard let response = await router.response(info) else {
Expand Down
Loading