From 325c15151b2cbc2f5b24b87fcef8d5f9e8d2bd18 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:57:16 -0700 Subject: [PATCH 1/7] Add iOS scroll-to-bottom toolbar coverage --- .../TerminalAccessoryConfigurationTests.swift | 75 ++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalAccessoryConfigurationTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalAccessoryConfigurationTests.swift index cb04c8a51745..8ead02d70584 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalAccessoryConfigurationTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/TerminalAccessoryConfigurationTests.swift @@ -42,6 +42,8 @@ struct TerminalAccessoryConfigurationTests { let tabIndex = try #require(order.firstIndex(of: id(.tab))) #expect(order[tabIndex + 1] == id(.escape)) #expect(order[tabIndex + 2] == id(.returnKey)) + let pageDownIndex = try #require(order.firstIndex(of: id(.pageDown))) + #expect(order[pageDownIndex + 1] == id(.scrollToBottom)) // Everything is shown on a fresh install, including the now-configurable // modifiers (⇧ among them)/zoom/paste. for action in TerminalInputAccessoryAction.configurableActions { @@ -78,9 +80,31 @@ struct TerminalAccessoryConfigurationTests { #expect(stored == "builtin.\(TerminalInputAccessoryAction.returnKey.rawValue)") let parsed = ToolbarItemID(storageKey: stored) #expect(parsed == id(.returnKey)) - // Appended last: its raw value is the max across all cases. + #expect(TerminalInputAccessoryAction.scrollToBottom.rawValue > TerminalInputAccessoryAction.returnKey.rawValue) + } + + // MARK: - Scroll to Bottom key + + @Test("Scroll to Bottom is shown by default next to navigation keys and has no byte output") + func scrollToBottomDefaultsAndOutput() throws { + let config = TerminalAccessoryConfiguration(defaults: freshDefaults()) + let order = config.displayOrder + + let pageDownIndex = try #require(order.firstIndex(of: id(.pageDown))) + #expect(order[pageDownIndex + 1] == id(.scrollToBottom)) + #expect(config.isEnabled(id(.scrollToBottom))) + #expect(TerminalInputAccessoryAction.scrollToBottom.output == nil) + #expect(TerminalInputAccessoryAction.scrollToBottom.isUserConfigurable) + } + + @Test("Scroll to Bottom's persisted identifier is stable") + func scrollToBottomStableIdentifier() { + let stored = TerminalInputAccessoryAction.scrollToBottom.itemID.storageKey + #expect(stored == "builtin.\(TerminalInputAccessoryAction.scrollToBottom.rawValue)") + let parsed = ToolbarItemID(storageKey: stored) + #expect(parsed == id(.scrollToBottom)) let maxRaw = TerminalInputAccessoryAction.allCases.map(\.rawValue).max() - #expect(TerminalInputAccessoryAction.returnKey.rawValue == maxRaw) + #expect(TerminalInputAccessoryAction.scrollToBottom.rawValue == maxRaw) } // MARK: - Reorder + hide/show round-trips @@ -417,4 +441,51 @@ struct TerminalAccessoryConfigurationTests { let escIndex = try #require(config.displayOrder.firstIndex(of: id(.escape))) #expect(config.displayOrder[escIndex + 1] == id(.returnKey)) } + + // MARK: - Gating test #5: existing v3 layout gains Scroll to Bottom + + @Test("an existing v3 layout without Scroll to Bottom gains it force-enabled after Page Down") + func migratesExistingV3LayoutForceEnablingScrollToBottom() throws { + let defaults = freshDefaults() + // A v3 layout persisted before Scroll to Bottom became configurable. + let preScroll: [TerminalInputAccessoryAction] = [ + .control, .alternate, .command, .shift, .paste, + .tab, .escape, .returnKey, + .home, .end, .pageUp, .pageDown, + .zoomOut, .zoomIn, + ] + defaults.set(preScroll.map { id($0).storageKey }, forKey: "cmux.terminal.toolbar.order.v3") + defaults.set(preScroll.map { id($0).storageKey }, forKey: "cmux.terminal.toolbar.enabled.v3") + + let config = TerminalAccessoryConfiguration(defaults: defaults) + + #expect(config.displayOrder.contains(id(.scrollToBottom))) + #expect(config.isEnabled(id(.scrollToBottom))) + let pageDownIndex = try #require(config.displayOrder.firstIndex(of: id(.pageDown))) + #expect(config.displayOrder[pageDownIndex + 1] == id(.scrollToBottom)) + for action in preScroll { + #expect(config.displayOrder.contains(id(action))) + #expect(config.isEnabled(id(action))) + } + } + + @Test("Scroll to Bottom folded into a v3 layout stays hidden once the user hides it") + func foldedScrollToBottomHonorsLaterHide() { + let defaults = freshDefaults() + let preScroll: [TerminalInputAccessoryAction] = [ + .control, .alternate, .command, .shift, .paste, + .tab, .escape, .returnKey, + .home, .end, .pageUp, .pageDown, + ] + defaults.set(preScroll.map { id($0).storageKey }, forKey: "cmux.terminal.toolbar.order.v3") + defaults.set(preScroll.map { id($0).storageKey }, forKey: "cmux.terminal.toolbar.enabled.v3") + + let config = TerminalAccessoryConfiguration(defaults: defaults) + #expect(config.isEnabled(id(.scrollToBottom))) + + config.setEnabled(id(.scrollToBottom), false) + let reloaded = TerminalAccessoryConfiguration(defaults: defaults) + #expect(!reloaded.isEnabled(id(.scrollToBottom))) + #expect(reloaded.displayOrder.contains(id(.scrollToBottom))) + } } From 32b7d70623e9fb48a6b8e4ecacfcd657a9b52f09 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 20 Jun 2026 20:57:21 -0700 Subject: [PATCH 2/7] Restore iOS scroll-to-bottom toolbar button --- .../GhosttySurfaceView.swift | 27 ++++++++++++++- .../TerminalAccessoryConfiguration.swift | 14 ++++++-- .../TerminalInputTextView.swift | 10 ++++++ ios/cmux/Resources/Localizable.xcstrings | 34 +++++++++++++++++++ 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index 2ee6c4f08855..fba4c8ee02f4 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -215,6 +215,10 @@ public enum TerminalInputAccessoryAction: Int, CaseIterable, Sendable { /// stable; its default on-bar position is curated separately in /// ``defaultConfigurableOrder``. case returnKey + /// Scroll the terminal viewport to the newest output. Appended at the end so + /// existing persisted raw values stay stable; its default on-bar position is + /// curated separately in ``defaultConfigurableOrder``. + case scrollToBottom var title: String { title(isMacRemote: false) } @@ -235,6 +239,8 @@ public enum TerminalInputAccessoryAction: Int, CaseIterable, Sendable { return "" case .composer: return "" + case .scrollToBottom: + return "" case .escape: return String(localized: "terminal.input_accessory.title.escape", defaultValue: "Esc") case .tab: @@ -293,6 +299,7 @@ public enum TerminalInputAccessoryAction: Int, CaseIterable, Sendable { case .zoomOut: return "terminal.inputAccessory.zoomOut" case .zoomIn: return "terminal.inputAccessory.zoomIn" case .composer: return "terminal.inputAccessory.composer" + case .scrollToBottom: return "terminal.inputAccessory.scrollToBottom" case .escape: return "terminal.inputAccessory.escape" case .tab: return "terminal.inputAccessory.tab" case .returnKey: return "terminal.inputAccessory.return" @@ -329,6 +336,8 @@ public enum TerminalInputAccessoryAction: Int, CaseIterable, Sendable { return String(localized: "terminal.input_accessory.paste", defaultValue: "Paste") case .composer: return String(localized: "terminal.input_accessory.composer", defaultValue: "Composer") + case .scrollToBottom: + return String(localized: "terminal.input_accessory.scroll_to_bottom", defaultValue: "Scroll to Bottom") default: return nil } @@ -344,6 +353,8 @@ public enum TerminalInputAccessoryAction: Int, CaseIterable, Sendable { return "doc.on.clipboard" case .composer: return "square.and.pencil" + case .scrollToBottom: + return "arrow.down.to.line" default: return nil } @@ -370,7 +381,7 @@ public enum TerminalInputAccessoryAction: Int, CaseIterable, Sendable { public var output: Data? { switch self { - case .control, .alternate, .command, .shift, .zoomOut, .zoomIn, .paste, .composer: + case .control, .alternate, .command, .shift, .zoomOut, .zoomIn, .paste, .composer, .scrollToBottom: return nil case .escape: return Data([0x1B]) @@ -482,6 +493,7 @@ public enum TerminalInputAccessoryAction: Int, CaseIterable, Sendable { .tilde, .dollar, .slash, .atSign, .pipe, .ctrlZ, .home, .end, .pageUp, .pageDown, + .scrollToBottom, ] + defaultTrailingActions } @@ -511,6 +523,7 @@ public enum TerminalInputAccessoryAction: Int, CaseIterable, Sendable { case .end: return String(localized: "terminal.shortcut.name.end", defaultValue: "End") case .pageUp: return String(localized: "terminal.shortcut.name.pageUp", defaultValue: "Page Up") case .pageDown: return String(localized: "terminal.shortcut.name.pageDown", defaultValue: "Page Down") + case .scrollToBottom: return String(localized: "terminal.shortcut.name.scrollToBottom", defaultValue: "Scroll to Bottom") case .paste: return String(localized: "terminal.input_accessory.paste", defaultValue: "Paste") case .control: return String(localized: "terminal.shortcut.name.control", defaultValue: "Control") case .alternate: return String(localized: "terminal.shortcut.name.alternate", defaultValue: "Option") @@ -919,6 +932,9 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { guard let self else { return } self.handleComposerButtonTap() } + inputProxy.onScrollToBottom = { [weak self] in + self?.scrollToBottom() + } inputProxy.onHideKeyboard = { [weak self] in guard let self else { return } #if DEBUG @@ -2272,6 +2288,15 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { private func scrollInitialOutputToBottomIfNeeded() { guard shouldScrollInitialOutputToBottom, let surface else { return } shouldScrollInitialOutputToBottom = false + scrollToBottom(surface: surface) + } + + private func scrollToBottom() { + guard let surface else { return } + scrollToBottom(surface: surface) + } + + private func scrollToBottom(surface: ghostty_surface_t) { // `ghostty_surface_binding_action` takes the same internal surface lock // as `process_output`/`render_now`. This runs on the MAIN thread (inside // the `processOutput` completion hop), so calling it inline would contend diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift index 45e59d45e361..a28c3341f67d 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalAccessoryConfiguration.swift @@ -92,7 +92,7 @@ public final class TerminalAccessoryConfiguration { let order = v3Order.compactMap(ToolbarItemID.init(storageKey:)) let enabled = (defaults.array(forKey: Self.enabledDefaultsKey) as? [String])? .compactMap(ToolbarItemID.init(storageKey:)) - // ⇧ and Return each became user-configurable after the v3 schema + // ⇧, Return, and Scroll to Bottom each became user-configurable after the v3 schema // shipped, so a layout persisted under the v3 keys has no record of // them and a naive load would leave them hidden. Fold each in next to // its neighbours and show it — once per id, keyed off that id's @@ -144,7 +144,7 @@ public final class TerminalAccessoryConfiguration { } /// Folds the built-ins that became user-configurable *after* the v3 schema - /// shipped (⇧, then Return) into a persisted v3 layout, each adjacent to its + /// shipped (⇧, Return, then Scroll to Bottom) into a persisted v3 layout, each adjacent to its /// canonical neighbours and force-shown, exactly once per id. Returns the /// possibly-folded order and enabled set; `enabled` is passed through /// unchanged (including `nil`) when no fold applies, so a config already @@ -159,7 +159,8 @@ public final class TerminalAccessoryConfiguration { // Each entry is the newly-configurable id plus the predecessors to insert // it after (first present wins; front if none present), mirroring // `defaultConfigurableOrder`: ⇧ trails the other modifiers, Return trails - // Esc (then Tab, then the modifiers). + // Esc (then Tab, then the modifiers), and Scroll to Bottom trails the + // navigation controls. let folds: [(id: ToolbarItemID, anchors: [ToolbarItemID])] = [ (TerminalInputAccessoryAction.shift.itemID, [ TerminalInputAccessoryAction.command.itemID, @@ -173,6 +174,13 @@ public final class TerminalAccessoryConfiguration { TerminalInputAccessoryAction.alternate.itemID, TerminalInputAccessoryAction.control.itemID, ]), + (TerminalInputAccessoryAction.scrollToBottom.itemID, [ + TerminalInputAccessoryAction.pageDown.itemID, + TerminalInputAccessoryAction.end.itemID, + TerminalInputAccessoryAction.home.itemID, + TerminalInputAccessoryAction.returnKey.itemID, + TerminalInputAccessoryAction.escape.itemID, + ]), ] for fold in folds { if let widened = migration.foldingNewlyConfigurable( diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift index c5cf0d55db2f..ff92f9919d49 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift @@ -51,6 +51,9 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { /// Invoked when the composer accessory button is tapped. The host toggles /// the iMessage-style composer above the terminal. var onToggleComposer: (() -> Void)? + /// Invoked when the scroll-to-bottom accessory button is tapped. The host + /// owns the surface binding because this responder has no Ghostty surface. + var onScrollToBottom: (() -> Void)? /// Fired by the pinned HIDE button: temporarily hides the toolbar + composer /// until the next terminal tap. var onHideChrome: (() -> Void)? @@ -1065,6 +1068,13 @@ final class TerminalInputTextView: UIView, UIKeyInput, UITextInput { return } + if action == .scrollToBottom { + disarmAllModifiers() + refreshAccessoryButtonStyles() + onScrollToBottom?() + return + } + if controlAccessoryArmed, !action.isModifier { if !controlAccessorySticky { diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index a7fb7961b298..aee36b92a843 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -4506,6 +4506,23 @@ } } }, + "terminal.input_accessory.scroll_to_bottom": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scroll to Bottom" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一番下までスクロール" + } + } + } + }, "mobile.pairing.accountMismatch": { "extractionState": "manual", "localizations": { @@ -6155,6 +6172,23 @@ } } }, + "terminal.shortcut.name.scrollToBottom": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Scroll to Bottom" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "一番下までスクロール" + } + } + } + }, "mobile.deviceTree.online": { "extractionState": "manual", "localizations": { From 712136b86ee6a46aa0605b87f8eebd67f50c71d2 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:52:26 -0700 Subject: [PATCH 3/7] Add iOS scroll-to-bottom RPC coverage --- .../ComposerSubmitRoutingTestSupport.swift | 18 ++++++++++++++++++ .../TerminalScrollDeliveryQueueTests.swift | 15 +++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift index c3645f4c3513..39688c855da5 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/ComposerSubmitRoutingTestSupport.swift @@ -40,9 +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] = [] /// Reject the Nth (0-based) and later paste_image requests; `nil` accepts all. private var rejectPasteImageFromIndex: Int? private var holdFirstPasteImage = false @@ -89,13 +95,16 @@ actor RoutingHostRouter { func recordedPasteImages() -> [PasteImageRecord] { pasteImages } func recordedPastes() -> [PasteRecord] { pastes } + func recordedScrollToBottomRequests() -> [ScrollToBottomRecord] { scrollToBottomRequests } /// 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? } @@ -163,6 +172,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 "mobile.events.unsubscribe", "mobile.terminal.replay", "mobile.terminal.viewport": return try? Self.resultFrame(id: id, result: [:]) default: @@ -232,7 +248,9 @@ 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 ) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalScrollDeliveryQueueTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalScrollDeliveryQueueTests.swift index 1a7eecf24c73..6ee54423eff3 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalScrollDeliveryQueueTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalScrollDeliveryQueueTests.swift @@ -110,3 +110,18 @@ import Testing var queueAfterStaleCompletion = try #require(store.terminalScrollQueuesBySurfaceID[surfaceID]) #expect(queueAfterStaleCompletion.completeInFlight() == pending) } + +@MainActor +@Test func scrollToBottomSendsAbsoluteMobileRPC() async throws { + let router = RoutingHostRouter() + let store = try await makeRoutingConnectedStore(router: router) + + await store.scrollTerminalToBottom(surfaceID: RoutingHostRouter.terminalA) + + let requests = await router.recordedScrollToBottomRequests() + let request = try #require(requests.first) + #expect(requests.count == 1) + #expect(request.workspaceID == RoutingHostRouter.workspaceID) + #expect(request.surfaceID == RoutingHostRouter.terminalA) + #expect(!request.clientID.isEmpty) +} From b2526fb0fcf4321b8ed140ef8fa23d26ce6a82e2 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:52:31 -0700 Subject: [PATCH 4/7] Route iOS scroll-to-bottom through Mac surface --- ...hellComposite+TerminalScrollDelivery.swift | 20 +++++++++++++++ .../GhosttySurfaceRepresentable.swift | 9 +++++++ .../GhosttySurfaceView.swift | 10 +++++++- .../Surface/TerminalSurface+Mobile.swift | 12 +++++++++ Sources/Mobile/MobileHostService.swift | 3 ++- Sources/TerminalController.swift | 25 +++++++++++++++++++ 6 files changed, 77 insertions(+), 2 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalScrollDelivery.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalScrollDelivery.swift index 251f1a7c68c2..cd7f90703941 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalScrollDelivery.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+TerminalScrollDelivery.swift @@ -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() diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift index 41525293e8af..ec31be55af0f 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/GhosttySurfaceRepresentable.swift @@ -311,6 +311,15 @@ struct GhosttySurfaceRepresentable: UIViewRepresentable { } } + func ghosttySurfaceViewDidRequestScrollToBottom(_ surfaceView: GhosttySurfaceView) { + // Forward to the Mac's real surface so the phone's mirror and the + // authoritative Mac viewport agree after the local preview jumps. + Task { @MainActor [weak self] in + guard let self else { return } + await self.store?.scrollTerminalToBottom(surfaceID: self.surfaceID) + } + } + func ghosttySurfaceViewDidRequestToolbarSettings(_ surfaceView: GhosttySurfaceView) { // The "customize" button on the keyboard toolbar. The editor view // lives in this UI package, so present it here (the terminal package diff --git a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift index fba4c8ee02f4..06d21acd6b3e 100644 --- a/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +++ b/Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift @@ -52,6 +52,9 @@ public protocol GhosttySurfaceViewDelegate: AnyObject { /// The Mac's libghostty self-gates: a normal screen treats it as a harmless /// empty selection. Optional. func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didTapAtCol col: Int, row: Int) + /// Request the Mac's real surface to jump its viewport to newest output. + /// Optional. + func ghosttySurfaceViewDidRequestScrollToBottom(_ surfaceView: GhosttySurfaceView) /// The user tapped the "customize" button at the end of the input-accessory /// bar; the host should present the toolbar shortcuts editor. Optional. func ghosttySurfaceViewDidRequestToolbarSettings(_ surfaceView: GhosttySurfaceView) @@ -79,6 +82,7 @@ public protocol GhosttySurfaceViewDelegate: AnyObject { public extension GhosttySurfaceViewDelegate { func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didScrollLines lines: Double, atCol col: Int, row: Int) {} func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didTapAtCol col: Int, row: Int) {} + func ghosttySurfaceViewDidRequestScrollToBottom(_ surfaceView: GhosttySurfaceView) {} func ghosttySurfaceViewDidRequestToolbarSettings(_ surfaceView: GhosttySurfaceView) {} func ghosttySurfaceView(_ surfaceView: GhosttySurfaceView, didPasteImage data: Data, format: String) {} /// Default no-op so hosts without a composer can ignore the toggle request. @@ -2294,6 +2298,7 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { private func scrollToBottom() { guard let surface else { return } scrollToBottom(surface: surface) + delegate?.ghosttySurfaceViewDidRequestScrollToBottom(self) } private func scrollToBottom(surface: ghostty_surface_t) { @@ -2307,10 +2312,13 @@ public final class GhosttySurfaceView: UIView, TerminalSurfaceHosting { // `process_output` also preserves ordering. The return was already // discarded. let action = "scroll_to_bottom" - Self.outputQueue.async { + Self.outputQueue.async { [weak self] in action.withCString { pointer in _ = ghostty_surface_binding_action(surface, pointer, UInt(action.utf8.count)) } + DispatchQueue.main.async { + self?.drawForWakeup() + } } } diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Mobile.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Mobile.swift index 772b2c9a6d3e..a726c74c652e 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Mobile.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Mobile.swift @@ -27,6 +27,18 @@ extension TerminalSurface { ghostty_surface_mouse_scroll(surface, 0, deltaLines, 0) } + /// Jump the authoritative Mac surface to newest output for a paired phone. + /// This uses the same Ghostty binding as the desktop scroll-to-bottom action, + /// so normal-screen scrollback semantics stay owned by the real surface. + @MainActor + public func mobileScrollToBottom() { + guard let surface = liveSurfaceForGhosttyAccess(reason: "mobileScrollToBottom") else { return } + let action = "scroll_to_bottom" + action.withCString { pointer in + _ = ghostty_surface_binding_action(surface, pointer, UInt(action.utf8.count)) + } + } + /// Forward a mobile tap to this real surface as a left mouse click at the /// given grid cell. libghostty does the mode-correct thing: a program with /// mouse reporting (alt-screen TUIs like lazygit/htop/fzf) gets an encoded diff --git a/Sources/Mobile/MobileHostService.swift b/Sources/Mobile/MobileHostService.swift index 9b40e93fa8be..6e20f5b43366 100644 --- a/Sources/Mobile/MobileHostService.swift +++ b/Sources/Mobile/MobileHostService.swift @@ -1394,7 +1394,8 @@ final class MobileHostService { "mobile.terminal.paste_image", "terminal.paste_image", "mobile.terminal.replay", "terminal.replay", "mobile.terminal.viewport", "terminal.viewport", - "mobile.terminal.scroll", "terminal.scroll": + "mobile.terminal.scroll", "terminal.scroll", + "mobile.terminal.scroll_to_bottom", "terminal.scroll_to_bottom": return ticketTerminalAuthorizationError( authorization: authorization, workspaceSelection: workspaceSelection.value, diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 94aef45928c7..eabe0638175b 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -12897,6 +12897,8 @@ class TerminalController { result = v2MobileTerminalViewport(params: request.params) case "mobile.terminal.scroll", "terminal.scroll": result = v2MobileTerminalScroll(params: request.params) + case "mobile.terminal.scroll_to_bottom", "terminal.scroll_to_bottom": + result = v2MobileTerminalScrollToBottom(params: request.params) case "mobile.terminal.mouse", "terminal.mouse": result = v2MobileTerminalMouse(params: request.params) case "workspace.action": @@ -13534,6 +13536,29 @@ class TerminalController { )) } + /// Jump the Mac-owned terminal viewport to newest output for a paired phone. + /// This is separate from relative scroll because the button needs an + /// absolute action, not a guessed large delta. + func v2MobileTerminalScrollToBottom(params: [String: Any]) -> V2CallResult { + if let error = mobileWorkspaceIDValidationError(params: params) { + return error + } + if let error = mobileTerminalAliasValidationError(params: params) { + return error + } + guard let resolved = mobileResolveWorkspaceAndSurface(params: params, requireTerminal: true), + let surfaceId = resolved.surfaceId, + let terminalPanel = resolved.workspace.terminalPanel(for: surfaceId) else { + return .err(code: "not_found", message: "Terminal surface not found", data: nil) + } + terminalPanel.surface.mobileScrollToBottom() + MobileTerminalRenderObserver.shared.noteTerminalBytes(surfaceID: terminalPanel.id) + return .ok([ + "workspace_id": resolved.workspace.id.uuidString, + "surface_id": surfaceId.uuidString, + ]) + } + func v2MobileTerminalMouse(params: [String: Any]) -> V2CallResult { if let error = mobileWorkspaceIDValidationError(params: params) { return error From f4efc111deadf5f9e7f02d4b40e1a73ca625086c Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Sat, 20 Jun 2026 22:00:54 -0700 Subject: [PATCH 5/7] Refresh Swift file length budget for scroll button --- .github/swift-file-length-budget.tsv | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 700c1661f861..e7b0bc5ed247 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -4,7 +4,7 @@ 34334 CLI/cmux.swift 17706 Sources/AppDelegate.swift 16052 Sources/ContentView.swift -13992 Sources/TerminalController.swift +14017 Sources/TerminalController.swift 12695 Sources/Workspace.swift 12144 cmuxTests/AppDelegateShortcutRoutingTests.swift 11841 Sources/GhosttyTerminalView.swift @@ -28,7 +28,7 @@ 3937 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 @@ -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 2355 Sources/FileExplorerView.swift 2328 cmuxTests/CJKIMEInputTests.swift 2257 Sources/TerminalNotificationStore.swift @@ -57,7 +57,7 @@ 1687 cmuxTests/MarkdownPanelTests.swift 1680 cmuxUITests/BrowserPaneNavigationKeybindUITests.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 From 9b84342b6b4c90c3fbbb7960c89d5992a5145c49 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 23 Jun 2026 03:11:03 -0700 Subject: [PATCH 6/7] Restore iOS terminal jump-to-bottom button --- .../MobileTerminalRenderGrid.swift | 27 ++++-- .../MobileTerminalRenderGridTests.swift | 78 +++++++++++++++++ ...hellComposite+TerminalOutputDelivery.swift | 4 + .../MobileShellComposite.swift | 15 ++++ .../TerminalOutputDeliveryQueueTests.swift | 52 ++++++++++++ .../GhosttySurfaceRepresentable.swift | 5 ++ .../WorkspaceDetailView.swift | 3 +- .../GhosttySurfaceView.swift | 83 +++++++++++++++++++ .../ScrollToBottomButton.swift | 61 ++++++++++++++ .../TerminalSurfaceNativeViewing.swift | 10 +++ .../Surface/TerminalSurface+Mobile.swift | 3 +- Sources/GhosttyTerminalView.swift | 6 ++ .../Mobile/MobileTerminalRenderObserver.swift | 16 +++- cmuxTests/MobileHostAuthorizationTests.swift | 15 ++++ 14 files changed, 368 insertions(+), 10 deletions(-) create mode 100644 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/ScrollToBottomButton.swift diff --git a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift index 5c135cb4978d..114bfcae0e1a 100644 --- a/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift +++ b/Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift @@ -43,6 +43,15 @@ public struct MobileTerminalRenderGridFrame: Codable, Equatable, Sendable { /// Styled spans for the scrollback lines, row index `0..